From 511e377dc88c6c0ef67de65e7922d32a7f48f8fe Mon Sep 17 00:00:00 2001 From: Michel Date: Sat, 20 Sep 2025 12:09:36 +0200 Subject: [PATCH] =?UTF-8?q?Remise=20en=20=C3=A9tat=20des=20alertes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 16 +- app/Monitor_Meudon.py | 482 ++++++++++++++++++------------------------ app/Monitor_Saclay.py | 383 +++++++++++++++++---------------- app/domo91.py | 2 +- app/tracker.py | 2 +- app/utils_db.py | 2 +- 6 files changed, 414 insertions(+), 473 deletions(-) diff --git a/.env b/.env index abe474a..4152499 100644 --- a/.env +++ b/.env @@ -2,14 +2,16 @@ #connexion mysql DB_HOST=162.19.78.131 DB_USER=sondes -DB_PASSWORD=TX.)-U1!zq5Axdk4 +DB_PASS=TX.)-U1!zq5Axdk4 DB_NAME=Sondes # MQTT -MQTT_HOST=162.19.78.131 -MQTT_USER=sondes -MQTT_PASS=3J@bjYP0 -GYRO_PUBLISH_GLOBAL=1 +GYRO_MODE=mqtt +MQTT_HOST=54.36.188.119 +MQTT_USER=Bwps +MQTT_PASS=scJ5ACj2keRfI^ +GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare +GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare # paramètres mail SMTP_HOST=ssl0.ovh.net @@ -30,5 +32,7 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6 OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SENDER=DOMO91FR -ALERT_SMS_TO_SACLAY==Michel:+33635164680,Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 +ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680 +RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 +RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248 diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index 2e7b840..9a36895 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -1,42 +1,38 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# ========= Imports & chargement .env ========= -import os -import re -import time -import ssl -import smtplib -import logging +SITE = "Meudon" +PROGRAM_NAME = f"Monitor_{SITE}" + +import os, re, time, ssl, smtplib, logging import datetime as dt from email.message import EmailMessage from typing import List from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv(usecwd=True), override=False) -SITE = "Meudon" -PROGRAM_NAME = f"Monitor_{SITE}" -# MySQL import mysql.connector from mysql.connector import Error as MySQLError -# OVH (robuste même si la lib n'est pas installée) try: import ovh from ovh.exceptions import APIError as OVHAPIError _ovh_available = True except Exception: ovh = None # type: ignore - class OVHAPIError(Exception): # fallback pour les except - pass + class OVHAPIError(Exception): pass _ovh_available = False -# ========= Logger ========= -log = logging.getLogger("monitor_saclay") +try: + import paho.mqtt.client as mqtt + _mqtt_ok = True +except Exception: + _mqtt_ok = False + +log = logging.getLogger(PROGRAM_NAME.lower()) if not log.handlers: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -# ========= Utilitaires DB ========= def get_db(): cnx = mysql.connector.connect( host=os.getenv("DB_HOST", "localhost"), @@ -49,10 +45,6 @@ def get_db(): return cnx def lire_sondes_depuis_db(site: str): - """ - Relevés les + récents par sonde pour le site (table = nom du site, ex. 'Saclay'). - Retour: [{"Sonde": str, "Temperature": float, "Date": datetime}] - """ table = site sql = f""" SELECT t1.Sonde, t1.Temperature, t1.Date @@ -78,10 +70,6 @@ def lire_sondes_depuis_db(site: str): cnx.close() def lire_seuils_depuis_db(site: str): - """ - Lit 'Chambres_froides' pour le site (Etat='ON'). - Retour: dict {sonde: seuil_float} - """ sql = """ SELECT Sonde, Temp_Max FROM Chambres_froides @@ -102,17 +90,10 @@ def lire_seuils_depuis_db(site: str): cnx.close() def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: - """ - Vrai si dépassement continu >= 30 minutes. - Approche : cherche la première mesure > seuil dans les 120 dernières minutes, - vérifie que la dernière mesure est toujours > seuil et que l'écart >= 30 min. - """ table = site cnx = get_db() try: cur = cnx.cursor() - - # Dernière valeur cur.execute(f""" SELECT Temperature, Date FROM `{table}` @@ -121,29 +102,20 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: LIMIT 1 """, (sonde,)) last = cur.fetchone() - if not last: - return False + if not last: return False last_temp, last_date = float(last[0]), last[1] - if last_temp <= float(seuil): - return False - - # Première mesure > seuil (fenêtre 120 min) + 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) + 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 - + if not first_over: return False 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) - return False + log.exception("Erreur DB (depassement_depuis_30min): %s", err); return False finally: cnx.close() @@ -152,14 +124,22 @@ def alerte_en_cours(site: str, sonde: str) -> bool: cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", - (sonde,) - ) + cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,)) return cur.fetchone() is not None except MySQLError as err: - log.exception("Erreur DB (alerte_en_cours): %s", err) - return False + log.exception("Erreur DB (alerte_en_cours): %s", err); return False + finally: + 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() @@ -168,10 +148,7 @@ def creer_alerte(site: str, sonde: str): cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", - (sonde,) - ) + cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,)) cnx.commit() except MySQLError as err: log.exception("Erreur DB (creer_alerte): %s", err) @@ -183,305 +160,254 @@ def acquitter_alerte(site: str, sonde: str): cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", - (sonde,) - ) + cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,)) cnx.commit() except MySQLError as err: log.exception("Erreur DB (acquitter_alerte): %s", err) finally: cnx.close() -# ========= Helpers destinataires ========= def _split_list(raw: str | None) -> list[str]: - """Pour les emails (MAIL_TO) — accepte virgule ou point-virgule.""" return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: - """ - Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')] - Si pas de nom fourni -> ('', '+336...') - """ out: list[tuple[str, str]] = [] for tok in re.split(r"[;,]", raw or ""): tok = tok.strip() - if not tok: - continue + if not tok: continue if ":" in tok: - name, num = tok.split(":", 1) - out.append((name.strip(), num.strip())) + name, num = tok.split(":", 1); out.append((name.strip(), num.strip())) else: out.append(("", tok)) return out def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]: - """ - Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs. - Si ALERT_SMS_ONLY absent -> tous les numéros. - """ only = os.getenv("ALERT_SMS_ONLY") - if not only: - return [num for (_name, num) in labeled] + if not only: return [num for (_n,num) in labeled] allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()} - return [num for (name, num) in labeled if (name and name in allow) or (num in allow)] + return [num for (name,num) in labeled if (name and name in allow) or (num in allow)] def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: - """Michel(+336...), Christian(+336...) pour les logs.""" - 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]) -# ========= Notifications (OVH + SMTP) ========= class Notifier: def __init__(self): - # ... self.ovh_enabled = _ovh_available and all( - os.getenv(k) for k in ( - "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY", - "OVH_SMS_SERVICE", "OVH_SMS_SENDER" - ) + os.getenv(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER") ) if self.ovh_enabled: self.ovh_client = ovh.Client( - endpoint=os.getenv("OVH_ENDPOINT", "ovh-eu"), + endpoint=os.getenv("OVH_ENDPOINT","ovh-eu"), application_key=os.getenv("OVH_APPLICATION_KEY"), application_secret=os.getenv("OVH_APPLICATION_SECRET"), consumer_key=os.getenv("OVH_CONSUMER_KEY"), ) self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_sender = os.getenv("OVH_SMS_SENDER") - - raw_sms = (os.getenv("ALERT_SMS_TO_Meudon") - or os.getenv("ALERT_SMS_TO_MEUDON") - or os.getenv("ALERT_SMS_TO")) + raw_sms = (os.getenv("ALERT_SMS_TO_Meudon") or os.getenv("ALERT_SMS_TO_MEUDON") or os.getenv("ALERT_SMS_TO")) self.sms_labeled = _parse_labeled_phones(raw_sms) else: self.sms_labeled = [] - # SMTP 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")) self.smtp_user = os.getenv("SMTP_USER") self.smtp_pass = os.getenv("SMTP_PASS") - self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user - self.mail_to = _split_list(os.getenv("MAIL_TO") or os.getenv("EMAIL_DESTINATAIRES")) - self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper() + self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() - site_key = SITE # "Saclay" ou "Meudon" selon le fichier - raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}") - or os.getenv(f"MAIL_TO_{site_key.upper()}") - or os.getenv("MAIL_TO") - or "") + raw_mail_to = (os.getenv("MAIL_TO_Meudon") or os.getenv("MAIL_TO_MEUDON") or os.getenv("MAIL_TO") or "") self.mail_to = _split_list(raw_mail_to) - - self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}") - or os.getenv(f"MAIL_FROM_{site_key.upper()}") - or os.getenv("MAIL_FROM") - or self.smtp_user) + self.mail_from = (os.getenv("MAIL_FROM_Meudon") or os.getenv("MAIL_FROM_MEUDON") 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]) - def send_sms(self, message: str, tag: str = "monitor-saclay") -> bool: + def send_sms(self, message: str, tag: str = f"monitor-{SITE.lower()}") -> bool: if not self.ovh_enabled or not self.sms_labeled: - log.warning("SMS désactivé ou aucun destinataire.") - return False - - receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros + log.warning("SMS désactivé ou aucun destinataire."); return False + receivers = _resolve_sms_receivers(self.sms_labeled) if not receivers: - log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).") - return False - + log.warning("ALERT_SMS_ONLY filtre tous les destinataires."); return False payload = { - "sender": self.ovh_sender, - "receivers": receivers, - "message": message[:1600], - "priority": "high", - "coding": "7bit", - "class": "phoneDisplay", - "noStopClause": True, # transactionnel (H24) si habilité chez OVH - "senderForResponse": False, - "validityPeriod": 2880, - "tag": tag, + "sender": self.ovh_sender, "receivers": receivers, "message": message[:1600], + "priority": "high", "coding": "7bit", "class": "phoneDisplay", + "noStopClause": True, "senderForResponse": False, "validityPeriod": 2880, "tag": tag, } - try: - log.info("Envoi SMS vers: %s", _human_labeled_list([(n, p) for (n, p) in self.sms_labeled if p in receivers])) + log.info("Envoi SMS vers: %s", _human_labeled_list([(n,p) for (n,p) in self.sms_labeled if p in receivers])) resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload) - ids = resp.get("ids") or [] - log.info("SMS OVH envoyé (job ids=%s)", ids) - # Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404 + ids = resp.get("ids") or []; log.info("SMS OVH envoyé (job ids=%s)", ids) try: if ids: job_id = ids[0] for _ in range(3): job = self.ovh_client.get(f"/sms/{self.ovh_service}/jobs/{job_id}") - if job.get("status") in ("done", "error", "cancelled"): - log.info("Statut job SMS: %s", job.get("status")) - break + if job.get("status") in ("done","error","cancelled"): log.info("Statut job SMS: %s", job.get("status")); break time.sleep(1.5) - except Exception as err: - log.debug("Suivi job OVH indisponible (OK): %s", err) + except Exception as e: log.debug("Suivi job OVH indisponible (OK): %s", e) return True - - except OVHAPIError as err: - log.exception("Erreur API OVH: %s", err) - return False - except Exception as err: - log.exception("Echec envoi SMS OVH: %s", err) - return False + except OVHAPIError as err: log.exception("Erreur API OVH: %s", err); return False + except Exception as err: log.exception("Echec envoi SMS OVH: %s", err); return False def send_email(self, subject: str, body: str) -> bool: if not self.smtp_enabled: - log.warning("SMTP non configuré, email non envoyé.") - return False - - msg = EmailMessage() - msg["From"] = self.mail_from - msg["To"] = ", ".join(self.mail_to) - msg["Subject"] = subject - msg.set_content(body) - + log.warning("SMTP non configuré, email non envoyé."); return False + msg = EmailMessage(); msg["From"]=self.mail_from; msg["To"]=", ".join(self.mail_to); msg["Subject"]=subject; msg.set_content(body) + timeout = int(os.getenv("SMTP_TIMEOUT","60")); debug = os.getenv("SMTP_DEBUG","0")=="1" + def _send_ssl(): + with smtplib.SMTP_SSL(self.smtp_host,465,context=ssl.create_default_context(),timeout=timeout) as s: + if debug: s.set_debuglevel(1); s.login(self.smtp_user,self.smtp_pass); s.send_message(msg) + def _send_starttls(): + with smtplib.SMTP(self.smtp_host,self.smtp_port,timeout=timeout) as s: + if debug: s.set_debuglevel(1); s.ehlo(); s.starttls(context=ssl.create_default_context()); s.ehlo() + s.login(self.smtp_user,self.smtp_pass); s.send_message(msg) try: - if self.smtp_security == "STARTTLS": - with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server: - server.ehlo() - server.starttls(context=ssl.create_default_context()) - server.ehlo() - server.login(self.smtp_user, self.smtp_pass) - server.send_message(msg) - else: - with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server: - server.login(self.smtp_user, self.smtp_pass) - server.send_message(msg) - log.info("Email envoyé à %s", self.mail_to) - return True - except (smtplib.SMTPException, ssl.SSLError) as err: - log.exception("Erreur SMTP: %s", err) - return False - except Exception as err: - log.exception("Echec envoi email: %s", err) - return False + if self.smtp_security=="STARTTLS": + try: _send_starttls() + except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err: + log.warning("STARTTLS/587 a échoué (%s). Tentative SSL/465...", err); _send_ssl() + else: _send_ssl() + log.info("Email envoyé à %s", self.mail_to); return True + except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err: log.exception("Erreur SMTP: %s", err); return False + except Exception as err: log.exception("Echec envoi email: %s", err); return False -# ========= Helpers de mise en forme des messages ========= from zoneinfo import ZoneInfo PARIS = ZoneInfo("Europe/Paris") +def fmt_deg(v: float) -> str: s=f"{float(v):.1f}".replace(".", ","); return f"{s}°C" +def now_paris()->dt.datetime: return dt.datetime.now(tz=PARIS) +def build_alert_text(site,sonde,temp,seuil,when=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')}"] + txt="\n".join(lines); return subject,txt,txt +def build_ok_text(site,sonde,temp,seuil,when=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')}"] + txt="\n".join(lines); return subject,txt,txt -def fmt_deg(v: float) -> str: - s = f"{float(v):.1f}".replace(".", ",") - return f"{s}°C" +# ========= Gyrophare MQTT ========= +class MQTTPublisher: + def __init__(self, site: str): + self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) + self.site = site + self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or + os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) + self.last_state: bool | None = None -def now_paris() -> dt.datetime: - return dt.datetime.now(tz=PARIS) + if not self.enabled: + log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou paho-mqtt absent).") + return + if not self.topic: + log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site) + self.enabled = False + return -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')}", - ] - txt = "\n".join(lines) - return subject, txt, txt + host = os.getenv("MQTT_HOST", "localhost") + port = int(os.getenv("MQTT_PORT", "1883")) + user = os.getenv("MQTT_USER") + pwd = os.getenv("MQTT_PASS") + tls = (os.getenv("MQTT_TLS", "0") == "1") -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')}", - ] - txt = "\n".join(lines) - return subject, txt, txt - -# ========= Fonctions de notification haut niveau ========= -# notifier = Notifier() - -def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): - subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil) - sms_ok = notifier.send_sms(sms_text) - mail_ok = notifier.send_email(subject, email_body) - if sms_ok and mail_ok: - log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde) - elif sms_ok: - log.warning("Alerte: SMS OK, mail KO pour %s/%s", site, sonde) - elif mail_ok: - log.warning("Alerte: mail OK, SMS KO pour %s/%s", site, sonde) - else: - log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde) - -def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): - subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil) - sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul - if sms_ok: - log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde) - else: - log.warning("Acquittement: SMS KO pour %s/%s", site, sonde) - -# ========= Cycle & boucle de monitoring ========= -def run_monitor_cycle(site: str): - sondes = lire_sondes_depuis_db(site) - seuils = lire_seuils_depuis_db(site) - - for r in sondes: - nom = str(r["Sonde"]) - temp = float(r["Temperature"]) - seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant - - if temp > seuil: - if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom): - creer_alerte(site, nom) - notifier_sur_depassement(site, nom, temp, seuil) + # --- Création du client MQTT : compatible paho 1.x et 2.x --- + cbver = getattr(mqtt, "CallbackAPIVersion", None) + if cbver is not None: + # paho >= 2.x : on choisit la meilleure constante disponible + api_v = ( + getattr(cbver, "VERSION2", None) # paho 2.x + or getattr(cbver, "V5", None) # certaines builds + or getattr(cbver, "v5", None) # fallback + or getattr(cbver, "V311", None) # dernier recours + ) + try: + self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client() + except TypeError: + # vieux paho ne supporte pas l’argument callback_api_version + self.client = mqtt.Client() else: - if alerte_en_cours(site, nom): - acquitter_alerte(site, nom) - notifier_acquittement(site, nom, temp, seuil) + # paho 1.x + self.client = mqtt.Client() + # ------------------------------------------------------------ + + if user and pwd: + self.client.username_pw_set(user, pwd) + if tls: + self.client.tls_set() -def run_monitor_loop(site: str, period_sec: int = 300): - log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", site, period_sec) - while True: - t0 = time.time() try: - run_monitor_cycle(site) - except Exception as err: # volontaire : ne jamais tuer le service - log.exception("Erreur cycle monitoring: %s", err) - time.sleep(max(0, period_sec - (time.time() - t0))) + self.client.connect(host, port, keepalive=30) + self.client.loop_start() + log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic) + except Exception as e: + log.exception("MQTT connexion impossible: %s", e) + self.enabled = False -# ========= Entrée CLI ========= -if __name__ == "__main__": - import argparse - parser = argparse.ArgumentParser(description="Monitor_Saclay") - parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay")) - parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)") - # tests - parser.add_argument("--test-sms", action="store_true") - parser.add_argument("--test-mail", action="store_true") - parser.add_argument("--test-alert", action="store_true") - parser.add_argument("--test-ok", action="store_true") - parser.add_argument("--once", action="store_true", help="exécuter un seul cycle puis quitter") - args = parser.parse_args() - # Faire en sorte que --site pilote aussi la liste SMS - import os + def set(self, on: bool): + if not self.enabled: + return + if self.last_state is not None and self.last_state == on: + return - os.environ["SITE_NAME"] = args.site + payload = "on" if on else "off" + try: + r = self.client.publish(self.topic, payload=payload, qos=1, retain=True) + r.wait_for_publish(timeout=3) + if r.rc != 0: + log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) + else: + log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper()) + self.last_state = on + except Exception as e: + log.exception("MQTT publish erreur: %s", e) - # Créer le notifier maintenant (après avoir fixé SITE_NAME) - from typing import cast - notifier = Notifier() # type: ignore[assignment] - if args.test_sms: - notifier.send_sms("TEST DOMO91 (transactionnel)") - elif args.test_mail: - notifier.send_email("[TEST DOMO91] Mail", "OK") - elif args.test_alert: - notifier_sur_depassement(args.site, "Congelateur", -14.5, -15.0) - elif args.test_ok: - notifier_acquittement(args.site, "Congelateur", -15.2, -15.0) - else: - if args.once: - run_monitor_cycle(args.site) + +notifier = Notifier() +beacon = MQTTPublisher(SITE) + +def notifier_sur_depassement(site,sonde,temp,seuil): + subject,sms_text,email_body=build_alert_text(site,sonde,temp,seuil) + notifier.send_sms(sms_text); notifier.send_email(subject,email_body) + try: beacon.set(True) + except Exception: pass + +def notifier_acquittement(site,sonde,temp,seuil): + subject,sms_text,_=build_ok_text(site,sonde,temp,seuil) + notifier.send_sms(sms_text) + try: + if not any_alert_open(site): beacon.set(False) + except Exception: pass + +def run_monitor_cycle(site: str = SITE): + sondes=lire_sondes_depuis_db(site); seuils=lire_seuils_depuis_db(site) + for r in sondes: + nom=str(r["Sonde"]); temp=float(r["Temperature"]); seuil=float(seuils.get(nom,6.0)) + if temp>seuil: + if depassement_depuis_30min(site,nom,seuil) and not alerte_en_cours(site,nom): + creer_alerte(site,nom); notifier_sur_depassement(site,nom,temp,seuil) else: - run_monitor_loop(args.site, period_sec=args.period) + if alerte_en_cours(site,nom): + acquitter_alerte(site,nom); notifier_acquittement(site,nom,temp,seuil) + +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) + while True: + t0=time.time() + try: run_monitor_cycle(site) + except Exception as err: log.exception("Erreur cycle monitoring: %s",err) + time.sleep(max(0, period_sec-(time.time()-t0))) + +if __name__=="__main__": + import argparse + p=argparse.ArgumentParser(description=PROGRAM_NAME) + p.add_argument("--period",type=int,default=300) + p.add_argument("--test-sms",action="store_true") + p.add_argument("--test-mail",action="store_true") + p.add_argument("--test-alert",action="store_true") + p.add_argument("--test-ok",action="store_true") + p.add_argument("--once",action="store_true") + args=p.parse_args() + + if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)") + elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail","OK") + elif args.test_alert: notifier_sur_depassement(SITE,"Congelateur",-14.5,-15.0) + elif args.test_ok: notifier_acquittement(SITE,"Congelateur",-15.2,-15.0) + else: + if args.once: run_monitor_cycle(SITE) + else: run_monitor_loop(SITE,period_sec=args.period) diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 238867a..4862307 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -1,46 +1,49 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# ========= Imports & chargement .env ========= -import os -import re -import time -import ssl -import smtplib -import logging +# ========= Site ========= +SITE = "Saclay" +PROGRAM_NAME = f"Monitor_{SITE}" + +# ========= Imports & .env ========= +import os, re, time, ssl, smtplib, logging import datetime as dt from email.message import EmailMessage from typing import List from dotenv import load_dotenv, find_dotenv load_dotenv(find_dotenv(usecwd=True), override=False) -SITE = "Saclay" -PROGRAM_NAME = f"Monitor_{SITE}" # MySQL import mysql.connector from mysql.connector import Error as MySQLError -# OVH (robuste même si la lib n'est pas installée) +# OVH (SMS) try: import ovh from ovh.exceptions import APIError as OVHAPIError _ovh_available = True except Exception: ovh = None # type: ignore - class OVHAPIError(Exception): # fallback pour les except - pass + class OVHAPIError(Exception): pass _ovh_available = False +# MQTT +try: + import paho.mqtt.client as mqtt + _mqtt_ok = True +except Exception: + _mqtt_ok = False + # ========= Logger ========= -log = logging.getLogger("monitor_saclay") +log = logging.getLogger(PROGRAM_NAME.lower()) if not log.handlers: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -# ========= Utilitaires DB ========= +# ========= DB utils ========= def get_db(): cnx = mysql.connector.connect( - host=os.getenv("DB_HOST", "localhost"), + host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), password=os.getenv("DB_PASS"), database=os.getenv("DB_NAME", "Sondes"), @@ -50,10 +53,6 @@ def get_db(): return cnx def lire_sondes_depuis_db(site: str): - """ - Relevés les + récents par sonde pour le site (table = nom du site, ex. 'Saclay'). - Retour: [{"Sonde": str, "Temperature": float, "Date": datetime}] - """ table = site sql = f""" SELECT t1.Sonde, t1.Temperature, t1.Date @@ -79,10 +78,6 @@ def lire_sondes_depuis_db(site: str): cnx.close() def lire_seuils_depuis_db(site: str): - """ - Lit 'Chambres_froides' pour le site (Etat='ON'). - Retour: dict {sonde: seuil_float} - """ sql = """ SELECT Sonde, Temp_Max FROM Chambres_froides @@ -103,17 +98,11 @@ def lire_seuils_depuis_db(site: str): cnx.close() def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: - """ - Vrai si dépassement continu >= 30 minutes. - Approche : cherche la première mesure > seuil dans les 120 dernières minutes, - vérifie que la dernière mesure est toujours > seuil et que l'écart >= 30 min. - """ table = site cnx = get_db() try: cur = cnx.cursor() - # Dernière valeur cur.execute(f""" SELECT Temperature, Date FROM `{table}` @@ -128,7 +117,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: if last_temp <= float(seuil): return False - # Première mesure > seuil (fenêtre 120 min) cur.execute(f""" SELECT MIN(Date) FROM `{table}` @@ -153,10 +141,7 @@ def alerte_en_cours(site: str, sonde: str) -> bool: cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", - (sonde,) - ) + cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,)) return cur.fetchone() is not None except MySQLError as err: log.exception("Erreur DB (alerte_en_cours): %s", err) @@ -164,15 +149,25 @@ def alerte_en_cours(site: str, sonde: str) -> bool: finally: 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() + def creer_alerte(site: str, sonde: str): table = f"Alertes_{site}" cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", - (sonde,) - ) + cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,)) cnx.commit() except MySQLError as err: log.exception("Erreur DB (creer_alerte): %s", err) @@ -184,26 +179,18 @@ def acquitter_alerte(site: str, sonde: str): cnx = get_db() try: cur = cnx.cursor() - cur.execute( - f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", - (sonde,) - ) + cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,)) cnx.commit() except MySQLError as err: log.exception("Erreur DB (acquitter_alerte): %s", err) finally: cnx.close() -# ========= Helpers destinataires ========= +# ========= Helpers listes/numéros ========= def _split_list(raw: str | None) -> list[str]: - """Pour les emails (MAIL_TO) — accepte virgule ou point-virgule.""" return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: - """ - Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')] - Si pas de nom fourni -> ('', '+336...') - """ out: list[tuple[str, str]] = [] for tok in re.split(r"[;,]", raw or ""): tok = tok.strip() @@ -217,79 +204,56 @@ def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: return out def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]: - """ - Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs. - Si ALERT_SMS_ONLY absent -> tous les numéros. - """ only = os.getenv("ALERT_SMS_ONLY") if not only: - return [num for (_name, num) in labeled] + return [num for (_n, num) in labeled] allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()} return [num for (name, num) in labeled if (name and name in allow) or (num in allow)] def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: - """Michel(+336...), Christian(+336...) pour les logs.""" return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) -# ========= Notifications (OVH + SMTP) ========= +# ========= Notifier (SMS + Mail) ========= class Notifier: def __init__(self): - # OVH + # OVH SMS self.ovh_enabled = _ovh_available and all( - os.getenv(k) for k in ( - "OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY", - "OVH_SMS_SERVICE", "OVH_SMS_SENDER" - ) + os.getenv(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER") ) if self.ovh_enabled: self.ovh_client = ovh.Client( - endpoint=os.getenv("OVH_ENDPOINT", "ovh-eu"), + endpoint=os.getenv("OVH_ENDPOINT","ovh-eu"), application_key=os.getenv("OVH_APPLICATION_KEY"), application_secret=os.getenv("OVH_APPLICATION_SECRET"), consumer_key=os.getenv("OVH_CONSUMER_KEY"), ) self.ovh_service = os.getenv("OVH_SMS_SERVICE") - self.ovh_sender = os.getenv("OVH_SMS_SENDER") - - # <<< LIGNE CLÉ POUR SACLAY >>> - raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") - or os.getenv("ALERT_SMS_TO_SACLAY") - or os.getenv("ALERT_SMS_TO")) # fallback facultatif + 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")) self.sms_labeled = _parse_labeled_phones(raw_sms) else: self.sms_labeled = [] # SMTP 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")) self.smtp_user = os.getenv("SMTP_USER") self.smtp_pass = os.getenv("SMTP_PASS") - self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user - self.mail_to = _split_list(os.getenv("MAIL_TO") or os.getenv("EMAIL_DESTINATAIRES")) - self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper() - # >>> NOUVEAU : destinataires/expéditeur par site, sinon valeur générique - site_key = SITE # "Saclay" ou "Meudon" selon le fichier - raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}") - or os.getenv(f"MAIL_TO_{site_key.upper()}") - or os.getenv("MAIL_TO") - or "") - self.mail_to = _split_list(raw_mail_to) + self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() - self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}") - or os.getenv(f"MAIL_FROM_{site_key.upper()}") - or os.getenv("MAIL_FROM") - or self.smtp_user) + raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "") + 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.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to]) - def send_sms(self, message: str, tag: str = "monitor-saclay") -> bool: + def send_sms(self, message: str, tag: str = f"monitor-{SITE.lower()}") -> bool: if not self.ovh_enabled or not self.sms_labeled: log.warning("SMS désactivé ou aucun destinataire.") return False - - receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros + receivers = _resolve_sms_receivers(self.sms_labeled) if not receivers: - log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).") + log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") return False payload = { @@ -299,42 +263,35 @@ class Notifier: "priority": "high", "coding": "7bit", "class": "phoneDisplay", - "noStopClause": True, # transactionnel (H24) si habilité chez OVH + "noStopClause": True, "senderForResponse": False, "validityPeriod": 2880, "tag": tag, } - try: - log.info("Envoi SMS vers: %s", _human_labeled_list([(n, p) for (n, p) in self.sms_labeled if p in receivers])) + log.info("Envoi SMS vers: %s", _human_labeled_list([(n,p) for (n,p) in self.sms_labeled if p in receivers])) resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload) ids = resp.get("ids") or [] log.info("SMS OVH envoyé (job ids=%s)", ids) - # Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404 try: if ids: job_id = ids[0] for _ in range(3): job = self.ovh_client.get(f"/sms/{self.ovh_service}/jobs/{job_id}") - if job.get("status") in ("done", "error", "cancelled"): - log.info("Statut job SMS: %s", job.get("status")) - break + if job.get("status") in ("done","error","cancelled"): + log.info("Statut job SMS: %s", job.get("status")); break time.sleep(1.5) - except Exception as err: - log.debug("Suivi job OVH indisponible (OK): %s", err) + except Exception as e: + log.debug("Suivi job OVH indisponible (OK): %s", e) return True - except OVHAPIError as err: - log.exception("Erreur API OVH: %s", err) - return False + log.exception("Erreur API OVH: %s", err); return False 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_email(self, subject: str, body: str) -> bool: 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 msg = EmailMessage() msg["From"] = self.mail_from @@ -342,34 +299,44 @@ class Notifier: msg["Subject"] = subject msg.set_content(body) + timeout = int(os.getenv("SMTP_TIMEOUT","60")) + debug = os.getenv("SMTP_DEBUG","0") == "1" + + def _send_ssl(): + with smtplib.SMTP_SSL(self.smtp_host, 465, context=ssl.create_default_context(), timeout=timeout) as server: + if debug: server.set_debuglevel(1) + server.login(self.smtp_user, self.smtp_pass) + server.send_message(msg) + + def _send_starttls(): + with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server: + if debug: server.set_debuglevel(1) + server.ehlo(); server.starttls(context=ssl.create_default_context()); server.ehlo() + server.login(self.smtp_user, self.smtp_pass) + server.send_message(msg) + try: if self.smtp_security == "STARTTLS": - with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server: - server.ehlo() - server.starttls(context=ssl.create_default_context()) - server.ehlo() - server.login(self.smtp_user, self.smtp_pass) - server.send_message(msg) + try: + _send_starttls() + except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err: + log.warning("STARTTLS/587 a échoué (%s). Tentative en SSL/465...", err) + _send_ssl() else: - with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server: - server.login(self.smtp_user, self.smtp_pass) - server.send_message(msg) + _send_ssl() log.info("Email envoyé à %s", self.mail_to) return True - except (smtplib.SMTPException, ssl.SSLError) as err: - log.exception("Erreur SMTP: %s", err) - return False + except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err: + log.exception("Erreur SMTP: %s", err); return False except Exception as err: - log.exception("Echec envoi email: %s", err) - return False + log.exception("Echec envoi email: %s", err); return False -# ========= Helpers de mise en forme des messages ========= +# ========= Mise en forme messages ========= from zoneinfo import ZoneInfo PARIS = ZoneInfo("Europe/Paris") def fmt_deg(v: float) -> str: - s = f"{float(v):.1f}".replace(".", ",") - return f"{s}°C" + s = f"{float(v):.1f}".replace(".", ","); return f"{s}°C" def now_paris() -> dt.datetime: return dt.datetime.now(tz=PARIS) @@ -377,106 +344,150 @@ 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 -# ========= Fonctions de notification haut niveau ========= +# ========= Gyrophare MQTT ========= +class MQTTPublisher: + def __init__(self, site: str): + self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) + self.site = site + self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or + os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) + self.last_state: bool | None = None + + if not self.enabled: + log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou paho-mqtt absent).") + return + if not self.topic: + log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site) + self.enabled = False + return + + host = os.getenv("MQTT_HOST", "localhost") + port = int(os.getenv("MQTT_PORT", "1883")) + user = os.getenv("MQTT_USER") + pwd = os.getenv("MQTT_PASS") + tls = (os.getenv("MQTT_TLS", "0") == "1") + + # --- Création du client MQTT : compatible paho 1.x et 2.x --- + cbver = getattr(mqtt, "CallbackAPIVersion", None) + if cbver is not None: + # paho >= 2.x : on choisit la meilleure constante disponible + api_v = ( + getattr(cbver, "VERSION2", None) # paho 2.x + or getattr(cbver, "V5", None) # certaines builds + or getattr(cbver, "v5", None) # fallback + or getattr(cbver, "V311", None) # dernier recours + ) + try: + self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client() + except TypeError: + # vieux paho ne supporte pas l’argument callback_api_version + self.client = mqtt.Client() + else: + # paho 1.x + self.client = mqtt.Client() + # ------------------------------------------------------------ + + if user and pwd: + self.client.username_pw_set(user, pwd) + if tls: + self.client.tls_set() + + try: + self.client.connect(host, port, keepalive=30) + self.client.loop_start() + log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic) + except Exception as e: + log.exception("MQTT connexion impossible: %s", e) + self.enabled = False + + def set(self, on: bool): + if not self.enabled: + 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=1, retain=True) + r.wait_for_publish(timeout=3) + if r.rc != 0: + log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) + else: + log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper()) + self.last_state = on + except Exception as e: + log.exception("MQTT publish erreur: %s", e) + + +# ========= Notifs haut-niveau ========= 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) - sms_ok = notifier.send_sms(sms_text) - mail_ok = notifier.send_email(subject, email_body) - if sms_ok and mail_ok: - log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde) - elif sms_ok: - log.warning("Alerte: SMS OK, mail KO pour %s/%s", site, sonde) - elif mail_ok: - log.warning("Alerte: mail OK, SMS KO pour %s/%s", site, sonde) - else: - log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde) + notifier.send_sms(sms_text) + notifier.send_email(subject, email_body) + try: beacon.set(True) + except Exception: pass def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil) - sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul - if sms_ok: - log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde) - else: - log.warning("Acquittement: SMS KO pour %s/%s", site, sonde) + notifier.send_sms(sms_text) + try: + if not any_alert_open(site): + beacon.set(False) + except Exception: pass -# ========= Cycle & boucle de monitoring ========= -def run_monitor_cycle(site: str): +# ========= Cycle & boucle ========= +def run_monitor_cycle(site: str = SITE): sondes = lire_sondes_depuis_db(site) seuils = lire_seuils_depuis_db(site) - for r in sondes: - nom = str(r["Sonde"]) - temp = float(r["Temperature"]) - seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant - + nom = str(r["Sonde"]); temp = float(r["Temperature"]); seuil = float(seuils.get(nom, 6.0)) if temp > seuil: if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom): - creer_alerte(site, nom) - notifier_sur_depassement(site, nom, temp, seuil) + creer_alerte(site, nom); notifier_sur_depassement(site, nom, temp, seuil) else: if alerte_en_cours(site, nom): - acquitter_alerte(site, nom) - notifier_acquittement(site, nom, temp, seuil) + acquitter_alerte(site, nom); notifier_acquittement(site, nom, temp, seuil) -def run_monitor_loop(site: str, period_sec: int = 300): - log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", site, period_sec) +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) while True: t0 = time.time() try: run_monitor_cycle(site) - except Exception as err: # volontaire : ne jamais tuer le service + except Exception as err: log.exception("Erreur cycle monitoring: %s", err) time.sleep(max(0, period_sec - (time.time() - t0))) -# ========= Entrée CLI ========= +# ========= CLI ========= if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description="Monitor_Saclay") - parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay")) - parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)") - # tests - parser.add_argument("--test-sms", action="store_true") - parser.add_argument("--test-mail", action="store_true") - parser.add_argument("--test-alert", action="store_true") - parser.add_argument("--test-ok", action="store_true") - parser.add_argument("--once", action="store_true", help="exécuter un seul cycle puis quitter") - args = parser.parse_args() + p = argparse.ArgumentParser(description=PROGRAM_NAME) + p.add_argument("--period", type=int, default=300) + p.add_argument("--test-sms", action="store_true") + p.add_argument("--test-mail", action="store_true") + p.add_argument("--test-alert", action="store_true") + p.add_argument("--test-ok", action="store_true") + p.add_argument("--once", action="store_true") + args = p.parse_args() - if args.test_sms: - notifier.send_sms("TEST DOMO91 (transactionnel)") - elif args.test_mail: - notifier.send_email("[TEST DOMO91] Mail", "OK") - elif args.test_alert: - notifier_sur_depassement(args.site, "Congelateur", -14.5, -15.0) - elif args.test_ok: - notifier_acquittement(args.site, "Congelateur", -15.2, -15.0) + if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)") + elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK") + elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) + elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0) else: - if args.once: - run_monitor_cycle(args.site) - else: - run_monitor_loop(args.site, period_sec=args.period) + if args.once: run_monitor_cycle(SITE) + else: run_monitor_loop(SITE, period_sec=args.period) diff --git a/app/domo91.py b/app/domo91.py index 32881a9..715dfe7 100644 --- a/app/domo91.py +++ b/app/domo91.py @@ -36,7 +36,7 @@ for key, default in { db_config = { "host": os.getenv("DB_HOST"), "user": os.getenv("DB_USER"), - "password": os.getenv("DB_PASSWORD"), + "password": os.getenv("DB_PASS"), "database": os.getenv("DB_NAME") } diff --git a/app/tracker.py b/app/tracker.py index 9c81ffc..7f65f17 100644 --- a/app/tracker.py +++ b/app/tracker.py @@ -41,7 +41,7 @@ COL_DATE = "date" DB_CFG = dict( host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), - password=os.getenv("DB_PASSWORD"), + password=os.getenv("DB_PASS"), database=os.getenv("DB_NAME"), port=int(os.getenv("MYSQL_PORT", "3306")), ) diff --git a/app/utils_db.py b/app/utils_db.py index 8f24e26..a6ea1df 100644 --- a/app/utils_db.py +++ b/app/utils_db.py @@ -8,7 +8,7 @@ def connect_to_mysql(): return mysql.connector.connect( host=os.getenv("DB_HOST"), user=os.getenv("DB_USER"), - password=os.getenv("DB_PASSWORD"), + password=os.getenv("DB_PASS"), database=os.getenv("DB_NAME") )