Règlages des alertes dans Monitor_Meudon

This commit is contained in:
2025-09-24 08:51:24 +02:00
parent 072a0cbbc5
commit d776f1bf12
3 changed files with 479 additions and 163 deletions

17
.env
View File

@@ -21,7 +21,12 @@ ALERT_OK_SMS=0
GYRO_SMS_MIN_SEC=120 GYRO_SMS_MIN_SEC=120
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# Délais
ALERT_CONTINUOUS_MINUTES=30
ALERT_LOOKBACK_MINUTES=120
# Logs
LOGLEVEL=INFO
# paramètres mail # paramètres mail
SMTP_HOST=ssl0.ovh.net SMTP_HOST=ssl0.ovh.net
@@ -43,8 +48,12 @@ OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR OVH_SMS_SENDER=DOMO91FR
SMS_RECEIVER=+33635164680 SMS_RECEIVER=+33635164680
ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_SACLAY=Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680
# ---- Reserve SMS
#Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 ALERT_SMS_CLIENT_TO_MEUDON=Sekou:+33625903364,Damien:+33680388259
#Sekou:+33625903364,Damien:+33680388259 ALERT_SMS_CLIENT_TO_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
# Activer/désactiver globalement lenvoi client
ALERT_SMS_CLIENT_ENABLED=1

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ========= Site ========= # ========= Site =========
SITE = "Meudon" SITE = "Meudon"
PROGRAM_NAME = f"Monitor_{SITE}" PROGRAM_NAME = f"Monitor_{SITE}"
@@ -11,10 +12,13 @@ from email.message import EmailMessage
from datetime import datetime from datetime import datetime
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False) load_dotenv(find_dotenv(usecwd=True), override=False)
from utils_sms import normaliser_sms
# MySQL # MySQL
import mysql.connector import mysql.connector
from mysql.connector import Error as MySQLError from mysql.connector import Error as MySQLError
from dotenv import load_dotenv
load_dotenv()
# OVH (SMS) # OVH (SMS)
try: try:
@@ -34,15 +38,16 @@ except Exception:
_mqtt_ok = False _mqtt_ok = False
# ========= Logger ========= # ========= Logger =========
level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO)
log = logging.getLogger(PROGRAM_NAME.lower()) log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers: if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
# ========= DB utils ========= # ========= DB utils =========
def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
""" """
Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde. Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde.
Retourne True si une nouvelle alerte a été créée (→ notifier). Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client).
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -51,20 +56,19 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
) )
if cur.fetchone(): if cur.fetchone():
cur.close() cur.close()
return False # déjà ouverte → pas de notif return False # déjà ouverte
cur.execute( cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')", f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt.strftime('%Y-%m-%d %H:%M:%S')) (sonde, dt.strftime('%Y-%m-%d %H:%M:%S'))
) )
conn.commit() conn.commit()
cur.close() cur.close()
return True # nouvelle alerte → notifier return True
def close_alert(conn, table_alertes: str, sonde: str) -> bool: def close_alert(conn, table_alertes: str, sonde: str) -> bool:
""" """
Ferme l'alerte 'En cours' si présente. Ferme l'alerte 'En cours' si présente.
Retourne True si une alerte est passée à 'Acquitté' (→ notifier). Retourne True si une alerte est passée à 'Acquitté' (→ notifier par mail).
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -76,10 +80,10 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool:
changed = (cur.rowcount == 1) changed = (cur.rowcount == 1)
conn.commit() conn.commit()
cur.close() cur.close()
return changed # True → notifier, False → rien return changed
def get_db(): def get_db():
cnx = mysql.connector.connect( return mysql.connector.connect(
host=os.getenv("DB_HOST"), host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"), user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"), password=os.getenv("DB_PASS"),
@@ -87,7 +91,6 @@ def get_db():
port=int(os.getenv("DB_PORT", "3306")), port=int(os.getenv("DB_PORT", "3306")),
autocommit=True, autocommit=True,
) )
return cnx
def lire_sondes_depuis_db(site: str): def lire_sondes_depuis_db(site: str):
table = site table = site
@@ -114,29 +117,9 @@ def lire_sondes_depuis_db(site: str):
finally: finally:
cnx.close() cnx.close()
def lire_seuils_depuis_db(site: str):
sql = """
SELECT Sonde, Temp_Max
FROM Chambres_froides
WHERE Lieu=%s AND Etat='ON'
"""
cnx = get_db()
seuils = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
for sonde, s in cur.fetchall():
seuils[str(sonde)] = float(s)
return seuils
except MySQLError as err:
log.exception("Erreur DB (lire_seuils_depuis_db): %s", err)
return seuils
finally:
cnx.close()
def lire_cfg_chambres(site: str): def lire_cfg_chambres(site: str):
""" """
Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}} Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
depuis Chambres_froides pour le site. depuis Chambres_froides pour le site.
""" """
sql = """ sql = """
@@ -145,10 +128,10 @@ def lire_cfg_chambres(site: str):
WHERE Lieu=%s WHERE Lieu=%s
""" """
cnx = get_db() cnx = get_db()
cfg = {} cfg: dict[str, dict] = {}
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute(sql, (site,)) cur.execute(sql, (site, ))
for sonde, temp_max, etat, en_entretien in cur.fetchall(): for sonde, temp_max, etat, en_entretien in cur.fetchall():
cfg[str(sonde)] = { cfg[str(sonde)] = {
"temp_max": float(temp_max), "temp_max": float(temp_max),
@@ -164,7 +147,7 @@ def lire_cfg_chambres(site: str):
def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0): def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0):
""" """
Retourne (is_on: bool, trigger: (sonde, temp, seuil) | None) Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None)
""" """
for row in last_values: for row in last_values:
sonde = str(row["Sonde"]) sonde = str(row["Sonde"])
@@ -172,61 +155,80 @@ def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis
if not meta or not meta["active"] or meta["entretien"]: if not meta or not meta["active"] or meta["entretien"]:
continue continue
temp = float(row["Temperature"]) temp = float(row["Temperature"])
if temp > float(meta["temp_max"]) + 0.0: if temp > float(meta["temp_max"]) + float(hysteresis):
return True, (sonde, temp, float(meta["temp_max"])) return True, (sonde, temp, float(meta["temp_max"]))
return False, None return False, None
def lire_seuils_depuis_db(site: str):
sql = """
SELECT Sonde, Temp_Max
FROM Chambres_froides
WHERE Lieu=%s AND Etat='ON'
"""
cnx = get_db()
seuils = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site, ))
for sonde, s in cur.fetchall():
seuils[str(sonde)] = float(s)
return seuils
except MySQLError as err:
log.exception("Erreur DB (lire_seuils_depuis_db): %s", err)
return seuils
finally:
cnx.close()
# --- Dépassement continu (configurable) ---
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes.
CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30)
LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3))
"""
CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))
LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3))))
table = site table = site
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
WHERE Sonde=%s WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC ORDER BY Date DESC
LIMIT 1 """, (sonde, LOOKBACK))
""", (sonde,)) rows = cur.fetchall()
last = cur.fetchone() if not rows:
if not last:
return False return False
last_temp, last_date = float(last[0]), last[1]
last_temp, last_dt = float(rows[0][0]), rows[0][1]
if last_temp <= float(seuil): if last_temp <= float(seuil):
return False return False
cur.execute(f""" # Début de la séquence continue > seuil
SELECT MIN(Date) start_dt = last_dt
FROM `{table}` for temp, d in rows[1:]:
WHERE Sonde=%s if float(temp) > float(seuil):
AND Temperature > %s start_dt = d
AND Date >= (NOW() - INTERVAL 120 MINUTE) else:
""", (sonde, float(seuil))) break
first_over = cur.fetchone()[0]
if not first_over: tzinfo = getattr(start_dt, "tzinfo", None)
return False now = dt.datetime.now(tz=tzinfo)
dur_min = (now - start_dt).total_seconds() / 60.0
log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d",
sonde, start_dt, now, dur_min, CONT_MIN)
return dur_min >= CONT_MIN
now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None))
return (now - first_over) >= dt.timedelta(minutes=30)
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min): %s", err) log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err)
return False return False
finally: finally:
cnx.close() cnx.close()
def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1")
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (any_alert_open): %s", err)
return False
finally:
cnx.close()
# ========= Helpers listes/numéros ========= # ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]: def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -234,7 +236,7 @@ def _split_list(raw: str | None) -> list[str]:
def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for tok in re.split(r"[;,]", raw or ""): for tok in re.split(r"[;,]", raw or ""):
tok = tok.strip() tok = tok.strip().strip('"').strip("'")
if not tok: if not tok:
continue continue
if ":" in tok: if ":" in tok:
@@ -254,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
# ========= Notifier (SMS + Mail) ========= # ========= Notifier (SMS interne + SMS client + Mail) =========
class Notifier: class Notifier:
def __init__(self): def __init__(self):
# OVH SMS # OVH SMS
@@ -270,11 +272,22 @@ class Notifier:
) )
self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER") self.ovh_sender = os.getenv("OVH_SMS_SENDER")
raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO")) raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO"))
self.sms_labeled = _parse_labeled_phones(raw_sms) self.sms_labeled = _parse_labeled_phones(raw_sms)
else: else:
self.sms_labeled = [] self.sms_labeled = []
# SMS CLIENTS (site-spécifique + génériques + compat FR)
raw_sms_client = (
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or
os.getenv("ALERT_SMS_CLIENT_TO") or
os.getenv(f"ALERTE_CLIENT_{SITE}") or
os.getenv("ALERTE_CLIENT")
)
self.sms_client_labeled = _parse_labeled_phones(raw_sms_client)
self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
# SMTP # SMTP
self.smtp_host = os.getenv("SMTP_HOST") self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT","465")) self.smtp_port = int(os.getenv("SMTP_PORT","465"))
@@ -282,9 +295,9 @@ class Notifier:
self.smtp_pass = os.getenv("SMTP_PASS") self.smtp_pass = os.getenv("SMTP_PASS")
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "") raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "")
self.mail_to = _split_list(raw_mail_to) self.mail_to = _split_list(raw_mail_to)
self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user) self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.upper()}") or os.getenv("MAIL_FROM") or self.smtp_user)
self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to]) self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to])
@@ -297,10 +310,13 @@ class Notifier:
log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).")
return False return False
# ✅ Normalisation GSM-7 + préfixe site
message = normaliser_sms(message, prefix=SITE)
payload = { payload = {
"sender": self.ovh_sender, "sender": self.ovh_sender,
"receivers": receivers, "receivers": receivers,
"message": message[:1600], "message": message,
"priority": "high", "priority": "high",
"coding": "7bit", "coding": "7bit",
"class": "phoneDisplay", "class": "phoneDisplay",
@@ -330,6 +346,45 @@ class Notifier:
except Exception as err: except Exception as err:
log.exception("Echec envoi SMS OVH: %s", err); return False log.exception("Echec envoi SMS OVH: %s", err); return False
def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool:
if not self.ovh_enabled:
log.warning("SMS client: OVH non configuré."); return False
if not self.sms_client_enabled or not self.sms_client_labeled:
log.info("SMS client: désactivé ou aucun destinataire."); return False
only = os.getenv("ALERT_SMS_CLIENT_ONLY")
if only:
allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()}
labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)]
else:
labeled = self.sms_client_labeled
receivers = [num for (_n, num) in labeled]
if not receivers:
log.info("SMS client: filtre vide → aucun envoi."); return False
message = normaliser_sms(message, prefix=SITE)
payload = {
"sender": self.ovh_sender,
"receivers": receivers,
"message": message,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True,
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
}
try:
log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled))
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids"))
return True
except Exception as err:
log.exception("Echec SMS CLIENT OVH: %s", err); return False
def send_email(self, subject: str, body: str) -> bool: def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled: if not self.smtp_enabled:
log.warning("SMTP non configuré, email non envoyé."); return False log.warning("SMTP non configuré, email non envoyé."); return False
@@ -385,32 +440,48 @@ def now_paris() -> dt.datetime:
def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris() when = when or now_paris()
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil" subject = f"[ALERTE {site}] {sonde} au-dessus du seuil"
lines = [subject + ":", f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})", f"Site: {site}", f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"] lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"
]
txt = "\n".join(lines) txt = "\n".join(lines)
return subject, txt, txt return subject, txt, txt
def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris() when = when or now_paris()
subject = f"[OK {site}] {sonde} revenue normale" subject = f"[OK {site}] {sonde} revenue normale"
lines = [subject + ":", f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}", f"Site: {site}", f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"] lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"
]
txt = "\n".join(lines) txt = "\n".join(lines)
return subject, txt, txt return subject, txt, txt
def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
when = when or now_paris()
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}"
# ========= Gyrophare MQTT ========= # ========= Gyrophare MQTT =========
class MQTTPublisher: class MQTTPublisher:
def __init__(self, site: str): def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) self.enabled = bool(_mqtt_ok)
self.site = site self.site = site
self.topic = ( self.topic = (
os.getenv(f"GYRO_MQTT_TOPIC_{site}") or os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
os.getenv("GYRO_MQTT_TOPIC") or os.getenv("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd" f"Sondes/{site}/Gyro/cmd"
) )
self.last_state: bool | None = None self.last_state: bool | None = None
if not self.enabled: if not self.enabled:
log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou paho-mqtt absent).") log.info("Gyro MQTT désactivé (paho-mqtt absent).")
return return
if not self.topic: if not self.topic:
log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site) log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site)
@@ -423,25 +494,20 @@ class MQTTPublisher:
pwd = os.getenv("MQTT_PASS") pwd = os.getenv("MQTT_PASS")
tls = (os.getenv("MQTT_TLS", "0") == "1") tls = (os.getenv("MQTT_TLS", "0") == "1")
# --- Création du client MQTT : compatible paho 1.x et 2.x ---
cbver = getattr(mqtt, "CallbackAPIVersion", None) cbver = getattr(mqtt, "CallbackAPIVersion", None)
if cbver is not None: if cbver is not None:
# paho >= 2.x : on choisit la meilleure constante disponible
api_v = ( api_v = (
getattr(cbver, "VERSION2", None) # paho 2.x getattr(cbver, "VERSION2", None)
or getattr(cbver, "V5", None) # certaines builds or getattr(cbver, "V5", None)
or getattr(cbver, "v5", None) # fallback or getattr(cbver, "v5", None)
or getattr(cbver, "V311", None) # dernier recours or getattr(cbver, "V311", None)
) )
try: try:
self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client() self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client()
except TypeError: except TypeError:
# vieux paho ne supporte pas largument callback_api_version
self.client = mqtt.Client() self.client = mqtt.Client()
else: else:
# paho 1.x
self.client = mqtt.Client() self.client = mqtt.Client()
# ------------------------------------------------------------
if user and pwd: if user and pwd:
self.client.username_pw_set(user, pwd) self.client.username_pw_set(user, pwd)
@@ -461,7 +527,6 @@ class MQTTPublisher:
return return
if self.last_state is not None and self.last_state == on: if self.last_state is not None and self.last_state == on:
return return
payload = "ON" if on else "OFF" payload = "ON" if on else "OFF"
try: try:
r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
@@ -474,68 +539,251 @@ class MQTTPublisher:
except Exception as e: except Exception as e:
log.exception("MQTT publish erreur: %s", e) log.exception("MQTT publish erreur: %s", e)
# ========= Contrôleur Gyro réactif =========
import enum, threading
class _GyroState(enum.Enum):
IDLE = 0
PULSE_ON = 1
COOLDOWN = 2
class GyroPulseController:
"""
Boucle rapide indépendante :
- MODE CONTINU (défaut) : ON tant que lalarme persiste, OFF quand normal confirmé.
- MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que lalarme persiste.
Ajouts :
- SMS ALERTE immédiat à lallumage
- SMS OK immédiat à lextinction (activé par défaut)
"""
def __init__(self, site: str, beacon, notifier, *,
check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")),
pulse_sec: int = int(os.getenv("GYRO_PULSE_SEC", "60")),
cooldown_sec: int = int(os.getenv("GYRO_COOLDOWN_SEC", "600")),
normal_confirm: int = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))):
self.site = site
self.beacon = beacon
self.notifier = notifier
self.check_sec = check_sec
self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec
self.normal_confirm = normal_confirm
self.state = _GyroState.IDLE
self._t_pulse_end = 0.0
self._t_cooldown_end = 0.0
self._normal_count = 0
self._stop = threading.Event()
self._thread = None
self._current = None # dernier état effectif
# Anti-spam SMS & SMS OK activé par défaut
self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi}
self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1")
# Conserver le dernier déclencheur (pour SMS OK)
self._last_trigger: tuple[str, float, float] | None = None # (sonde, temp, seuil)
def _set_gyro(self, on: bool):
if self._current is not on:
self.beacon.set(on)
self._current = on
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info("GyroPulseController démarré (site=%s, check=%ss, pulse=%ss, cooldown=%ss, confirm=%d)",
self.site, self.check_sec, self.pulse_sec, self.cooldown_sec, self.normal_confirm)
def stop(self):
self._stop.set()
def _sms_can_send(self, sonde: str) -> bool:
t = time.time()
last = self._last_sms.get(sonde, 0.0)
if (t - last) >= self._sms_min_sec:
self._last_sms[sonde] = t
return True
return False
def _send_alert_sms(self, trigger: tuple[str, float, float] | None):
if not trigger:
return
sonde, temp, seuil = trigger
if self._sms_can_send(sonde):
_, sms_text, _ = build_alert_text(self.site, sonde, temp, seuil, when=now_paris())
self.notifier.send_sms(sms_text)
def _send_ok_sms_from_last_trigger(self):
if not self._send_ok or not self._last_trigger:
return
sonde, _temp_prev, seuil = self._last_trigger
# Température courante pour le SMS OK
rows = lire_sondes_depuis_db(self.site)
curr_temp = None
for r in rows:
if str(r["Sonde"]) == sonde:
curr_temp = float(r["Temperature"]); break
if curr_temp is None:
curr_temp = seuil - 0.1 # fallback léger
if self._sms_can_send(sonde):
_, sms_text, _ = build_ok_text(self.site, sonde, curr_temp, seuil, when=now_paris())
self.notifier.send_sms(sms_text)
self._last_trigger = None # reset
def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]:
last_rows = lire_sondes_depuis_db(self.site)
cfg = lire_cfg_chambres(self.site)
return compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
def _run(self):
while not self._stop.is_set():
now = time.time()
try:
active, trigger = self._is_alarm_now()
except Exception as e:
log.exception("Gyro fast-loop: erreur lecture état: %s", e)
active, trigger = (False, None)
if self.state == _GyroState.IDLE:
if active:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self._normal_count = 0
self.state = _GyroState.PULSE_ON
self._last_trigger = trigger
if trigger:
s, t, se = trigger
log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)",
s, t, se, "CONTINU" if os.getenv("GYRO_MODE_CONTINUOUS","1")=="1" else "PULSE")
# SMS alerte immédiat
self._send_alert_sms(trigger)
elif self.state == _GyroState.PULSE_ON:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self._set_gyro(False)
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)")
# SMS OK immédiat
self._send_ok_sms_from_last_trigger()
else:
self._normal_count = 0
if os.getenv("GYRO_MODE_CONTINUOUS", "1") != "1":
if now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
self.state = _GyroState.COOLDOWN
log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec)
elif self.state == _GyroState.COOLDOWN:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro: retour IDLE (plus dalerte)")
else:
self._normal_count = 0
if now >= self._t_cooldown_end:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self.state = _GyroState.PULSE_ON
log.info("Gyro → ON (re-pulse)")
time.sleep(self.check_sec)
# ========= Notifs haut-niveau ========= # ========= Notifs haut-niveau =========
notifier = Notifier() notifier = Notifier()
beacon = MQTTPublisher(SITE) beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil) """
notifier.send_sms(sms_text) MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base.
notifier.send_email(subject, email_body) + SMS CLIENT couplé (ALERTE_CLIENT_{SITE}).
(Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.)
"""
subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) # MAIL (≥30 min)
# SMS client couplé au mail 30 min
if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1":
client_msg = build_client_alert_sms(site, sonde, temp, seuil)
notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}")
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil) """
notifier.send_sms(sms_text) MAIL lorsque lalerte est acquittée en base.
(Le SMS "OK" est envoyé immédiatement par la boucle gyro.)
"""
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) # mail d'acquittement
if os.getenv("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text)
# ========= Cycle & boucle ========= # ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE): def run_monitor_cycle(site: str = SITE):
# 1) Lecture mesures + config # 1) Lecture dernières mesures + config
sondes = lire_sondes_depuis_db(site) last_rows = lire_sondes_depuis_db(site)
cfg = lire_cfg_chambres(site) cfg = lire_cfg_chambres(site)
# 2) Gyro instantané # 2) Info: état instantané (le gyro est piloté par la boucle rapide)
try: try:
gyro_on, trigger = compute_site_alarm(sondes, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
if trigger: if trigger:
s, t, se = trigger s, t, se = trigger
log.info("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se) log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se)
else: else:
log.info("Gyro %s => OFF (aucun dépassement)", site) log.info("Aucun dépassement au moment du cycle")
beacon.set(gyro_on)
except Exception as e: except Exception as e:
log.exception("Erreur calcul/publish gyrophare: %s", e) log.exception("Erreur calcul alarme (info): %s", e)
# 3) Alertes officielles” (inchangées) avec temporisation 30 min # 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client
# On reconstitue un dict seuils à partir de la cfg (et on ignore les sondes OFF).
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in sondes: for r in last_rows:
nom = str(r["Sonde"]) nom = str(r["Sonde"])
temp = float(r["Temperature"]) temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) seuil = float(seuils.get(nom, 6.0))
now = now_paris() now = now_paris()
if temp > seuil: if temp > seuil:
if depassement_depuis_30min(site, nom, seuil): if depassement_depuis_30min(site, nom, seuil):
# Ouvrir si pas déjà ouverte → notifier seulement si création
try: try:
conn = get_db() conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now): if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil) notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client
finally: finally:
conn.close() conn.close()
else: else:
# Fermer si ouverte → notifier seulement si fermeture réelle
try: try:
conn = get_db() conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom): if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil) notifier_acquittement(site, nom, temp, seuil) # MAIL acquittement
finally: finally:
conn.close() conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300): def run_monitor_loop(site: str = SITE, period_sec: int = 300):
log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec) log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
# Démarrage du contrôleur gyro rapide (thread) + notifier pour SMS immédiats
try:
global _gyro_controller
_gyro_controller = GyroPulseController(site, beacon, notifier)
_gyro_controller.start()
except Exception as e:
log.exception("Impossible de démarrer le GyroPulseController: %s", e)
while True: while True:
t0 = time.time() t0 = time.time()
try: try:
@@ -556,10 +804,17 @@ if __name__ == "__main__":
p.add_argument("--once", action="store_true") p.add_argument("--once", action="store_true")
args = p.parse_args() args = p.parse_args()
if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)") if args.test_sms:
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK") n = Notifier()
elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) n.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0) elif args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(SITE, "Chambre_N1", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(SITE, "Chambre_N1", -15.2, -15.0)
else: else:
if args.once: run_monitor_cycle(SITE) if args.once:
else: run_monitor_loop(SITE, period_sec=args.period) run_monitor_cycle(SITE)
else:
run_monitor_loop(SITE, period_sec=args.period)

View File

@@ -38,15 +38,16 @@ except Exception:
_mqtt_ok = False _mqtt_ok = False
# ========= Logger ========= # ========= Logger =========
level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO)
log = logging.getLogger(PROGRAM_NAME.lower()) log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers: if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
# ========= DB utils ========= # ========= DB utils =========
def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
""" """
Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde. Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde.
Retourne True si une nouvelle alerte a été créée (→ notifier par mail). Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client).
""" """
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
@@ -178,46 +179,49 @@ def lire_seuils_depuis_db(site: str):
finally: finally:
cnx.close() cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: int = 180) -> bool: # --- Dépassement continu (configurable) ---
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
""" """
True si la sonde est au-dessus du seuil de façon CONTINUE depuis ≥ 30 min. True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes.
On lit l'historique (par défaut 3h) et on remonte jusqu'au début de la séquence > seuil. CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30)
LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3))
""" """
CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))
LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3))))
table = site table = site
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
# 1) Récupère l'historique récent de la sonde (ordre : plus récent -> plus ancien)
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
WHERE Sonde=%s WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE) AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC ORDER BY Date DESC
""", (sonde, int(window_min))) """, (sonde, LOOKBACK))
rows = cur.fetchall() rows = cur.fetchall()
if not rows: if not rows:
return False return False
# 2) La dernière valeur doit être > seuil, sinon aucun dépassement en cours
last_temp, last_dt = float(rows[0][0]), rows[0][1] last_temp, last_dt = float(rows[0][0]), rows[0][1]
if last_temp <= float(seuil): if last_temp <= float(seuil):
return False return False
# 3) Remonter jusqu'au début de la séquence continue > seuil # Début de la séquence continue > seuil
# (dès qu'on rencontre une valeur <= seuil, on s'arrête)
start_dt = last_dt start_dt = last_dt
for temp, d in rows[1:]: for temp, d in rows[1:]:
if float(temp) > float(seuil): if float(temp) > float(seuil):
start_dt = d # on prolonge la séquence vers le passé start_dt = d
else: else:
break # séquence continue terminée break
# 4) Durée de la séquence continue
tzinfo = getattr(start_dt, "tzinfo", None) tzinfo = getattr(start_dt, "tzinfo", None)
now = dt.datetime.now(tz=tzinfo) # naïf si la date DB est naïve now = dt.datetime.now(tz=tzinfo)
return (now - start_dt) >= dt.timedelta(minutes=30) dur_min = (now - start_dt).total_seconds() / 60.0
log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d",
sonde, start_dt, now, dur_min, CONT_MIN)
return dur_min >= CONT_MIN
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err) log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err)
@@ -225,20 +229,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: in
finally: finally:
cnx.close() cnx.close()
def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1")
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (any_alert_open): %s", err)
return False
finally:
cnx.close()
# ========= Helpers listes/numéros ========= # ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]: def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -266,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
# ========= Notifier (SMS + Mail) ========= # ========= Notifier (SMS interne + SMS client + Mail) =========
class Notifier: class Notifier:
def __init__(self): def __init__(self):
# OVH SMS # OVH SMS
@@ -282,11 +272,22 @@ class Notifier:
) )
self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER") self.ovh_sender = os.getenv("OVH_SMS_SENDER")
raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO")) raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO"))
self.sms_labeled = _parse_labeled_phones(raw_sms) self.sms_labeled = _parse_labeled_phones(raw_sms)
else: else:
self.sms_labeled = [] self.sms_labeled = []
# SMS CLIENTS (site-spécifique + génériques + compat FR)
raw_sms_client = (
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or
os.getenv("ALERT_SMS_CLIENT_TO") or
os.getenv(f"ALERTE_CLIENT_{SITE}") or
os.getenv("ALERTE_CLIENT")
)
self.sms_client_labeled = _parse_labeled_phones(raw_sms_client)
self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
# SMTP # SMTP
self.smtp_host = os.getenv("SMTP_HOST") self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT","465")) self.smtp_port = int(os.getenv("SMTP_PORT","465"))
@@ -294,9 +295,9 @@ class Notifier:
self.smtp_pass = os.getenv("SMTP_PASS") self.smtp_pass = os.getenv("SMTP_PASS")
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "") raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "")
self.mail_to = _split_list(raw_mail_to) self.mail_to = _split_list(raw_mail_to)
self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user) self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.upper()}") or os.getenv("MAIL_FROM") or self.smtp_user)
self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to]) self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to])
@@ -345,6 +346,45 @@ class Notifier:
except Exception as err: except Exception as err:
log.exception("Echec envoi SMS OVH: %s", err); return False log.exception("Echec envoi SMS OVH: %s", err); return False
def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool:
if not self.ovh_enabled:
log.warning("SMS client: OVH non configuré."); return False
if not self.sms_client_enabled or not self.sms_client_labeled:
log.info("SMS client: désactivé ou aucun destinataire."); return False
only = os.getenv("ALERT_SMS_CLIENT_ONLY")
if only:
allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()}
labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)]
else:
labeled = self.sms_client_labeled
receivers = [num for (_n, num) in labeled]
if not receivers:
log.info("SMS client: filtre vide → aucun envoi."); return False
message = normaliser_sms(message, prefix=SITE)
payload = {
"sender": self.ovh_sender,
"receivers": receivers,
"message": message,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True,
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
}
try:
log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled))
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids"))
return True
except Exception as err:
log.exception("Echec SMS CLIENT OVH: %s", err); return False
def send_email(self, subject: str, body: str) -> bool: def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled: if not self.smtp_enabled:
log.warning("SMTP non configuré, email non envoyé."); return False log.warning("SMTP non configuré, email non envoyé."); return False
@@ -423,6 +463,11 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat
txt = "\n".join(lines) txt = "\n".join(lines)
return subject, txt, txt return subject, txt, txt
def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
when = when or now_paris()
# Court, 1 ligne; accents/° nettoyés par normaliser_sms
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}"
# ========= Gyrophare MQTT ========= # ========= Gyrophare MQTT =========
class MQTTPublisher: class MQTTPublisher:
def __init__(self, site: str): def __init__(self, site: str):
@@ -513,7 +558,7 @@ class GyroPulseController:
Ajouts : Ajouts :
- SMS ALERTE immédiat à lallumage - SMS ALERTE immédiat à lallumage
- SMS OK immédiat à lextinction (option activée par défaut) - SMS OK immédiat à lextinction (activé par défaut)
""" """
def __init__(self, site: str, beacon, notifier, *, def __init__(self, site: str, beacon, notifier, *,
check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")), check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")),
@@ -536,7 +581,7 @@ class GyroPulseController:
self._thread = None self._thread = None
self._current = None # dernier état effectif self._current = None # dernier état effectif
# Anti-spam SMS & SMS OK activé par défaut (tranquilliser) # Anti-spam SMS & SMS OK activé par défaut
self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi} self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi}
self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120")) self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1") self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1")
@@ -668,19 +713,25 @@ beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
""" """
MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base. MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base.
(Le SMS d'alerte est déjà envoyé par la boucle gyro, immédiat.) + SMS CLIENT couplé (ALERTE_CLIENT_{SITE}).
(Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.)
""" """
subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil) subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) notifier.send_email(subject, email_body) # MAIL (≥30 min)
# SMS client couplé au mail 30 min
if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1":
client_msg = build_client_alert_sms(site, sonde, temp, seuil)
notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}")
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
""" """
MAIL lorsque lalerte est acquittée en base. MAIL lorsque lalerte est acquittée en base.
(Le SMS "OK" a déjà été géré par la boucle gyro.) (Le SMS "OK" est envoyé immédiatement par la boucle gyro.)
""" """
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil) subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) # mail d'acquittement notifier.send_email(subject, email_body) # mail d'acquittement
# Optionnel: SMS "OK" côté cycle si tu souhaites un doublon (désactivé par défaut) # Optionnel: SMS "OK" côté cycle si souhaité
if os.getenv("ALERT_OK_SMS", "0") == "1": if os.getenv("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
@@ -701,7 +752,7 @@ def run_monitor_cycle(site: str = SITE):
except Exception as e: except Exception as e:
log.exception("Erreur calcul alarme (info): %s", e) log.exception("Erreur calcul alarme (info): %s", e)
# 3) Alertes "officielles" temporisées (≥30 min) → mails # 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows: for r in last_rows:
@@ -715,7 +766,7 @@ def run_monitor_cycle(site: str = SITE):
try: try:
conn = get_db() conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now): if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil) # MAIL alerte notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client
finally: finally:
conn.close() conn.close()
else: else:
@@ -771,3 +822,4 @@ if __name__ == "__main__":
run_monitor_cycle(SITE) run_monitor_cycle(SITE)
else: else:
run_monitor_loop(SITE, period_sec=args.period) run_monitor_loop(SITE, period_sec=args.period)