diff --git a/.env b/.env index d8fa205..4305bd6 100644 --- a/.env +++ b/.env @@ -21,7 +21,12 @@ ALERT_OK_SMS=0 GYRO_SMS_MIN_SEC=120 GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare +# Délais +ALERT_CONTINUOUS_MINUTES=30 +ALERT_LOOKBACK_MINUTES=120 +# Logs +LOGLEVEL=INFO # paramètres mail SMTP_HOST=ssl0.ovh.net @@ -43,8 +48,12 @@ OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SENDER=DOMO91FR SMS_RECEIVER=+33635164680 -ALERT_SMS_TO_SACLAY==Michel:+33635164680 +ALERT_SMS_TO_SACLAY=Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680 -# ---- Reserve SMS -#Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 -#Sekou:+33625903364,Damien:+33680388259 \ No newline at end of file + +ALERT_SMS_CLIENT_TO_MEUDON=Sekou:+33625903364,Damien:+33680388259 +ALERT_SMS_CLIENT_TO_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 +# Activer/désactiver globalement l’envoi client +ALERT_SMS_CLIENT_ENABLED=1 + + diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index 2b542dc..1ec50aa 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- + # ========= Site ========= SITE = "Meudon" PROGRAM_NAME = f"Monitor_{SITE}" @@ -11,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: @@ -34,15 +38,16 @@ except Exception: _mqtt_ok = False # ========= Logger ========= +level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO) log = logging.getLogger(PROGRAM_NAME.lower()) if not log.handlers: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s") # ========= DB utils ========= 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 & SMS client). """ cur = conn.cursor() cur.execute( @@ -51,20 +56,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( @@ -76,10 +80,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"), @@ -87,7 +91,6 @@ def get_db(): port=int(os.getenv("DB_PORT", "3306")), autocommit=True, ) - return cnx def lire_sondes_depuis_db(site: str): table = site @@ -114,29 +117,9 @@ def lire_sondes_depuis_db(site: str): finally: cnx.close() -def lire_seuils_depuis_db(site: str): - sql = """ - SELECT Sonde, Temp_Max - FROM Chambres_froides - WHERE Lieu=%s AND Etat='ON' - """ - cnx = get_db() - seuils = {} - try: - cur = cnx.cursor() - cur.execute(sql, (site,)) - for sonde, s in cur.fetchall(): - seuils[str(sonde)] = float(s) - return seuils - except MySQLError as err: - log.exception("Erreur DB (lire_seuils_depuis_db): %s", err) - return seuils - finally: - cnx.close() - def lire_cfg_chambres(site: str): """ - 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,10 +128,10 @@ def lire_cfg_chambres(site: str): WHERE Lieu=%s """ cnx = get_db() - cfg = {} + 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), @@ -164,7 +147,7 @@ def lire_cfg_chambres(site: str): def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0): """ - Retourne (is_on: bool, trigger: (sonde, temp, seuil) | None) + Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None) """ for row in last_values: sonde = str(row["Sonde"]) @@ -172,61 +155,80 @@ def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis if not meta or not meta["active"] or meta["entretien"]: 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 +def lire_seuils_depuis_db(site: str): + sql = """ + SELECT Sonde, Temp_Max + FROM Chambres_froides + WHERE Lieu=%s AND Etat='ON' + """ + cnx = get_db() + seuils = {} + try: + cur = cnx.cursor() + cur.execute(sql, (site, )) + for sonde, s in cur.fetchall(): + seuils[str(sonde)] = float(s) + return seuils + except MySQLError as err: + log.exception("Erreur DB (lire_seuils_depuis_db): %s", err) + return seuils + finally: + cnx.close() + +# --- Dépassement continu (configurable) --- def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: + """ + True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes. + CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30) + LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3)) + """ + CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30")) + LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3)))) + table = site cnx = get_db() try: cur = cnx.cursor() - 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, LOOKBACK)) + rows = cur.fetchall() + if not rows: return False - last_temp, last_date = float(last[0]), last[1] + + last_temp, last_dt = float(rows[0][0]), rows[0][1] if last_temp <= float(seuil): 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 + # Début de la séquence continue > seuil + start_dt = last_dt + for temp, d in rows[1:]: + if float(temp) > float(seuil): + start_dt = d + else: + break + + tzinfo = getattr(start_dt, "tzinfo", None) + now = dt.datetime.now(tz=tzinfo) + dur_min = (now - start_dt).total_seconds() / 60.0 + log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d", + sonde, start_dt, now, dur_min, CONT_MIN) + return dur_min >= CONT_MIN - now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None)) - return (now - first_over) >= dt.timedelta(minutes=30) except MySQLError as err: - 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() - try: - cur = cnx.cursor() - cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1") - return cur.fetchone() is not None - except MySQLError as err: - log.exception("Erreur DB (any_alert_open): %s", err) - return False - finally: - cnx.close() # ========= Helpers listes/numéros ========= def _split_list(raw: str | None) -> list[str]: return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] @@ -234,7 +236,7 @@ def _split_list(raw: str | None) -> list[str]: def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]: out: list[tuple[str, str]] = [] for tok in re.split(r"[;,]", raw or ""): - tok = tok.strip() + tok = tok.strip().strip('"').strip("'") if not tok: continue if ":" in tok: @@ -254,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]: def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) -# ========= Notifier (SMS + Mail) ========= +# ========= Notifier (SMS interne + SMS client + Mail) ========= class Notifier: def __init__(self): # OVH SMS @@ -270,11 +272,22 @@ class Notifier: ) self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_sender = os.getenv("OVH_SMS_SENDER") - raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO")) + raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO")) self.sms_labeled = _parse_labeled_phones(raw_sms) else: self.sms_labeled = [] + # SMS CLIENTS (site-spécifique + génériques + compat FR) + raw_sms_client = ( + os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or + os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or + os.getenv("ALERT_SMS_CLIENT_TO") or + os.getenv(f"ALERTE_CLIENT_{SITE}") or + os.getenv("ALERTE_CLIENT") + ) + self.sms_client_labeled = _parse_labeled_phones(raw_sms_client) + self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1") + # SMTP self.smtp_host = os.getenv("SMTP_HOST") self.smtp_port = int(os.getenv("SMTP_PORT","465")) @@ -282,9 +295,9 @@ class Notifier: self.smtp_pass = os.getenv("SMTP_PASS") self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() - raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "") + raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "") self.mail_to = _split_list(raw_mail_to) - self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user) + self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.upper()}") or os.getenv("MAIL_FROM") or self.smtp_user) self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to]) @@ -297,10 +310,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", @@ -330,6 +346,45 @@ class Notifier: except Exception as err: log.exception("Echec envoi SMS OVH: %s", err); return False + def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool: + if not self.ovh_enabled: + log.warning("SMS client: OVH non configuré."); return False + if not self.sms_client_enabled or not self.sms_client_labeled: + log.info("SMS client: désactivé ou aucun destinataire."); return False + + only = os.getenv("ALERT_SMS_CLIENT_ONLY") + if only: + allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()} + labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)] + else: + labeled = self.sms_client_labeled + + receivers = [num for (_n, num) in labeled] + if not receivers: + log.info("SMS client: filtre vide → aucun envoi."); return False + + message = normaliser_sms(message, prefix=SITE) + + payload = { + "sender": self.ovh_sender, + "receivers": receivers, + "message": message, + "priority": "high", + "coding": "7bit", + "class": "phoneDisplay", + "noStopClause": True, + "senderForResponse": False, + "validityPeriod": 2880, + "tag": tag, + } + try: + log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled)) + resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload) + log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids")) + return True + except Exception as err: + log.exception("Echec SMS CLIENT OVH: %s", err); return False + def send_email(self, subject: str, body: str) -> bool: if not self.smtp_enabled: log.warning("SMTP non configuré, email non envoyé."); return False @@ -385,32 +440,48 @@ def now_paris() -> dt.datetime: def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): 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 +def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str: + when = when or now_paris() + return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}" + # ========= Gyrophare MQTT ========= 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) @@ -423,25 +494,20 @@ 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: - # 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() - # ------------------------------------------------------------ if user and pwd: self.client.username_pw_set(user, pwd) @@ -461,7 +527,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,68 +539,251 @@ class MQTTPublisher: except Exception as e: log.exception("MQTT publish erreur: %s", e) +# ========= Contrôleur Gyro réactif ========= +import enum, threading + +class _GyroState(enum.Enum): + IDLE = 0 + PULSE_ON = 1 + COOLDOWN = 2 + +class GyroPulseController: + """ + Boucle rapide indépendante : + - MODE CONTINU (défaut) : ON tant que 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 (activé par défaut) + """ + def __init__(self, site: str, beacon, notifier, *, + check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")), + pulse_sec: int = int(os.getenv("GYRO_PULSE_SEC", "60")), + cooldown_sec: int = int(os.getenv("GYRO_COOLDOWN_SEC", "600")), + normal_confirm: int = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))): + self.site = site + self.beacon = beacon + self.notifier = notifier + self.check_sec = check_sec + self.pulse_sec = pulse_sec + self.cooldown_sec = cooldown_sec + self.normal_confirm = normal_confirm + + self.state = _GyroState.IDLE + self._t_pulse_end = 0.0 + self._t_cooldown_end = 0.0 + self._normal_count = 0 + self._stop = threading.Event() + self._thread = None + self._current = None # dernier état effectif + + # Anti-spam SMS & SMS OK activé par défaut + self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi} + self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120")) + self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1") + + # Conserver le dernier déclencheur (pour SMS OK) + self._last_trigger: tuple[str, float, float] | None = None # (sonde, temp, seuil) + + def _set_gyro(self, on: bool): + if self._current is not on: + self.beacon.set(on) + self._current = on + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + log.info("GyroPulseController démarré (site=%s, check=%ss, pulse=%ss, cooldown=%ss, confirm=%d)", + self.site, self.check_sec, self.pulse_sec, self.cooldown_sec, self.normal_confirm) + + def stop(self): + self._stop.set() + + def _sms_can_send(self, sonde: str) -> bool: + t = time.time() + last = self._last_sms.get(sonde, 0.0) + if (t - last) >= self._sms_min_sec: + self._last_sms[sonde] = t + return True + return False + + def _send_alert_sms(self, trigger: tuple[str, float, float] | None): + if not trigger: + return + sonde, temp, seuil = trigger + if self._sms_can_send(sonde): + _, sms_text, _ = build_alert_text(self.site, sonde, temp, seuil, when=now_paris()) + self.notifier.send_sms(sms_text) + + def _send_ok_sms_from_last_trigger(self): + if not self._send_ok or not self._last_trigger: + return + sonde, _temp_prev, seuil = self._last_trigger + + # Température courante pour le SMS OK + rows = lire_sondes_depuis_db(self.site) + curr_temp = None + for r in rows: + if str(r["Sonde"]) == sonde: + curr_temp = float(r["Temperature"]); break + if curr_temp is None: + curr_temp = seuil - 0.1 # fallback léger + + if self._sms_can_send(sonde): + _, sms_text, _ = build_ok_text(self.site, sonde, curr_temp, seuil, when=now_paris()) + self.notifier.send_sms(sms_text) + + self._last_trigger = None # reset + + def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]: + last_rows = lire_sondes_depuis_db(self.site) + cfg = lire_cfg_chambres(self.site) + return compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) + + def _run(self): + while not self._stop.is_set(): + now = time.time() + try: + active, trigger = self._is_alarm_now() + except Exception as e: + log.exception("Gyro fast-loop: erreur lecture état: %s", e) + active, trigger = (False, None) + + if self.state == _GyroState.IDLE: + if active: + self._set_gyro(True) + self._t_pulse_end = now + self.pulse_sec + self._normal_count = 0 + self.state = _GyroState.PULSE_ON + self._last_trigger = trigger + if trigger: + s, t, se = trigger + log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)", + s, t, se, "CONTINU" if os.getenv("GYRO_MODE_CONTINUOUS","1")=="1" else "PULSE") + # SMS alerte immédiat + self._send_alert_sms(trigger) + + elif self.state == _GyroState.PULSE_ON: + if not active: + self._normal_count += 1 + if self._normal_count >= self.normal_confirm: + self._set_gyro(False) + self.state = _GyroState.IDLE + self._normal_count = 0 + log.info("Gyro → OFF (retour à la normale confirmé)") + # SMS OK immédiat + self._send_ok_sms_from_last_trigger() + else: + self._normal_count = 0 + if os.getenv("GYRO_MODE_CONTINUOUS", "1") != "1": + if now >= self._t_pulse_end: + self._set_gyro(False) + self._t_cooldown_end = now + self.cooldown_sec + self.state = _GyroState.COOLDOWN + log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec) + + elif self.state == _GyroState.COOLDOWN: + if not active: + self._normal_count += 1 + if self._normal_count >= self.normal_confirm: + self.state = _GyroState.IDLE + self._normal_count = 0 + log.info("Gyro: retour IDLE (plus 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() 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) - notifier.send_email(subject, email_body) + """ + MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base. + + SMS CLIENT couplé (ALERTE_CLIENT_{SITE}). + (Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.) + """ + subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil) + notifier.send_email(subject, email_body) # MAIL (≥30 min) + + # ➕ SMS client couplé au mail 30 min + if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1": + client_msg = build_client_alert_sms(site, sonde, temp, seuil) + notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}") def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): - 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" est envoyé immédiatement par la boucle gyro.) + """ + subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil) + notifier.send_email(subject, email_body) # mail d'acquittement + if os.getenv("ALERT_OK_SMS", "0") == "1": + notifier.send_sms(sms_text) # ========= Cycle & boucle ========= def run_monitor_cycle(site: str = SITE): - # 1) Lecture mesures + config - sondes = lire_sondes_depuis_db(site) - cfg = lire_cfg_chambres(site) + # 1) Lecture dernières mesures + config + last_rows = lire_sondes_depuis_db(site) + cfg = lire_cfg_chambres(site) - # 2) Gyro instantané + # 2) Info: état instantané (le gyro est piloté par la boucle rapide) try: - gyro_on, trigger = compute_site_alarm(sondes, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) + gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) if trigger: s, t, se = trigger - log.info("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se) + log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se) else: - 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 à partir de la cfg (et on ignore les sondes OFF). + # 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} - for r in sondes: + 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): - # Ouvrir si pas déjà ouverte → notifier seulement si création 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 + SMS client finally: conn.close() else: - # Fermer si ouverte → notifier seulement si fermeture réelle 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) + notifier pour SMS immédiats + try: + global _gyro_controller + _gyro_controller = GyroPulseController(site, beacon, notifier) + _gyro_controller.start() + except Exception as e: + log.exception("Impossible de démarrer le GyroPulseController: %s", e) + while True: t0 = time.time() try: @@ -556,10 +804,17 @@ 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: + n = Notifier() + n.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, "Chambre_N1", -14.5, -15.0) + elif args.test_ok: + notifier_acquittement(SITE, "Chambre_N1", -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/Monitor_Saclay.py b/app/Monitor_Saclay.py index 1a4965e..0c6b23a 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -38,15 +38,16 @@ except Exception: _mqtt_ok = False # ========= Logger ========= +level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO) log = logging.getLogger(PROGRAM_NAME.lower()) if not log.handlers: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s") # ========= DB utils ========= 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). + Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client). """ cur = conn.cursor() cur.execute( @@ -178,46 +179,49 @@ def lire_seuils_depuis_db(site: str): finally: cnx.close() -def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: int = 180) -> bool: +# --- Dépassement continu (configurable) --- +def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: """ - True si la sonde est au-dessus du seuil de façon CONTINUE depuis ≥ 30 min. - On lit l'historique (par défaut 3h) et on remonte jusqu'au début de la séquence > seuil. + True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes. + CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30) + LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3)) """ + CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30")) + LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3)))) + table = site 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 - """, (sonde, int(window_min))) + """, (sonde, LOOKBACK)) rows = cur.fetchall() if not rows: return False - # 2) La dernière valeur doit être > seuil, sinon aucun dépassement en cours last_temp, last_dt = float(rows[0][0]), rows[0][1] if last_temp <= float(seuil): 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) + # Début de la séquence continue > seuil 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é + start_dt = d else: - break # séquence continue terminée + break - # 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=tzinfo) + dur_min = (now - start_dt).total_seconds() / 60.0 + log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d", + sonde, start_dt, now, dur_min, CONT_MIN) + return dur_min >= CONT_MIN except MySQLError as err: log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err) @@ -225,20 +229,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: in finally: cnx.close() - -def any_alert_open(site: str) -> bool: - table = f"Alertes_{site}" - cnx = get_db() - try: - cur = cnx.cursor() - cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1") - return cur.fetchone() is not None - except MySQLError as err: - log.exception("Erreur DB (any_alert_open): %s", err) - return False - finally: - cnx.close() - # ========= Helpers listes/numéros ========= def _split_list(raw: str | None) -> list[str]: return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] @@ -266,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]: def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) -# ========= Notifier (SMS + Mail) ========= +# ========= Notifier (SMS interne + SMS client + Mail) ========= class Notifier: def __init__(self): # OVH SMS @@ -282,11 +272,22 @@ class Notifier: ) self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_sender = os.getenv("OVH_SMS_SENDER") - raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO")) + raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO")) self.sms_labeled = _parse_labeled_phones(raw_sms) else: self.sms_labeled = [] + # SMS CLIENTS (site-spécifique + génériques + compat FR) + raw_sms_client = ( + os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or + os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or + os.getenv("ALERT_SMS_CLIENT_TO") or + os.getenv(f"ALERTE_CLIENT_{SITE}") or + os.getenv("ALERTE_CLIENT") + ) + self.sms_client_labeled = _parse_labeled_phones(raw_sms_client) + self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1") + # SMTP self.smtp_host = os.getenv("SMTP_HOST") self.smtp_port = int(os.getenv("SMTP_PORT","465")) @@ -294,9 +295,9 @@ class Notifier: self.smtp_pass = os.getenv("SMTP_PASS") self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() - raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "") + raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "") self.mail_to = _split_list(raw_mail_to) - self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user) + self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.upper()}") or os.getenv("MAIL_FROM") or self.smtp_user) self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to]) @@ -345,6 +346,45 @@ class Notifier: except Exception as err: log.exception("Echec envoi SMS OVH: %s", err); return False + def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool: + if not self.ovh_enabled: + log.warning("SMS client: OVH non configuré."); return False + if not self.sms_client_enabled or not self.sms_client_labeled: + log.info("SMS client: désactivé ou aucun destinataire."); return False + + only = os.getenv("ALERT_SMS_CLIENT_ONLY") + if only: + allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()} + labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)] + else: + labeled = self.sms_client_labeled + + receivers = [num for (_n, num) in labeled] + if not receivers: + log.info("SMS client: filtre vide → aucun envoi."); return False + + message = normaliser_sms(message, prefix=SITE) + + payload = { + "sender": self.ovh_sender, + "receivers": receivers, + "message": message, + "priority": "high", + "coding": "7bit", + "class": "phoneDisplay", + "noStopClause": True, + "senderForResponse": False, + "validityPeriod": 2880, + "tag": tag, + } + try: + log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled)) + resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload) + log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids")) + return True + except Exception as err: + log.exception("Echec SMS CLIENT OVH: %s", err); return False + def send_email(self, subject: str, body: str) -> bool: if not self.smtp_enabled: log.warning("SMTP non configuré, email non envoyé."); return False @@ -423,6 +463,11 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat txt = "\n".join(lines) return subject, txt, txt +def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str: + when = when or now_paris() + # Court, 1 ligne; accents/° nettoyés par normaliser_sms + return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}" + # ========= Gyrophare MQTT ========= class MQTTPublisher: def __init__(self, site: str): @@ -513,7 +558,7 @@ class GyroPulseController: Ajouts : - SMS ALERTE immédiat à l’allumage - - SMS OK immédiat à l’extinction (option activée par défaut) + - SMS OK immédiat à l’extinction (activé par défaut) """ def __init__(self, site: str, beacon, notifier, *, check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")), @@ -536,7 +581,7 @@ class GyroPulseController: self._thread = None self._current = None # dernier état effectif - # Anti-spam SMS & SMS OK activé par défaut (tranquilliser) + # Anti-spam SMS & SMS OK activé par défaut self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi} self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120")) self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1") @@ -668,19 +713,25 @@ beacon = MQTTPublisher(SITE) def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): """ MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base. - (Le SMS d'alerte est déjà envoyé par la boucle gyro, immédiat.) + + SMS CLIENT couplé (ALERTE_CLIENT_{SITE}). + (Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.) """ subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil) - notifier.send_email(subject, email_body) + notifier.send_email(subject, email_body) # MAIL (≥30 min) + + # ➕ SMS client couplé au mail 30 min + if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1": + client_msg = build_client_alert_sms(site, sonde, temp, seuil) + notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}") def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): """ MAIL lorsque l’alerte est acquittée en base. - (Le SMS "OK" a déjà été géré par la boucle gyro.) + (Le SMS "OK" est envoyé immédiatement par la boucle gyro.) """ subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil) notifier.send_email(subject, email_body) # mail d'acquittement - # Optionnel: SMS "OK" côté cycle si tu souhaites un doublon (désactivé par défaut) + # Optionnel: SMS "OK" côté cycle si souhaité if os.getenv("ALERT_OK_SMS", "0") == "1": notifier.send_sms(sms_text) @@ -701,7 +752,7 @@ def run_monitor_cycle(site: str = SITE): except Exception as e: log.exception("Erreur calcul alarme (info): %s", e) - # 3) Alertes "officielles" temporisées (≥30 min) → mails + # 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} for r in last_rows: @@ -715,7 +766,7 @@ def run_monitor_cycle(site: str = SITE): try: conn = get_db() if open_alert(conn, f"Alertes_{site}", nom, now): - notifier_sur_depassement(site, nom, temp, seuil) # MAIL alerte + notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client finally: conn.close() else: @@ -771,3 +822,4 @@ if __name__ == "__main__": run_monitor_cycle(SITE) else: run_monitor_loop(SITE, period_sec=args.period) +