From 072a0cbbc5789716572479196b38ca974a55ff2b Mon Sep 17 00:00:00 2001 From: Michel Date: Tue, 23 Sep 2025 13:40:14 +0200 Subject: [PATCH] =?UTF-8?q?R=C3=A8glages=20des=20alertes=20dans=20Monitor?= =?UTF-8?q?=5FSaclay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 19 ++-- app/Monitor_Saclay.py | 216 ++++++++++++++++++++++++++++-------------- app/utils_sms.py | 21 +++- 3 files changed, 177 insertions(+), 79 deletions(-) diff --git a/.env b/.env index 2275150..d8fa205 100644 --- a/.env +++ b/.env @@ -1,4 +1,3 @@ - # OVH_SMS_SENDER=DOMO91FR #connexion mysql DB_HOST=162.19.78.131 @@ -10,15 +9,20 @@ DB_NAME=Sondes MQTT_HOST=54.36.188.119 MQTT_USER=Bwps MQTT_PASS="scJ5ACj2keRfI^" + # Boucle rapide du gyro GYRO_MODE=mqtt GYRO_CHECK_SEC=20 GYRO_NORMAL_CONFIRM=2 GYRO_MODE_CONTINUOUS=1 GYRO_HYSTERESIS=0.3 +ALERT_OK_SMS_GYRO=1 +ALERT_OK_SMS=0 +GYRO_SMS_MIN_SEC=120 GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare + # paramètres mail SMTP_HOST=ssl0.ovh.net SMTP_PORT=587 @@ -26,9 +30,9 @@ SMTP_SECURITY=STARTTLS SMTP_USER=services@domo91.fr SMTP_PASS=6ZiCsVtSf9@nEHv@$^0 MAIL_FROM=services@domo91.fr -MAIL_TO_SACLAY=robots@domo91.fr +MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com MAIL_FROM_SACLAY="DOMO91 Saclay " -MAIL_TO_MEUDON=robots@domo91.fr +MAIL_TO_MEUDON=robots@domo91.fr,superviseur.restauration@parismeudonermitage.com,chef@parismeudonermitage.com MAIL_FROM_MEUDON="DOMO91 Meudon " # --- Paramètres SMS ---- @@ -41,9 +45,6 @@ OVH_SMS_SENDER=DOMO91FR SMS_RECEIVER=+33635164680 ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680 - -# --- Réserves destinataires ---- -SMS_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 -SMS_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248 -SACLAY_MAIL=nicolas.thibaut@bw-paris-saclay.com -MEUDON_MAIL=superviseur.restauration@parismeudonermitage.com,chef@parismeudonermitage.com +# ---- Reserve SMS +#Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 +#Sekou:+33625903364,Damien:+33680388259 \ No newline at end of file diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 2832717..1a4965e 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -12,10 +12,13 @@ from email.message import EmailMessage from datetime import datetime from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv(usecwd=True), override=False) +from utils_sms import normaliser_sms # MySQL import mysql.connector from mysql.connector import Error as MySQLError +from dotenv import load_dotenv +load_dotenv() # OVH (SMS) try: @@ -43,7 +46,7 @@ if not log.handlers: 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. - Retourne True si une nouvelle alerte a été créée (→ notifier). + Retourne True si une nouvelle alerte a été créée (→ notifier par mail). """ cur = conn.cursor() cur.execute( @@ -52,20 +55,19 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: ) if cur.fetchone(): cur.close() - return False # déjà ouverte → pas de notif - + return False # déjà ouverte cur.execute( f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')", (sonde, dt.strftime('%Y-%m-%d %H:%M:%S')) ) conn.commit() cur.close() - return True # nouvelle alerte → notifier + return True def close_alert(conn, table_alertes: str, sonde: str) -> bool: """ 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.execute( @@ -77,10 +79,10 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool: changed = (cur.rowcount == 1) conn.commit() cur.close() - return changed # True → notifier, False → rien + return changed def get_db(): - cnx = mysql.connector.connect( + return mysql.connector.connect( host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASS"), @@ -88,7 +90,6 @@ def get_db(): port=int(os.getenv("DB_PORT", "3306")), autocommit=True, ) - return cnx def lire_sondes_depuis_db(site: str): table = site @@ -117,7 +118,7 @@ def lire_sondes_depuis_db(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. """ sql = """ @@ -145,8 +146,7 @@ def lire_cfg_chambres(site: str): def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0): """ - Retourne (is_on: bool, trigger: tuple[str,float,float] | None) - trigger = (sonde, temp, seuil) si dépassement détecté. + Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None) """ for row in last_values: sonde = str(row["Sonde"]) @@ -178,45 +178,54 @@ def lire_seuils_depuis_db(site: str): finally: cnx.close() -def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: +def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: int = 180) -> bool: + """ + True si la sonde est au-dessus du seuil de façon CONTINUE depuis ≥ 30 min. + On lit l'historique (par défaut 3h) et on remonte jusqu'au début de la séquence > seuil. + """ table = site cnx = get_db() try: cur = cnx.cursor() + # 1) Récupère l'historique récent de la sonde (ordre : plus récent -> plus ancien) cur.execute(f""" SELECT Temperature, Date FROM `{table}` WHERE Sonde=%s + AND Date >= (NOW() - INTERVAL %s MINUTE) ORDER BY Date DESC - LIMIT 1 - """, (sonde, )) - last = cur.fetchone() - if not last: + """, (sonde, int(window_min))) + rows = cur.fetchall() + if not rows: return False - last_temp, last_date = float(last[0]), last[1] + + # 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] if last_temp <= float(seuil): return False - cur.execute(f""" - SELECT MIN(Date) - FROM `{table}` - WHERE Sonde=%s - AND Temperature > %s - AND Date >= (NOW() - INTERVAL 120 MINUTE) - """, (sonde, float(seuil))) - first_over = cur.fetchone()[0] - if not first_over: - return False + # 3) Remonter jusqu'au début de la séquence continue > seuil + # (dès qu'on rencontre une valeur <= seuil, on s'arrête) + start_dt = last_dt + for temp, d in rows[1:]: + if float(temp) > float(seuil): + start_dt = d # on prolonge la séquence vers le passé + else: + break # séquence continue terminée + + # 4) Durée de la séquence continue + tzinfo = getattr(start_dt, "tzinfo", None) + now = dt.datetime.now(tz=tzinfo) # naïf si la date DB est naïve + return (now - start_dt) >= dt.timedelta(minutes=30) - now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None)) - return (now - first_over) >= dt.timedelta(minutes=30) 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 finally: cnx.close() + def any_alert_open(site: str) -> bool: table = f"Alertes_{site}" cnx = get_db() @@ -300,10 +309,13 @@ class Notifier: log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") return False + # ✅ Normalisation GSM-7 + préfixe site + message = normaliser_sms(message, prefix=SITE) + payload = { "sender": self.ovh_sender, "receivers": receivers, - "message": message[:1600], + "message": message, "priority": "high", "coding": "7bit", "class": "phoneDisplay", @@ -388,14 +400,26 @@ def now_paris() -> dt.datetime: def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): when = when or now_paris() 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) return subject, txt, txt def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): when = when or now_paris() 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) return subject, txt, txt @@ -461,7 +485,6 @@ class MQTTPublisher: return if self.last_state is not None and self.last_state == on: return - payload = "ON" if on else "OFF" try: r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) @@ -474,7 +497,7 @@ class MQTTPublisher: except Exception as e: log.exception("MQTT publish erreur: %s", e) -# ========= Contrôleur Gyro réactif (pulse/cooldown ou continu) ========= +# ========= Contrôleur Gyro réactif ========= import enum, threading class _GyroState(enum.Enum): @@ -484,18 +507,22 @@ class _GyroState(enum.Enum): class GyroPulseController: """ - Boucle rapide indépendante : lit les dernières mesures + config - et applique un automate : - - MODE CONTINU (par défaut) : ON tant que l’alarme persiste, OFF rapide si retour normal. - - MODE PULSE : IDLE → PULSE_ON (ON PULSE_SEC) → COOLDOWN (OFF COOLDOWN_SEC) → re-PULSE tant que l’alarme persiste. + Boucle rapide indépendante : + - MODE CONTINU (défaut) : ON tant que l’alarme persiste, OFF quand normal confirmé. + - MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que l’alarme persiste. + + Ajouts : + - SMS ALERTE immédiat à l’allumage + - SMS OK immédiat à l’extinction (option activée par défaut) """ - def __init__(self, site: str, beacon, *, + 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 @@ -507,7 +534,15 @@ class GyroPulseController: self._normal_count = 0 self._stop = threading.Event() self._thread = None - self._current = None # dernier état envoyé pour éviter les doublons + self._current = None # dernier état effectif + + # Anti-spam SMS & SMS OK activé par défaut (tranquilliser) + 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: @@ -526,16 +561,48 @@ class GyroPulseController: 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]: - """ - Réutilise tes fonctions existantes pour décider rapidement. - """ - last_rows = lire_sondes_depuis_db(self.site) # [{'Sonde','Temperature','Date'}] - cfg = lire_cfg_chambres(self.site) # {sonde: {temp_max, active, entretien}} + 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): - import time while not self._stop.is_set(): now = time.time() try: @@ -550,10 +617,13 @@ class GyroPulseController: 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: @@ -563,14 +633,11 @@ class GyroPulseController: 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 - # --- MODE CONTINU par défaut --- - if os.getenv("GYRO_MODE_CONTINUOUS", "1") == "1": - # Rester ON tant que l’alerte persiste - pass - else: - # --- MODE PULSE --- + 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 @@ -599,63 +666,73 @@ notifier = Notifier() beacon = MQTTPublisher(SITE) 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. + (Le SMS d'alerte est déjà envoyé par la boucle gyro, immédiat.) + """ + subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil) notifier.send_email(subject, email_body) 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 l’alerte est acquittée en base. + (Le SMS "OK" a déjà été géré 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 + # Optionnel: SMS "OK" côté cycle si tu souhaites un doublon (désactivé par défaut) + if os.getenv("ALERT_OK_SMS", "0") == "1": + notifier.send_sms(sms_text) # ========= Cycle & boucle ========= def run_monitor_cycle(site: str = SITE): - # 1) Lecture dernières mesures + config chambres - last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}] - cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}} + # 1) Lecture dernières mesures + config + last_rows = lire_sondes_depuis_db(site) + cfg = lire_cfg_chambres(site) - # 2) Gyro géré par le contrôleur rapide → ici, on ne touche plus au gyro + # 2) Info: état instantané (le gyro est piloté par la boucle rapide) try: gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) if trigger: s, t, se = trigger - log.info("Dépassement détecté (pilotage gyro par boucle rapide) : %s %.2f > %.2f", s, t, se) + log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se) else: log.info("Aucun dépassement au moment du cycle") except Exception as 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) → mails seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} for r in last_rows: nom = str(r["Sonde"]) temp = float(r["Temperature"]) seuil = float(seuils.get(nom, 6.0)) - now = now_paris() + if temp > seuil: if depassement_depuis_30min(site, nom, seuil): try: conn = get_db() if open_alert(conn, f"Alertes_{site}", nom, now): - notifier_sur_depassement(site, nom, temp, seuil) + notifier_sur_depassement(site, nom, temp, seuil) # MAIL alerte finally: conn.close() else: try: conn = get_db() if close_alert(conn, f"Alertes_{site}", nom): - notifier_acquittement(site, nom, temp, seuil) + notifier_acquittement(site, nom, temp, seuil) # MAIL acquittement finally: conn.close() 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) - # Démarrage du contrôleur gyro rapide (thread) + # Démarrage du contrôleur gyro rapide (thread) + notifier pour SMS immédiats try: global _gyro_controller - _gyro_controller = GyroPulseController(site, beacon) + _gyro_controller = GyroPulseController(site, beacon, notifier) _gyro_controller.start() except Exception as e: log.exception("Impossible de démarrer le GyroPulseController: %s", e) @@ -681,7 +758,8 @@ if __name__ == "__main__": args = p.parse_args() if args.test_sms: - notifier.send_sms("TEST DOMO91 (transactionnel)") + n = Notifier() + n.send_sms("TEST DOMO91 (transactionnel)") elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK") elif args.test_alert: diff --git a/app/utils_sms.py b/app/utils_sms.py index ea3a292..483c8bc 100644 --- a/app/utils_sms.py +++ b/app/utils_sms.py @@ -1,9 +1,28 @@ import os import ovh from dotenv import load_dotenv +import unicodedata, re load_dotenv() +def normaliser_sms(message: str, prefix: str = "") -> str: + REPL = {"°": "C", "’": "'", "“": '"', "”": '"', "–": "-", "—": "-", "…": "..."} + for k, v in REPL.items(): + message = message.replace(k, v) + message = unicodedata.normalize("NFKD", message).encode("ascii", "ignore").decode("ascii") + rules = [ + (r"\bTemperature\b", "T"), + (r"\bTemp[ée]rature\b", "T"), + (r"\bSeuil\b", "S"), + (r"\bHeure\b", "H"), + (r"\s{2,}", " "), + ] + for pat, repl in rules: + message = re.sub(pat, repl, message, flags=re.IGNORECASE) + if prefix: + message = f"[{prefix}] {message}" + return message[:160] + def envoyer_sms(message: str, lieu: str = ""): try: client = ovh.Client( @@ -28,7 +47,7 @@ def envoyer_sms(message: str, lieu: str = ""): if not numero_dest or not numero_dest.isdigit(): print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True) return - + message = normaliser_sms(message, prefix=lieu) payload = { "sender": sender, "receivers": [numero_dest],