diff --git a/.env b/.env index 522265b..2275150 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ + # OVH_SMS_SENDER=DOMO91FR #connexion mysql DB_HOST=162.19.78.131 @@ -6,10 +7,15 @@ DB_PASS=TX.)-U1!zq5Axdk4 DB_NAME=Sondes # MQTT -GYRO_MODE=mqtt MQTT_HOST=54.36.188.119 MQTT_USER=Bwps -MQTT_PASS=scJ5ACj2keRfI^ +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 GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare @@ -20,7 +26,7 @@ SMTP_SECURITY=STARTTLS SMTP_USER=services@domo91.fr SMTP_PASS=6ZiCsVtSf9@nEHv@$^0 MAIL_FROM=services@domo91.fr -MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com, +MAIL_TO_SACLAY=robots@domo91.fr MAIL_FROM_SACLAY="DOMO91 Saclay " MAIL_TO_MEUDON=robots@domo91.fr MAIL_FROM_MEUDON="DOMO91 Meudon " @@ -36,5 +42,8 @@ SMS_RECEIVER=+33635164680 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 +# --- 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 diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 0e37cd8..2832717 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -129,7 +129,7 @@ def lire_cfg_chambres(site: str): cfg: dict[str, dict] = {} try: cur = cnx.cursor() - cur.execute(sql, (site,)) + cur.execute(sql, (site, )) for sonde, temp_max, etat, en_entretien in cur.fetchall(): cfg[str(sonde)] = { "temp_max": float(temp_max), @@ -154,7 +154,7 @@ def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis if not meta or not meta["active"] or meta["entretien"]: continue 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 False, None @@ -168,7 +168,7 @@ def lire_seuils_depuis_db(site: str): seuils = {} try: cur = cnx.cursor() - cur.execute(sql, (site,)) + cur.execute(sql, (site, )) for sonde, s in cur.fetchall(): seuils[str(sonde)] = float(s) return seuils @@ -190,7 +190,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: WHERE Sonde=%s ORDER BY Date DESC LIMIT 1 - """, (sonde,)) + """, (sonde, )) last = cur.fetchone() if not last: return False @@ -229,6 +229,7 @@ def any_alert_open(site: str) -> bool: return False finally: cnx.close() + # ========= Helpers listes/numéros ========= def _split_list(raw: str | None) -> list[str]: return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] @@ -401,18 +402,18 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat # ========= Gyrophare MQTT ========= class MQTTPublisher: 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.topic = ( - os.getenv(f"GYRO_MQTT_TOPIC_{site}") or - os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or - os.getenv("GYRO_MQTT_TOPIC") or - f"Sondes/{site}/Gyro/cmd" + os.getenv(f"GYRO_MQTT_TOPIC_{site}") or + os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or + os.getenv("GYRO_MQTT_TOPIC") or + f"Sondes/{site}/Gyro/cmd" ) self.last_state: bool | None = None 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 if not self.topic: log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site) @@ -428,20 +429,17 @@ class MQTTPublisher: # --- 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 + getattr(cbver, "VERSION2", None) + or getattr(cbver, "V5", None) + or getattr(cbver, "v5", None) + or getattr(cbver, "V311", None) ) 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() # ------------------------------------------------------------ @@ -476,6 +474,125 @@ class MQTTPublisher: except Exception as e: log.exception("MQTT publish erreur: %s", e) +# ========= Contrôleur Gyro réactif (pulse/cooldown ou continu) ========= +import enum, threading + +class _GyroState(enum.Enum): + IDLE = 0 + PULSE_ON = 1 + COOLDOWN = 2 + +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. + """ + def __init__(self, site: str, beacon, *, + 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.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 envoyé pour éviter les doublons + + 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 _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}} + 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: + 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 + 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") + + 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é)") + 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 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 d’alerte)") + 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 ========= notifier = Notifier() @@ -496,20 +613,18 @@ def run_monitor_cycle(site: str = SITE): last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}] cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}} - # 2) Gyro instantané : ON si >=1 sonde active & non en entretien dépasse son seuil + # 2) Gyro géré par le contrôleur rapide → ici, on ne touche plus au gyro 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("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se) + log.info("Dépassement détecté (pilotage gyro par boucle rapide) : %s %.2f > %.2f", s, t, se) else: - log.info("Gyro %s => OFF (aucun dépassement)", site) - beacon.set(gyro_on) + log.info("Aucun dépassement au moment du cycle") 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 - # On reconstitue un dict seuils pour réutiliser ta logique existante en dessous. seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} for r in last_rows: @@ -520,7 +635,6 @@ def run_monitor_cycle(site: str = SITE): now = now_paris() if temp > seuil: if depassement_depuis_30min(site, nom, seuil): - # Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle try: conn = get_db() if open_alert(conn, f"Alertes_{site}", nom, now): @@ -528,7 +642,6 @@ def run_monitor_cycle(site: str = SITE): finally: conn.close() else: - # Fermer si ouvert → notifier seulement si fermeture réelle try: conn = get_db() if close_alert(conn, f"Alertes_{site}", nom): @@ -536,10 +649,17 @@ def run_monitor_cycle(site: str = SITE): 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) + try: + global _gyro_controller + _gyro_controller = GyroPulseController(site, beacon) + _gyro_controller.start() + except Exception as e: + log.exception("Impossible de démarrer le GyroPulseController: %s", e) + while True: t0 = time.time() try: @@ -560,10 +680,16 @@ if __name__ == "__main__": 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) + 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) + if args.once: + run_monitor_cycle(SITE) + else: + run_monitor_loop(SITE, period_sec=args.period) diff --git a/app/gyro_control.py b/app/gyro_control.py new file mode 100644 index 0000000..4499f65 --- /dev/null +++ b/app/gyro_control.py @@ -0,0 +1,165 @@ +# gyro_control.py +import os, time, enum, logging, threading +import mysql.connector # pip install mysql-connector-python +import paho.mqtt.client as mqtt # pip install paho-mqtt + +log = logging.getLogger("gyro") + +# Paramètres par défaut (surclassables via env ou arguments) +DEF_CHECK_SEC = int(os.getenv("GYRO_CHECK_SEC", "20")) +DEF_PULSE_SEC = int(os.getenv("GYRO_PULSE_SEC", "60")) +DEF_COOLDOWN_SEC = int(os.getenv("GYRO_COOLDOWN_SEC", "600")) +DEF_NORMAL_CONFIRM = int(os.getenv("GYRO_NORMAL_CONFIRM", "2")) + +class GyroState(enum.Enum): + IDLE = 0 + PULSE_ON = 1 + COOLDOWN = 2 + +class MqttGyroDriver: + def __init__(self, host, port, user, password, topic_command): + self.topic_command = topic_command + self.client = mqtt.Client() + if user: + self.client.username_pw_set(user, password or "") + self.client.connect(host, int(port or 1883), keepalive=30) + self.client.loop_start() + + def set(self, on: bool): + payload = "ON" if on else "OFF" + res = self.client.publish(self.topic_command, payload=payload, qos=1, retain=False) + res.wait_for_publish(timeout=5) + log.info("MQTT → %s : %s", self.topic_command, payload) + + def close(self): + try: + self.client.loop_stop(); self.client.disconnect() + except Exception: + pass + +class GyroController: + """ + Boucle indépendante et légère : lit l'état d'alerte en SQL et pulse le gyro via MQTT. + """ + def __init__( + self, + *, + site_name: str, + db_cfg: dict, + alertes_table: str, + mqtt_driver: MqttGyroDriver, + check_sec: int = DEF_CHECK_SEC, + pulse_sec: int = DEF_PULSE_SEC, + cooldown_sec: int = DEF_COOLDOWN_SEC, + normal_confirm: int = DEF_NORMAL_CONFIRM, + ): + self.site = site_name + self.db_cfg = db_cfg + self.alertes_table = alertes_table + self.mqtt = mqtt_driver + 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._current_gyro_on = None + self._thread = None + + # --- helpers --- + def _set_gyro(self, on: bool): + if self._current_gyro_on is not on: + self.mqtt.set(on) + self._current_gyro_on = on + + def _has_active_alert(self, cur) -> bool: + cur.execute(f"SELECT COUNT(*) FROM `{self.alertes_table}` WHERE Etat='En cours'") + return cur.fetchone()[0] > 0 + + # --- lifecycle --- + 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("[%s] GyroController démarré (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() + + # --- main loop --- + def _run(self): + # Ouverture connexion MySQL persistante + while not self._stop.is_set(): + try: + cnx = mysql.connector.connect(autocommit=True, **self.db_cfg) + cur = cnx.cursor() + break + except Exception as e: + log.error("[%s] Connexion MySQL KO (%s). Retry 5s…", self.site, e) + time.sleep(5) + try: + while not self._stop.is_set(): + now = time.time() + try: + active = self._has_active_alert(cur) + except Exception as e: + log.error("[%s] Lecture alertes KO: %s", self.site, e) + active = False # prudence + + if self.state == GyroState.IDLE: + if active: + self._set_gyro(True) + self._t_pulse_end = now + self.pulse_sec + self.state = GyroState.PULSE_ON + self._normal_count = 0 + log.info("[%s] Gyro ON (pulse %ss)", self.site, self.pulse_sec) + + 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("[%s] Gyro OFF (retour à la normale)", self.site) + else: + self._normal_count = 0 + if now >= self._t_pulse_end: + self._set_gyro(False) + self._t_cooldown_end = now + self.cooldown_sec + self.state = GyroState.COOLDOWN + log.info("[%s] Gyro OFF → cooldown %ss", self.site, 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("[%s] Retour IDLE", self.site) + 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("[%s] Gyro ON (re-pulse)", self.site) + + time.sleep(self.check_sec) + finally: + try: + self._set_gyro(False) + except Exception: + pass + try: + cur.close(); cnx.close() + except Exception: + pass + log.info("[%s] GyroController stoppé", self.site) \ No newline at end of file diff --git a/app/surveillance_releves.py b/app/surveillance_releves.py index ad3b8aa..461810c 100644 --- a/app/surveillance_releves.py +++ b/app/surveillance_releves.py @@ -34,7 +34,7 @@ SERVICE_NAME = os.getenv('OVH_SMS_SERVICE') SMS_RECEIVER = os.getenv('SMS_RECEIVER') SMS_SENDER = os.getenv('OVH_SMS_SENDER') -tables = ['Saclay', 'Meudon', 'Chaufferie'] +tables = ['Saclay', 'Meudon'] DELAI_MINUTES = 15 RAPPEL_HEURES = 6