From 4bd43a3611f51904bb0ffb0a8bf92c198ed2b980 Mon Sep 17 00:00:00 2001 From: Michel Date: Fri, 3 Oct 2025 13:52:13 +0200 Subject: [PATCH] =?UTF-8?q?Remise=20en=20=C3=A9tat=20des=20userforms=20inv?= =?UTF-8?q?entaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 +- app/Monitor_Meudon.py | 143 ++++++++++++++++++++++++++++++++++++++---- app/Monitor_Saclay.py | 134 ++++++++++++++++++++++++++++++++++++--- app/domo91.py | 1 + 4 files changed, 257 insertions(+), 23 deletions(-) diff --git a/.env b/.env index 0f7b127..0130ce2 100644 --- a/.env +++ b/.env @@ -47,7 +47,7 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6 OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SENDER=DOMO91FR -SMS_RECEIVER=+33635164680 +SMS_RECEIVER=+33759600180 ALERT_SMS_TO_SACLAY=Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680 diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index 1ec50aa..5f18022 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -27,7 +27,7 @@ try: _ovh_available = True except Exception: ovh = None # type: ignore - class OVHAPIError(Exception): pass + class OVHAPIError(Exception): ... _ovh_available = False # MQTT @@ -44,7 +44,7 @@ if not log.handlers: logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s") # ========= 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. Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client). @@ -59,7 +59,7 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: 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')) + (sonde, dt_.strftime('%Y-%m-%d %H:%M:%S')) ) conn.commit() cur.close() @@ -92,6 +92,45 @@ def get_db(): autocommit=True, ) +# --- Journalisation Gyro en table dédiée `Gyro` --- +def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, + qos: int | None, retained: int | None, when: datetime): + cnx = get_db() + try: + cur = cnx.cursor() + cur.execute( + "INSERT INTO `Gyro` (Lieu, Sonde, Etat, Date, Topic, Payload, QoS, Retained) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + ( + lieu, + os.getenv("GYRO_SONDE_NAME", "Gyro"), + etat, # 'ON' ou 'OFF' + when.strftime('%Y-%m-%d %H:%M:%S'), + topic, + payload_raw, + qos, + retained + ) + ) + cnx.commit() + log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic) + except MySQLError as err: + log.exception("Erreur DB insert_gyro_log: %s", err) + finally: + cnx.close() + +def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: + sql = "SELECT Etat FROM `Gyro` WHERE Lieu=%s AND Sonde=%s ORDER BY Date DESC LIMIT 1" + cnx = get_db() + try: + cur = cnx.cursor() + cur.execute(sql, (lieu, sonde)) + row = cur.fetchone() + return (row is None) or (row[0] != etat) + finally: + cnx.close() + +# --- Lecture des dernières mesures de température (en ignorant lignes d'état) --- def lire_sondes_depuis_db(site: str): table = site sql = f""" @@ -100,8 +139,10 @@ def lire_sondes_depuis_db(site: str): JOIN ( SELECT Sonde, MAX(Date) AS MaxDate FROM `{table}` + WHERE Temperature IS NOT NULL GROUP BY Sonde ) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate + WHERE t1.Temperature IS NOT NULL """ cnx = get_db() try: @@ -109,7 +150,7 @@ def lire_sondes_depuis_db(site: str): cur.execute(sql) rows = cur.fetchall() for r in rows: - r["Temperature"] = float(r["Temperature"]) + r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL return rows except MySQLError as err: log.exception("Erreur DB (lire_sondes_depuis_db): %s", err) @@ -117,6 +158,7 @@ def lire_sondes_depuis_db(site: str): finally: cnx.close() + def lire_cfg_chambres(site: str): """ Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}} @@ -236,7 +278,7 @@ def _split_list(raw: str | None) -> list[str]: def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] for tok in re.split(r"[;,]", raw or ""): - tok = tok.strip().strip('"').strip("'") + tok = tok.strip() if not tok: continue if ":" in tok: @@ -465,6 +507,7 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat 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 ========= @@ -494,6 +537,7 @@ class MQTTPublisher: 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: api_v = ( @@ -508,6 +552,7 @@ class MQTTPublisher: self.client = mqtt.Client() else: self.client = mqtt.Client() + # ------------------------------------------------------------ if user and pwd: self.client.username_pw_set(user, pwd) @@ -515,13 +560,70 @@ class MQTTPublisher: self.client.tls_set() try: + # Attacher le callback avant de s'abonner + self.client.on_message = self._on_message + self.client.connect(host, port, keepalive=30) + + # Abonnements (depuis env ou valeurs par défaut raisonnables) + subs_env = ( + os.getenv(f"GYRO_MQTT_SUB_{site}") or + os.getenv(f"GYRO_MQTT_SUB_{site.upper()}") or + os.getenv("GYRO_MQTT_SUB") or + "" + ) + subs = [t.strip() for t in subs_env.split(",") if t.strip()] + if not subs: + subs = [ + self.topic, # ex: Sondes/Saclay/Gyro/cmd + f"Sondes/{site}/Gyro/#", + f"{site}/Gyro/#", + "Gyro/#", + ] + for t in subs: + try: + self.client.subscribe(t, qos=2) + log.info("MQTT subscribe: %s", t) + except Exception as e: + log.warning("Subscribe échoué (%s): %s", t, e) + 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 + # --- Callback réception MQTT --- + def _on_message(self, client, userdata, msg): + lieu = self.site + topic = msg.topic + payload_raw = msg.payload.decode(errors="ignore").strip() + upper = payload_raw.upper() + + # 1) Évènements gyrophare + if upper in ("ON", "OFF") or "gyro" in topic.lower() or "gyrophare" in topic.lower(): + etat = upper if upper in ("ON", "OFF") else ("ON" if "on" in upper else "OFF") + try: + if should_insert_gyro(lieu, etat): + insert_gyro_log( + lieu=lieu, + etat=etat, + topic=topic, + payload_raw=payload_raw, + qos=getattr(msg, "qos", None), + retained=getattr(msg, "retain", None), + when=now_paris() + ) + except Exception as e: + log.exception("Insert Gyro échoué: %s", e) + return # ne pas poursuivre vers un parse température ici + + # 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs) + try: + float(payload_raw.replace(",", ".")) + except ValueError: + log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw) + def set(self, on: bool): if not self.enabled: return @@ -530,11 +632,27 @@ class MQTTPublisher: payload = "ON" if on else "OFF" try: r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) - r.wait_for_publish(timeout=3) - if r.rc != 0: - log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) + try: + r.wait_for_publish(timeout=3) + except Exception: + pass + if getattr(r, 'rc', 0) != 0: + log.warning("MQTT publish rc=%s (topic=%s)", getattr(r, 'rc', None), self.topic) else: log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper()) + # Enregistrer en base l'événement gyro + try: + insert_gyro_log( + lieu=self.site, + etat=payload, + topic=self.topic, + payload_raw=payload, + qos=2, + retained=1 if getattr(r, 'is_published', lambda: False)() else None, + when=now_paris() + ) + except Exception as e: + log.exception("Insert événement gyro en base a échoué: %s", e) self.last_state = on except Exception as e: log.exception("MQTT publish erreur: %s", e) @@ -728,6 +846,7 @@ def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): """ 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 souhaité if os.getenv("ALERT_OK_SMS", "0") == "1": notifier.send_sms(sms_text) @@ -755,13 +874,13 @@ def run_monitor_cycle(site: str = SITE): nom = str(r["Sonde"]) temp = float(r["Temperature"]) seuil = float(seuils.get(nom, 6.0)) - now = now_paris() + 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): + if open_alert(conn, f"Alertes_{site}", nom, now_): notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client finally: conn.close() @@ -810,9 +929,9 @@ if __name__ == "__main__": 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) + notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) elif args.test_ok: - notifier_acquittement(SITE, "Chambre_N1", -15.2, -15.0) + notifier_acquittement(SITE, "Congelateur", -15.2, -15.0) else: if args.once: run_monitor_cycle(SITE) diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 0c6b23a..ace0d6c 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -27,7 +27,7 @@ try: _ovh_available = True except Exception: ovh = None # type: ignore - class OVHAPIError(Exception): pass + class OVHAPIError(Exception): ... _ovh_available = False # MQTT @@ -44,7 +44,7 @@ if not log.handlers: logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s") # ========= 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. Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client). @@ -59,7 +59,7 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: 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')) + (sonde, dt_.strftime('%Y-%m-%d %H:%M:%S')) ) conn.commit() cur.close() @@ -92,6 +92,45 @@ def get_db(): autocommit=True, ) +# --- Journalisation Gyro en table dédiée `Gyro` --- +def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, + qos: int | None, retained: int | None, when: datetime): + cnx = get_db() + try: + cur = cnx.cursor() + cur.execute( + "INSERT INTO Sondes.Gyro (Lieu, Sonde, Etat, Date, Topic, Payload, QoS, Retained) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + ( + lieu, + os.getenv("GYRO_SONDE_NAME", "Gyro"), + etat, # 'ON' ou 'OFF' + when.strftime('%Y-%m-%d %H:%M:%S'), + topic, + payload_raw, + qos, + retained + ) + ) + cnx.commit() + log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic) + except MySQLError as err: + log.exception("Erreur DB insert_gyro_log: %s", err) + finally: + cnx.close() + +def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: + sql = "SELECT Etat FROM Sondes.Gyro WHERE Lieu=%s AND Sonde=%s ORDER BY Date DESC LIMIT 1" + cnx = get_db() + try: + cur = cnx.cursor() + cur.execute(sql, (lieu, sonde)) + row = cur.fetchone() + return (row is None) or (row[0] != etat) + finally: + cnx.close() + +# --- Lecture des dernières mesures de température (en ignorant lignes d'état) --- def lire_sondes_depuis_db(site: str): table = site sql = f""" @@ -100,8 +139,10 @@ def lire_sondes_depuis_db(site: str): JOIN ( SELECT Sonde, MAX(Date) AS MaxDate FROM `{table}` + WHERE Temperature IS NOT NULL GROUP BY Sonde ) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate + WHERE t1.Temperature IS NOT NULL """ cnx = get_db() try: @@ -109,7 +150,7 @@ def lire_sondes_depuis_db(site: str): cur.execute(sql) rows = cur.fetchall() for r in rows: - r["Temperature"] = float(r["Temperature"]) + r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL return rows except MySQLError as err: log.exception("Erreur DB (lire_sondes_depuis_db): %s", err) @@ -117,6 +158,7 @@ def lire_sondes_depuis_db(site: str): finally: cnx.close() + def lire_cfg_chambres(site: str): """ Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}} @@ -518,13 +560,70 @@ class MQTTPublisher: self.client.tls_set() try: + # Attacher le callback avant de s'abonner + self.client.on_message = self._on_message + self.client.connect(host, port, keepalive=30) + + # Abonnements (depuis env ou valeurs par défaut raisonnables) + subs_env = ( + os.getenv(f"GYRO_MQTT_SUB_{site}") or + os.getenv(f"GYRO_MQTT_SUB_{site.upper()}") or + os.getenv("GYRO_MQTT_SUB") or + "" + ) + subs = [t.strip() for t in subs_env.split(",") if t.strip()] + if not subs: + subs = [ + self.topic, # ex: Sondes/Saclay/Gyro/cmd + f"Sondes/{site}/Gyro/#", + f"{site}/Gyro/#", + "Gyro/#", + ] + for t in subs: + try: + self.client.subscribe(t, qos=2) + log.info("MQTT subscribe: %s", t) + except Exception as e: + log.warning("Subscribe échoué (%s): %s", t, e) + 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 + # --- Callback réception MQTT --- + def _on_message(self, client, userdata, msg): + lieu = self.site + topic = msg.topic + payload_raw = msg.payload.decode(errors="ignore").strip() + upper = payload_raw.upper() + + # 1) Évènements gyrophare + if upper in ("ON", "OFF") or "gyro" in topic.lower() or "gyrophare" in topic.lower(): + etat = upper if upper in ("ON", "OFF") else ("ON" if "on" in upper else "OFF") + try: + if should_insert_gyro(lieu, etat): + insert_gyro_log( + lieu=lieu, + etat=etat, + topic=topic, + payload_raw=payload_raw, + qos=getattr(msg, "qos", None), + retained=getattr(msg, "retain", None), + when=now_paris() + ) + except Exception as e: + log.exception("Insert Gyro échoué: %s", e) + return # ne pas poursuivre vers un parse température ici + + # 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs) + try: + float(payload_raw.replace(",", ".")) + except ValueError: + log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw) + def set(self, on: bool): if not self.enabled: return @@ -533,11 +632,27 @@ class MQTTPublisher: payload = "ON" if on else "OFF" try: r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) - r.wait_for_publish(timeout=3) - if r.rc != 0: - log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) + try: + r.wait_for_publish(timeout=3) + except Exception: + pass + if getattr(r, 'rc', 0) != 0: + log.warning("MQTT publish rc=%s (topic=%s)", getattr(r, 'rc', None), self.topic) else: log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper()) + # Enregistrer en base l'événement gyro + try: + insert_gyro_log( + lieu=self.site, + etat=payload, + topic=self.topic, + payload_raw=payload, + qos=2, + retained=1 if getattr(r, 'is_published', lambda: False)() else None, + when=now_paris() + ) + except Exception as e: + log.exception("Insert événement gyro en base a échoué: %s", e) self.last_state = on except Exception as e: log.exception("MQTT publish erreur: %s", e) @@ -759,13 +874,13 @@ def run_monitor_cycle(site: str = SITE): nom = str(r["Sonde"]) temp = float(r["Temperature"]) seuil = float(seuils.get(nom, 6.0)) - now = now_paris() + 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): + if open_alert(conn, f"Alertes_{site}", nom, now_): notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client finally: conn.close() @@ -822,4 +937,3 @@ if __name__ == "__main__": run_monitor_cycle(SITE) else: run_monitor_loop(SITE, period_sec=args.period) - diff --git a/app/domo91.py b/app/domo91.py index 67caf9f..8492dec 100644 --- a/app/domo91.py +++ b/app/domo91.py @@ -9,6 +9,7 @@ import matplotlib.dates as mdates import matplotlib.pyplot as plt import mysql.connector import pandas as pd +pd.set_option('future.no_silent_downcasting', True) import streamlit as st from contextlib import closing from dotenv import load_dotenv