diff --git a/.env b/.env index 771f8a1..cb816eb 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -#connexion mysql +# connexion mysql DB_HOST=162.19.78.131 DB_USER=sondes DB_PASS=TX.)-U1!zq5Axdk4 @@ -9,25 +9,23 @@ AUTH_USERS=[{"user":"Michel","pass":"210462"}] MQTT_HOST=162.19.78.131 MQTT_USER=sondes MQTT_PASS=3J@bjYP0 -MQTT_PORT_MEUDON=1883 +MQTT_PORT=1883 -#Synology Chat -SYNOLOGY_CHAT_WEBHOOK_URL=https://192.168.1.250/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=UN7nhD70vrhrHFh1VeDdOpsklIHiIFRop2qB7b6YusMEY3clY3R8CXe4hFzz4KKc -SYNOLOGY_CHAT_VERIFY_SSL=false +# Synology Chat +SYNO_CHAT_WEBHOOK=https://mj91.fr/chat/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=QpLWAZEqIW1EOBHkfDkmr1LqC3P3J1SASWfqpchZdd1xPY7xGbYerS4lCADJnPrm +SYNO_CHAT_VERIFY_SSL=true +SYNO_CHAT_BOTNAME="Gestion Gyro" +SYNO_CHAT_TIMEOUT=10 +SYNO_CHAT_GYRO_ENABLED=1 # Boucle rapide du gyro -GYRO_WINDOW_MIN=3 -GYRO_NEEDED_POINTS=2 GYRO_CHECK_SEC=20 GYRO_NORMAL_CONFIRM=6 -GYRO_MODE=mqtt GYRO_MODE_CONTINUOUS=1 GYRO_HYSTERESIS=0.3 -ALERT_OK_SMS_GYRO=0 -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 @@ -46,25 +44,4 @@ MAIL_TO=robots@domo91.fr MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com MAIL_FROM_SACLAY="DOMO91 Saclay " MAIL_TO_MEUDON=robots@domo91.fr,chef@parismeudonermitage.com -MAIL_FROM_MEUDON="DOMO91 Meudon " - -# --- Paramètres SMS ---- -OVH_ENDPOINT=ovh-eu -OVH_APPLICATION_KEY=f725d07b2f98a195 -OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6 -OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 -OVH_SMS_SERVICE=sms-jm164396-1 -OVH_SMS_SENDER=DOMO91FR -SMS_RECEIVER=+33759600180 -ALERT_SMS_TO_SACLAY=Michel:+33759600180 -ALERT_SMS_TO_MEUDON=Michel:+33759600180 - -ALERT_SMS_CLIENT_TO_MEUDON=Sekou:+33625903364,Damien:+33680388259 -ALERT_SMS_CLIENT_TO_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939 -# Activer/désactiver globalement l’envoi client -ALERT_SMS_CLIENT_ENABLED=1 -# 1) couper les SMS internes -ALERT_INTERNAL_SMS_ENABLED=0# 0 = coupe tous les SMS “internes” (déclenchement) - -# 2) limiter le flux par cooldown (par sonde) -ALERT_SMS_COOLDOWN_SEC=3600 +MAIL_FROM_MEUDON="DOMO91 Meudon " \ No newline at end of file diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index 04268c6..1d7ce0e 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -1,146 +1,93 @@ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- -""" -Monitor températures + alertes + gyrophare MQTT + notifications (OVH SMS + SMTP). -Version corrigée pour VPS unique et robustesse DB / config. - -Principes : -- DB_HOST / MQTT_HOST / SMTP_HOST : uniques (VPS unique) -- Paramètres par site via env : MAIL_TO_{SITE}, ALERT_SMS_TO_{SITE}, etc. -- Les alertes ne concernent QUE les sondes présentes dans Chambres_froides pour le site - et avec Etat=ON . -""" - import datetime as dt import enum +import json import logging import os -import re import smtplib import ssl import threading import time from datetime import datetime from email.message import EmailMessage +from typing import Any, cast from zoneinfo import ZoneInfo -from dotenv import load_dotenv, find_dotenv -# ========= .env (une seule fois) ========= -load_dotenv(find_dotenv(usecwd=True), override=False) - -# ========= Utils projet ========= -from utils_sms import normaliser_sms - -# ========= MySQL ========= import mysql.connector +import requests +from dotenv import find_dotenv, load_dotenv from mysql.connector import Error as MySQLError -# ========= OVH (SMS) ========= -try: - import ovh - from ovh.exceptions import APIError as OVHAPIError - _ovh_available = True -except Exception: - ovh = None # type: ignore - class OVHAPIError(Exception): - ... - _ovh_available = False +load_dotenv(find_dotenv(usecwd=True), override=False) + +# ========= Site ========= +SITE = "Meudon" +PROGRAM_NAME = f"Monitor_{SITE}" + + +def _env_str(name: str, default: str = "") -> str: + return (os.getenv(name, default) or "").strip() + + +def _env_bool(name: str, default: bool) -> bool: + value = _env_str(name, "1" if default else "0").lower() + return value in ("1", "true", "yes", "on") + # ========= MQTT ========= try: import paho.mqtt.client as mqtt _mqtt_ok = True except Exception: + mqtt = None # type: ignore[assignment] _mqtt_ok = False -# ========= Helpers env ========= -def _env_bool(name: str, default: bool) -> bool: - v = os.getenv(name, str(int(default))).strip().lower() - return v in ("1", "true", "yes", "on") - - -def _split_list(raw: str | None) -> list[str]: - 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]]: - out: list[tuple[str, str]] = [] - for tok in re.split(r"[;,]", raw or ""): - tok = tok.strip() - if not tok: - continue - if ":" in tok: - 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]: - only = os.getenv("ALERT_SMS_ONLY") - 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)] - - -def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: - return ", ".join([f"{n}({p})" if n else p for n, p in labeled]) - - -# ========= Sécurisation du site / tables ========= -_ALLOWED_SITE_RE = re.compile(r"^[A-Za-z0-9_]+$") - -def safe_site(site: str) -> str: - site = (site or "").strip() - if not site or not _ALLOWED_SITE_RE.fullmatch(site): - raise ValueError(f"Nom de site invalide: {site!r}") - return site - - -# ========= Timezone ========= -PARIS = ZoneInfo("Europe/Paris") - -def now_paris() -> dt.datetime: - return dt.datetime.now(tz=PARIS) - - -def fmt_deg(v: float) -> str: - s = f"{float(v):.1f}".replace(".", ",") - return f"{s}°C" - - -# ========= Site (par défaut via env, sinon Meudon) ========= -SITE = safe_site(os.getenv("SITE", "Meudon")) -PROGRAM_NAME = f"Monitor_{SITE}" - # ========= Logger ========= -level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO) +level = getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO) log = logging.getLogger(PROGRAM_NAME.lower()) if not log.handlers: logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s") -# ========= DB Connexion ========= +# ========= Helpers types ========= +def _to_float(value: Any) -> float: + return float(cast(Any, value)) + + +def _to_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + raise TypeError(f"datetime attendu, reçu: {type(value)!r}") + + +# ========= Timezone ========= +PARIS = ZoneInfo("Europe/Paris") + + +def now_paris() -> dt.datetime: + return dt.datetime.now(tz=PARIS) + + +def fmt_deg(value: float) -> str: + return f"{float(value):.1f}".replace(".", ",") + "°C" + + +# ========= DB ========= def get_db(): - """ - Connexion DB unique (VPS unique). Le site ne change pas d'hôte, - seules les tables changent (ex: `Meudon`, `Alertes_Meudon`). - """ return mysql.connector.connect( - host=os.getenv("DB_HOST"), - user=os.getenv("DB_USER"), - password=os.getenv("DB_PASS"), - database=os.getenv("DB_NAME", "Sondes"), - port=int(os.getenv("DB_PORT", "3306")), + host=_env_str("DB_HOST"), + user=_env_str("DB_USER"), + password=_env_str("DB_PASS"), + database=_env_str("DB_NAME", "Sondes"), + port=int(_env_str("DB_PORT", "3306")), autocommit=False, ) -# ========= Alertes DB ========= 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. @@ -150,14 +97,14 @@ def open_alert(conn, table_alertes: str, sonde: str, dt_: datetime) -> bool: try: cur.execute( f"SELECT Id FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' ORDER BY Debut_defaut DESC LIMIT 1", - (sonde,) + (sonde,), ) if cur.fetchone(): return False 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() return True @@ -174,7 +121,7 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool: try: cur.execute( f"SELECT Id FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' ORDER BY Debut_defaut DESC LIMIT 1", - (sonde,) + (sonde,), ) row = cur.fetchone() if not row: @@ -183,18 +130,18 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool: alert_id = int(row[0]) cur.execute( f"UPDATE `{table_alertes}` SET Etat='Acquitté' WHERE Id=%s", - (alert_id,) + (alert_id,), ) - changed = (cur.rowcount == 1) + changed = cur.rowcount == 1 conn.commit() return changed finally: cur.close() -# ========= Gyro DB (journalisation) ========= +# ========= Gyro DB ========= def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: - dbname = os.getenv("DB_NAME", "Sondes") + dbname = _env_str("DB_NAME", "Sondes") sql = f"SELECT Etat FROM `{dbname}`.`Gyro` WHERE Lieu=%s AND Sonde=%s ORDER BY Date DESC LIMIT 1" cnx = get_db() try: @@ -206,9 +153,16 @@ def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: cnx.close() -def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, - qos: int | None, retained: int | None, when: datetime): - dbname = os.getenv("DB_NAME", "Sondes") +def insert_gyro_log( + lieu: str, + etat: str, + topic: str, + payload_raw: str, + qos: int | None, + retained: int | None, + when: datetime, +) -> None: + dbname = _env_str("DB_NAME", "Sondes") cnx = get_db() try: cur = cnx.cursor() @@ -217,14 +171,14 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", ( lieu, - os.getenv("GYRO_SONDE_NAME", "Gyro"), + _env_str("GYRO_SONDE_NAME", "Gyro"), etat, when.strftime("%Y-%m-%d %H:%M:%S"), topic, payload_raw, qos, - retained - ) + retained, + ), ) cnx.commit() log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic) @@ -235,17 +189,13 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, # ========= Lecture températures / config ========= -def lire_sondes_depuis_db(site: str): - """ - Dernière mesure par sonde, en ignorant Temperature NULL. - """ - table = safe_site(site) +def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]: sql = f""" SELECT t1.Sonde, t1.Temperature, t1.Date - FROM `{table}` t1 + FROM `{site}` t1 JOIN ( SELECT Sonde, MAX(Date) AS MaxDate - FROM `{table}` + FROM `{site}` WHERE Temperature IS NOT NULL GROUP BY Sonde ) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate @@ -255,9 +205,9 @@ def lire_sondes_depuis_db(site: str): try: cur = cnx.cursor(dictionary=True) cur.execute(sql) - rows = cur.fetchall() - for r in rows: - r["Temperature"] = float(r["Temperature"]) + rows = cast(list[dict[str, Any]], cur.fetchall()) + for row in rows: + row["Temperature"] = float(row["Temperature"]) return rows except MySQLError as err: log.exception("Erreur DB (lire_sondes_depuis_db): %s", err) @@ -266,19 +216,18 @@ def lire_sondes_depuis_db(site: str): cnx.close() -def lire_cfg_chambres(site: str): +def lire_cfg_chambres(site: str) -> dict[str, dict[str, float | bool]]: """ - Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}} - depuis Chambres_froides pour le site. + Retourne {sonde: {"temp_max": float, "active": bool}} depuis Chambres_froides. """ - dbname = os.getenv("DB_NAME", "Sondes") + dbname = _env_str("DB_NAME", "Sondes") sql = f""" SELECT Sonde, Temp_Max, Etat FROM `{dbname}`.`Chambres_froides` WHERE Lieu=%s """ cnx = get_db() - cfg: dict[str, dict] = {} + cfg: dict[str, dict[str, float | bool]] = {} try: cur = cnx.cursor() cur.execute(sql, (site,)) @@ -295,44 +244,46 @@ def lire_cfg_chambres(site: str): cnx.close() -def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0): +def compute_site_alarm( + last_values: list[dict[str, Any]], + cfg: dict[str, dict[str, float | bool]], + hysteresis: float = 0.0, +) -> tuple[bool, tuple[str, float, float] | None]: """ - Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None) - Ne considère que les sondes actives et non en entretien. + Retourne (is_on, trigger) avec trigger = (sonde, temperature, seuil). """ for row in last_values: sonde = str(row["Sonde"]) meta = cfg.get(sonde) - if not meta: + if not meta or not meta.get("active", False): continue - if not meta["active"]: - continue - temp = float(row["Temperature"]) - seuil = float(meta["temp_max"]) - if temp > seuil + float(hysteresis): + + temp = _to_float(row["Temperature"]) + seuil = _to_float(meta["temp_max"]) + if temp > seuil + hysteresis: return True, (sonde, temp, seuil) + return False, None -def depassement_depuis_2min(site: str, window_min: int = 3, needed_points: int = 2): + +def depassement_depuis_2min( + site: str, + window_min: int = 3, + needed_points: int = 2, +) -> tuple[bool, tuple[str, float, float] | None]: """ - Retourne (active: bool, trigger: (sonde, temp, seuil) | None) + Retourne (active, trigger) active = True si une sonde dépasse son seuil sur au moins 'needed_points' mesures dans les 'window_min' dernières minutes. - - window_min=3 rend le système tolérant aux petits décalages (ex: sonde en retard). - needed_points=2 correspond à votre objectif "2 minutes" (sondes toutes les minutes). """ - table = safe_site(site) - dbname = os.getenv("DB_NAME", "Sondes") - + dbname = _env_str("DB_NAME", "Sondes") cnx = get_db() try: cur = cnx.cursor() - - # 1) Cherche une sonde qui dépasse >=2 fois dans la fenêtre - cur.execute(f""" + cur.execute( + f""" SELECT m.Sonde - FROM `{table}` m + FROM `{site}` m JOIN `{dbname}`.`Chambres_froides` c ON c.Lieu=%s AND c.Sonde=m.Sonde @@ -343,32 +294,33 @@ def depassement_depuis_2min(site: str, window_min: int = 3, needed_points: int = GROUP BY m.Sonde HAVING COUNT(*) >= %s LIMIT 1 - """, (site, window_min, needed_points)) - + """, + (site, window_min, needed_points), + ) row = cur.fetchone() if not row: return False, None sonde = str(row[0]) - - # 2) Récupère dernière température + seuil pour le trigger - cur.execute(f""" + cur.execute( + f""" SELECT m.Temperature, c.Temp_Max - FROM `{table}` m + FROM `{site}` m JOIN `{dbname}`.`Chambres_froides` c ON c.Lieu=%s AND c.Sonde=%s WHERE m.Sonde=%s AND m.Temperature IS NOT NULL ORDER BY m.Date DESC LIMIT 1 - """, (site, sonde, sonde)) - - trow = cur.fetchone() - if not trow: + """, + (site, sonde, sonde), + ) + trigger_row = cur.fetchone() + if not trigger_row: return True, (sonde, 0.0, 0.0) - temp = float(trow[0]) - seuil = float(trow[1]) + temp = float(trigger_row[0]) + seuil = float(trigger_row[1]) return True, (sonde, temp, seuil) except MySQLError as err: @@ -380,50 +332,54 @@ def depassement_depuis_2min(site: str, window_min: int = 3, needed_points: int = 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)) - - Hypothèse : Date en DB et horloge VPS cohérentes (timezone serveur). + True si la sonde est > seuil de façon continue depuis CONT_MIN minutes. """ - CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30")) - LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, CONT_MIN * 3)))) + cont_min = int(_env_str("ALERT_CONTINUOUS_MINUTES", "30")) + lookback = int(_env_str("ALERT_LOOKBACK_MINUTES", str(max(60, cont_min * 3)))) - table = safe_site(site) cnx = get_db() try: cur = cnx.cursor() - cur.execute(f""" + cur.execute( + f""" SELECT Temperature, Date - FROM `{table}` + FROM `{site}` WHERE Sonde=%s AND Temperature IS NOT NULL AND Date >= (NOW() - INTERVAL %s MINUTE) ORDER BY Date DESC - """, (sonde, LOOKBACK)) + """, + (sonde, lookback), + ) rows = cur.fetchall() if not rows: return False - last_temp, last_dt = float(rows[0][0]), rows[0][1] - if last_temp <= float(seuil): + first_row = cast(tuple[Any, Any], rows[0]) + last_temp = _to_float(first_row[0]) + last_dt = _to_datetime(first_row[1]) + if last_temp <= seuil: 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 + for temp, row_dt in rows[1:]: + if _to_float(temp) > seuil: + start_dt = _to_datetime(row_dt) else: break - # Comparaison sur l'horloge locale (VPS) : cohérent avec NOW() DB si timezone serveur identique - now_local = dt.datetime.now() - dur_min = (now_local - start_dt).total_seconds() / 60.0 - - log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d", - sonde, start_dt, now_local, dur_min, CONT_MIN) - return dur_min >= CONT_MIN + 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 except MySQLError as err: log.exception("Erreur DB (depassement_depuis_30min): %s", err) @@ -432,133 +388,84 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: cnx.close() -# ========= Notifier (OVH SMS + SMTP) ========= +# ========= Synology Chat ========= +def send_synology_chat(message: str, *, username: str | None = None) -> bool: + webhook = ( + _env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or + _env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or + _env_str("SYNO_CHAT_WEBHOOK") + ) + if not webhook: + log.info("Synology Chat non configuré.") + return False + + botname = username or _env_str("SYNO_CHAT_BOTNAME") + timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10")) + verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True) + + chat_payload: dict[str, str] = {"text": message} + if botname: + chat_payload["username"] = botname + + form_data = {"payload": json.dumps(chat_payload, ensure_ascii=False)} + + try: + response = requests.post( + webhook, + data=form_data, + timeout=timeout, + verify=verify_ssl, + ) + txt = (response.text or "").strip() + log.info("Réponse Synology Chat: %s", txt[:300] if txt else "") + response.raise_for_status() + + try: + data = response.json() + if isinstance(data, dict): + success = bool(data.get("success", False)) + if not success: + log.warning("Synology Chat a répondu sans succès: %s", data) + return success + except ValueError: + pass + + return txt.lower() == "ok" or not txt + + except requests.RequestException as err: + log.exception("Echec envoi Synology Chat: %s", err) + return False + + +# ========= Notifier mail ========= class Notifier: - def __init__(self, site: str): - self.site = site + def __init__(self) -> None: + self.smtp_host = _env_str("SMTP_HOST") + self.smtp_port = int(_env_str("SMTP_PORT", "465")) + self.smtp_user = _env_str("SMTP_USER") + self.smtp_pass = _env_str("SMTP_PASS") + self.smtp_security = _env_str("SMTP_SECURITY", "SSL").upper() - # 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") + raw_mail_to = ( + _env_str(f"MAIL_TO_{SITE}") + or _env_str(f"MAIL_TO_{SITE.upper()}") + or _env_str("MAIL_TO") ) - if self.ovh_enabled: - self.ovh_client = ovh.Client( - 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(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 - 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.mail_to = [x.strip() for x in raw_mail_to.replace(";", ",").split(",") if x.strip()] + self.mail_from = ( + _env_str(f"MAIL_FROM_{SITE}") + or _env_str(f"MAIL_FROM_{SITE.upper()}") + or _env_str("MAIL_FROM") + or self.smtp_user ) - 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")) - self.smtp_user = os.getenv("SMTP_USER") - self.smtp_pass = os.getenv("SMTP_PASS") - self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper() - - 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(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]) - - def send_sms(self, message: str, tag: str | None = None) -> bool: - tag = tag or f"monitor-{self.site.lower()}" - 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) - if not receivers: - log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") - return False - - message = normaliser_sms(message, prefix=self.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 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) - 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 - - def send_sms_client(self, message: str, tag: str | None = None) -> bool: - tag = tag or f"monitor-client-{self.site.lower()}" - 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=self.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 + self.smtp_enabled = all([ + self.smtp_host, + self.smtp_port, + self.smtp_user, + self.smtp_pass, + self.mail_to, + ]) def send_email(self, subject: str, body: str) -> bool: if not self.smtp_enabled: @@ -571,17 +478,22 @@ class Notifier: msg["Subject"] = subject msg.set_content(body) - timeout = int(os.getenv("SMTP_TIMEOUT", "60")) - debug = os.getenv("SMTP_DEBUG", "0") == "1" + timeout = int(_env_str("SMTP_TIMEOUT", "60")) + debug = _env_bool("SMTP_DEBUG", False) - def _send_ssl(): - with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=timeout) as server: + def _send_ssl() -> None: + with smtplib.SMTP_SSL( + self.smtp_host, + self.smtp_port, + 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(): + def _send_starttls() -> None: with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server: if debug: server.set_debuglevel(1) @@ -593,51 +505,95 @@ class Notifier: try: if self.smtp_security == "STARTTLS": - _send_starttls() + 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: _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) + log.exception("Échec envoi email: %s", err) return False # ========= Messages ========= -def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): - when = when or now_paris() +def build_alert_text( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> tuple[str, str, str]: + when_dt = when if when is not None else 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')}", + f"Heure: {when_dt.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() +def build_ok_text( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> tuple[str, str, str]: + when_dt = when if when is not None else 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')}", + f"Heure: {when_dt.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')}" +def build_gyro_chat_alert( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> str: + when_dt = when if when is not None else now_paris() + return ( + f":rotating_light: [{site}] GYRO DECLENCHE\n" + f"Sonde: {sonde}\n" + f"Température: {fmt_deg(temp)} > seuil {fmt_deg(seuil)}\n" + f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" + ) + + +def build_gyro_chat_ok( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> str: + when_dt = when if when is not None else now_paris() + return ( + f":white_check_mark: [{site}] GYRO RETOUR NORMALE\n" + f"Sonde: {sonde}\n" + f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}\n" + f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" + ) # ========= MQTT Gyrophare ========= @@ -646,38 +602,32 @@ class MQTTPublisher: 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" + _env_str(f"GYRO_MQTT_TOPIC_{site}") + or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}") + or _env_str("GYRO_MQTT_TOPIC") + or f"Sondes/{site}/Gyro/cmd" ) self.last_state: bool | None = None + self.client: Any | None = None if not self.enabled: log.info("Gyro MQTT désactivé (paho-mqtt absent).") return + if not self.topic: + log.warning("Topic MQTT manquant pour %s.", 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") + host = _env_str("MQTT_HOST", "localhost") + port = int(_env_str("MQTT_PORT", "1883")) + user = _env_str("MQTT_USER") + pwd = _env_str("MQTT_PASS") + tls = _env_bool("MQTT_TLS", False) - # Compat paho 1.x / 2.x - cbver = getattr(mqtt, "CallbackAPIVersion", None) - if cbver is not None: - api_v = ( - 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: - self.client = mqtt.Client() - else: - self.client = mqtt.Client() + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) # type: ignore[union-attr] + except Exception: + self.client = mqtt.Client() # type: ignore[union-attr] if user and pwd: self.client.username_pw_set(user, pwd) @@ -689,12 +639,11 @@ class MQTTPublisher: self.client.connect(host, port, keepalive=30) 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 - "" + _env_str(f"GYRO_MQTT_SUB_{site}") + or _env_str(f"GYRO_MQTT_SUB_{site.upper()}") + or _env_str("GYRO_MQTT_SUB") ) - subs = [t.strip() for t in subs_env.split(",") if t.strip()] + subs = [topic.strip() for topic in subs_env.split(",") if topic.strip()] if not subs: subs = [ self.topic, @@ -702,22 +651,23 @@ class MQTTPublisher: f"{site}/Gyro/#", "Gyro/#", ] - for t in subs: + + for topic 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.subscribe(topic, qos=2) + log.info("MQTT subscribe: %s", topic) + except Exception as err: + log.warning("Subscribe échoué (%s): %s", topic, err) 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) + except Exception as err: + log.exception("MQTT connexion impossible: %s", err) self.enabled = False - def _on_message(self, client, userdata, msg): + def _on_message(self, _client, _userdata, msg) -> None: lieu = self.site - topic = msg.topic + topic = str(msg.topic) payload_raw = msg.payload.decode(errors="ignore").strip() upper = payload_raw.upper() @@ -734,50 +684,49 @@ class MQTTPublisher: retained=getattr(msg, "retain", None), when=now_paris(), ) - except Exception as e: - log.exception("Insert Gyro échoué: %s", e) + except Exception as err: + log.exception("Insert Gyro échoué: %s", err) return - # sinon ignorer 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: + def set(self, on: bool) -> None: + if not self.enabled or self.client is None: 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) + result = self.client.publish(self.topic, payload=payload, qos=2, retain=True) try: - r.wait_for_publish(timeout=3) + result.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) - return - - log.info("Gyro %s -> %s (MQTT)", self.site, payload) - try: - insert_gyro_log( - lieu=self.site, - etat=payload, - topic=self.topic, - payload_raw=payload, - qos=2, - retained=1, - when=now_paris(), - ) - except Exception as e: - log.exception("Insert événement gyro DB échoué: %s", e) + if getattr(result, "rc", 0) != 0: + log.warning("MQTT publish rc=%s (topic=%s)", getattr(result, "rc", None), self.topic) + else: + log.info("Gyro %s -> %s (MQTT)", self.site, payload) + try: + insert_gyro_log( + lieu=self.site, + etat=payload, + topic=self.topic, + payload_raw=payload, + qos=2, + retained=1 if getattr(result, "is_published", lambda: False)() else None, + when=now_paris(), + ) + except Exception as err: + log.exception("Insert événement gyro en base a échoué: %s", err) self.last_state = on - except Exception as e: - log.exception("MQTT publish erreur: %s", e) + except Exception as err: + log.exception("MQTT publish erreur: %s", err) # ========= Contrôleur Gyro réactif ========= @@ -790,22 +739,26 @@ class _GyroState(enum.Enum): 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. + - MODE CONTINU : ON tant que l’alarme persiste, OFF quand retour normal confirmé. + - MODE PULSE : ON puis OFF pendant cooldown tant que l’alarme persiste. - Ajouts : - - SMS ALERTE immédiat à l’allumage (option) - - SMS OK immédiat à l’extinction (option) + Notifications conservées : + - Synology Chat immédiat au déclenchement Gyro + - Synology Chat immédiat au retour à la normale """ - def __init__(self, site: str, beacon: MQTTPublisher, notifier: 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"))): + def __init__( + self, + site: str, + beacon: MQTTPublisher, + *, + check_sec: int = int(_env_str("GYRO_CHECK_SEC", "20")), + pulse_sec: int = int(_env_str("GYRO_PULSE_SEC", "60")), + cooldown_sec: int = int(_env_str("GYRO_COOLDOWN_SEC", "600")), + normal_confirm: int = int(_env_str("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 @@ -818,83 +771,69 @@ class GyroPulseController: self._stop = threading.Event() self._thread: threading.Thread | None = None self._current: bool | None = None - - self._last_sms: dict[str, float] = {} - self._sms_min_sec = int(os.getenv("ALERT_SMS_COOLDOWN_SEC") or os.getenv("GYRO_SMS_MIN_SEC", "120")) - self._send_ok = _env_bool("ALERT_OK_SMS_GYRO", True) - self._last_trigger: tuple[str, float, float] | None = None - def _set_gyro(self, on: bool): + def _set_gyro(self, on: bool) -> None: if self._current is not on: self.beacon.set(on) self._current = on - def start(self): + def start(self) -> None: 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) + 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): + def stop(self) -> None: 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 _env_bool("ALERT_INTERNAL_SMS_ENABLED", True): - return - if not trigger: + def _send_chat_alert(self, trigger: tuple[str, float, float] | None) -> None: + if not trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): 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) + chat_msg = build_gyro_chat_alert(self.site, sonde, temp, seuil, when=now_paris()) + send_synology_chat(chat_msg) - def _send_ok_sms_from_last_trigger(self): - if not self._send_ok or not self._last_trigger: + def _send_chat_ok_from_last_trigger(self) -> None: + if not self._last_trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): return - sonde, _temp_prev, seuil = self._last_trigger - # Température courante (best-effort) + sonde, _temp_prev, seuil = self._last_trigger rows = lire_sondes_depuis_db(self.site) - curr_temp = None - for r in rows: - if str(r["Sonde"]) == sonde: - curr_temp = float(r["Temperature"]) + curr_temp: float | None = None + for row in rows: + if str(row["Sonde"]) == sonde: + curr_temp = float(row["Temperature"]) break if curr_temp is None: curr_temp = seuil - 0.1 - 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) - + chat_msg = build_gyro_chat_ok(self.site, sonde, curr_temp, seuil, when=now_paris()) + send_synology_chat(chat_msg) self._last_trigger = None def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]: - # Déclenchement "2 minutes" (2 points au-dessus du seuil sur ~3 minutes) - window_min = int(os.getenv("GYRO_WINDOW_MIN", "3")) - needed = int(os.getenv("GYRO_NEEDED_POINTS", "2")) + window_min = int(_env_str("GYRO_WINDOW_MIN", "3")) + needed = int(_env_str("GYRO_NEEDED_POINTS", "2")) return depassement_depuis_2min(self.site, window_min=window_min, needed_points=needed) - def _run(self): + def _run(self) -> None: while not self._stop.is_set(): now_ts = 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) + except Exception as err: + log.exception("Gyro fast-loop: erreur lecture état: %s", err) + active, trigger = False, None if self.state == _GyroState.IDLE: if active: @@ -903,16 +842,11 @@ class GyroPulseController: 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 _env_bool("GYRO_MODE_CONTINUOUS", True) else "PULSE") - - # SMS alerte immédiat (option) - if _env_bool("ALERT_INTERNAL_SMS_ENABLED", False): - self._send_alert_sms(trigger) + sonde, temp, seuil = trigger + mode = "CONTINU" if _env_bool("GYRO_MODE_CONTINUOUS", True) else "PULSE" + log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)", sonde, temp, seuil, mode) + self._send_chat_alert(trigger) elif self.state == _GyroState.PULSE_ON: if not active: @@ -922,16 +856,14 @@ class GyroPulseController: self.state = _GyroState.IDLE self._normal_count = 0 log.info("Gyro → OFF (retour à la normale confirmé)") - if _env_bool("ALERT_OK_SMS_GYRO", False): - self._send_ok_sms_from_last_trigger() + self._send_chat_ok_from_last_trigger() else: self._normal_count = 0 - if not _env_bool("GYRO_MODE_CONTINUOUS", True): - if now_ts >= self._t_pulse_end: - self._set_gyro(False) - self._t_cooldown_end = now_ts + self.cooldown_sec - self.state = _GyroState.COOLDOWN - log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec) + if not _env_bool("GYRO_MODE_CONTINUOUS", True) and now_ts >= self._t_pulse_end: + self._set_gyro(False) + self._t_cooldown_end = now_ts + 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: @@ -952,65 +884,55 @@ class GyroPulseController: # ========= Notifs haut-niveau ========= -def notifier_sur_depassement(notifier: Notifier, site: str, sonde: str, temp: float, seuil: float): +notifier = Notifier() +beacon = MQTTPublisher(SITE) + + +def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float) -> None: """ - MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base. - + SMS CLIENT couplé (si activé). - (Le SMS interne immédiat est géré par la boucle gyro.) + Mail quand l'alerte est confirmée (≥ 30 min) et ouverte en base. """ - subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil) + subject, _mail_text, email_body = build_alert_text(site, sonde, temp, seuil) notifier.send_email(subject, email_body) - if _env_bool("ALERT_SMS_CLIENT_ENABLED", True): - client_msg = build_client_alert_sms(site, sonde, temp, seuil) - notifier.send_sms_client(client_msg, tag=f"client-{site.lower()}") - -def notifier_acquittement(notifier: Notifier, site: str, sonde: str, temp: float, seuil: float): +def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float) -> None: """ - MAIL lorsque l’alerte est acquittée en base. - (Le SMS OK est géré par la boucle gyro si activé.) + Mail lorsque l’alerte est acquittée en base. """ - subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil) + subject, _mail_text, email_body = build_ok_text(site, sonde, temp, seuil) notifier.send_email(subject, email_body) - if _env_bool("ALERT_OK_SMS", False): - notifier.send_sms(sms_text) - # ========= Cycle & boucle ========= -def run_monitor_cycle(site: str, notifier: Notifier): - site = safe_site(site) - - # 1) Lecture dernières mesures + config +def run_monitor_cycle(site: str = SITE) -> None: last_rows = lire_sondes_depuis_db(site) cfg = lire_cfg_chambres(site) - # 2) Info: état instantané (gyro piloté par la boucle rapide) try: - gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) + _gyro_on, trigger = compute_site_alarm( + last_rows, + cfg, + hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")), + ) if trigger: - s, t, se = trigger - log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se) + sonde, temp, seuil = trigger + log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", sonde, temp, seuil) else: log.info("Aucun dépassement au moment du cycle") - except Exception as e: - log.exception("Erreur calcul alarme (info): %s", e) + except Exception as err: + log.exception("Erreur calcul alarme (info): %s", err) - # 3) Alertes temporisées (≥30 min) : uniquement sur sondes configurées, actives, non entretien - table_alertes = f"Alertes_{site}" + seuils = {sonde: float(meta["temp_max"]) for sonde, meta in cfg.items() if meta.get("active", False)} - for r in last_rows: - nom = str(r["Sonde"]) - temp = float(r["Temperature"]) + for row in last_rows: + nom = str(row["Sonde"]) + temp = float(row["Temperature"]) - meta = cfg.get(nom) - if not meta: - continue - if not meta["active"]: + if nom not in seuils: continue - seuil = float(meta["temp_max"]) + seuil = seuils[nom] now_ = now_paris() if temp > seuil: @@ -1018,8 +940,8 @@ def run_monitor_cycle(site: str, notifier: Notifier): conn = None try: conn = get_db() - if open_alert(conn, table_alertes, nom, now_): - notifier_sur_depassement(notifier, site, nom, temp, seuil) + if open_alert(conn, f"Alertes_{site}", nom, now_): + notifier_sur_depassement(site, nom, temp, seuil) finally: if conn: conn.close() @@ -1027,64 +949,55 @@ def run_monitor_cycle(site: str, notifier: Notifier): conn = None try: conn = get_db() - if close_alert(conn, table_alertes, nom): - notifier_acquittement(notifier, site, nom, temp, seuil) + if close_alert(conn, f"Alertes_{site}", nom): + notifier_acquittement(site, nom, temp, seuil) finally: if conn: conn.close() -def run_monitor_loop(site: str, period_sec: int = 300): - site = safe_site(site) - notifier = Notifier(site) - beacon = MQTTPublisher(site) +def run_monitor_loop(site: str = SITE, period_sec: int = 300) -> None: + log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec) - log.info("%s démarré (site=%s, période=%ss) ✅", f"Monitor_{site}", site, period_sec) - - # Boucle rapide gyro try: - gyro_controller = GyroPulseController(site, beacon, notifier) - gyro_controller.start() - except Exception as e: - log.exception("Impossible de démarrer le GyroPulseController: %s", e) + global _gyro_controller + _gyro_controller = GyroPulseController(site, beacon) + _gyro_controller.start() + except Exception as err: + log.exception("Impossible de démarrer le GyroPulseController: %s", err) - # Boucle lente while True: t0 = time.time() try: - run_monitor_cycle(site, notifier) + run_monitor_cycle(site) except Exception as err: log.exception("Erreur cycle monitoring: %s", err) - time.sleep(max(0, period_sec - (time.time() - t0))) + time.sleep(max(0.0, period_sec - (time.time() - t0))) # ========= CLI ========= if __name__ == "__main__": import argparse - p = argparse.ArgumentParser(description=PROGRAM_NAME) - p.add_argument("--site", default=os.getenv("SITE", SITE), help="Nom du site (ex: Meudon, Saclay, Roissy)") - 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() + parser = argparse.ArgumentParser(description=PROGRAM_NAME) + parser.add_argument("--period", type=int, default=300) + 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("--test-chat", action="store_true") + parser.add_argument("--once", action="store_true") + args = parser.parse_args() - site = safe_site(args.site) - notifier = Notifier(site) - - if args.test_sms: - notifier.send_sms("TEST DOMO91 (transactionnel)") - elif args.test_mail: - notifier.send_email(f"[TEST {site}] Mail", "OK") + if args.test_mail: + notifier.send_email(f"[TEST {SITE}] Mail", "OK") elif args.test_alert: - notifier_sur_depassement(notifier, site, "Congelateur", -14.5, -15.0) + notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) elif args.test_ok: - notifier_acquittement(notifier, site, "Congelateur", -15.2, -15.0) + notifier_acquittement(SITE, "Congelateur", -15.2, -15.0) + elif args.test_chat: + send_synology_chat(f":speech_balloon: [TEST {SITE}] Notification Synology Chat OK") else: if args.once: - run_monitor_cycle(site, notifier) + run_monitor_cycle(SITE) else: - run_monitor_loop(site, period_sec=args.period) + run_monitor_loop(SITE, period_sec=args.period) diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 01f2760..63b0638 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -6,110 +6,127 @@ SITE = "Saclay" PROGRAM_NAME = f"Monitor_{SITE}" # ========= Imports & .env ========= -import os, re, time, ssl, smtplib, logging -from typing import Any, cast -import requests +import json +import logging +import os +import smtplib +import ssl +import time +import threading +import enum import datetime as dt -from email.message import EmailMessage from datetime import datetime -from dotenv import load_dotenv, find_dotenv +from email.message import EmailMessage +from typing import Any, cast +from zoneinfo import ZoneInfo + +import requests +import mysql.connector +from mysql.connector import Error as MySQLError +from dotenv import find_dotenv, load_dotenv + load_dotenv(find_dotenv(usecwd=True), override=False) -from utils_sms import normaliser_sms + + +def _env_str(name: str, default: str = "") -> str: + return (os.getenv(name, default) or "").strip() def _env_bool(name: str, default: bool) -> bool: - v = os.getenv(name, str(int(default))).strip().lower() - return v in ("1", "true", "yes", "on") + value = _env_str(name, "1" if default else "0").lower() + return value in ("1", "true", "yes", "on") -# MySQL -import mysql.connector -from mysql.connector import Error as MySQLError -from dotenv import load_dotenv -load_dotenv() - -# OVH (SMS) -try: - import ovh - from ovh.exceptions import APIError as OVHAPIError - _ovh_available = True -except Exception: - ovh = None # type: ignore - class OVHAPIError(Exception): ... - _ovh_available = False # MQTT try: import paho.mqtt.client as mqtt _mqtt_ok = True except Exception: + mqtt = None # type: ignore[assignment] _mqtt_ok = False + # ========= Logger ========= -level = getattr(logging, (os.getenv("LOGLEVEL", "INFO") or "INFO").upper(), logging.INFO) +level = getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO) log = logging.getLogger(PROGRAM_NAME.lower()) 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: - """ - 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). - """ - cur = conn.cursor() - cur.execute( - f"SELECT 1 FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' LIMIT 1", - (sonde,) - ) - if cur.fetchone(): - cur.close() - 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 - -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 par mail). - """ - cur = conn.cursor() - cur.execute( - f"UPDATE `{table_alertes}` SET Etat='Acquitté' " - f"WHERE Sonde=%s AND Etat='En cours' " - f"ORDER BY Debut_defaut DESC LIMIT 1", - (sonde,) - ) - changed = (cur.rowcount == 1) - conn.commit() - cur.close() - return changed +# ========= Helpers types ========= def _to_float(value: Any) -> float: return float(cast(Any, value)) + def _to_datetime(value: Any) -> datetime: if isinstance(value, datetime): return value raise TypeError(f"datetime attendu, reçu: {type(value)!r}") + +# ========= DB utils ========= def get_db(): return mysql.connector.connect( - host=os.getenv("DB_HOST"), - user=os.getenv("DB_USER"), - password=os.getenv("DB_PASS"), - database=os.getenv("DB_NAME", "Sondes"), - port=int(os.getenv("DB_PORT", "3306")), + host=_env_str("DB_HOST"), + user=_env_str("DB_USER"), + password=_env_str("DB_PASS"), + database=_env_str("DB_NAME", "Sondes"), + port=int(_env_str("DB_PORT", "3306")), autocommit=True, ) + +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. + """ + cur = conn.cursor() + cur.execute( + f"SELECT 1 FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' LIMIT 1", + (sonde,), + ) + if cur.fetchone(): + cur.close() + return False + + 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 + + +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é'. + """ + cur = conn.cursor() + cur.execute( + f"UPDATE `{table_alertes}` SET Etat='Acquitté' " + f"WHERE Sonde=%s AND Etat='En cours' " + f"ORDER BY Debut_defaut DESC LIMIT 1", + (sonde,), + ) + changed = cur.rowcount == 1 + conn.commit() + cur.close() + return changed + + # --- 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): +def insert_gyro_log( + lieu: str, + etat: str, + topic: str, + payload_raw: str, + qos: int | None, + retained: int | None, + when: datetime, +) -> None: cnx = get_db() try: cur = cnx.cursor() @@ -119,13 +136,13 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, ( lieu, _env_str("GYRO_SONDE_NAME", "Gyro"), - etat, # 'ON' ou 'OFF' - when.strftime('%Y-%m-%d %H:%M:%S'), + etat, + when.strftime("%Y-%m-%d %H:%M:%S"), topic, payload_raw, qos, - retained - ) + retained, + ), ) cnx.commit() log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic) @@ -134,6 +151,7 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, 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() @@ -145,15 +163,15 @@ def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: finally: cnx.close() -# --- Lecture des dernières mesures de température (en ignorant lignes d'état) --- + +# --- Lecture des dernières mesures de température --- def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]: - table = site sql = f""" SELECT t1.Sonde, t1.Temperature, t1.Date - FROM `{table}` t1 + FROM `{site}` t1 JOIN ( SELECT Sonde, MAX(Date) AS MaxDate - FROM `{table}` + FROM `{site}` WHERE Temperature IS NOT NULL GROUP BY Sonde ) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate @@ -164,8 +182,8 @@ def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]: cur = cnx.cursor(dictionary=True) cur.execute(sql) rows = cast(list[dict[str, Any]], cur.fetchall()) - for r in rows: - r["Temperature"] = float(r["Temperature"]) + for row in rows: + row["Temperature"] = float(row["Temperature"]) return rows except MySQLError as err: log.exception("Erreur DB (lire_sondes_depuis_db): %s", err) @@ -176,8 +194,7 @@ def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]: def lire_cfg_chambres(site: str) -> dict[str, dict[str, float | bool]]: """ - Retourne {sonde: {"temp_max": float, "active": bool}} - depuis Chambres_froides pour le site. + Retourne {sonde: {"temp_max": float, "active": bool}} depuis Chambres_froides. """ dbname = _env_str("DB_NAME", "Sondes") sql = f""" @@ -202,62 +219,54 @@ def lire_cfg_chambres(site: str) -> dict[str, dict[str, float | bool]]: finally: cnx.close() -def compute_site_alarm(last_values: list[dict[str, Any]], cfg: dict[str, dict[str, float | bool]], hysteresis: float = 0.0) -> tuple[bool, tuple[str, float, float] | None]: + +def compute_site_alarm( + last_values: list[dict[str, Any]], + cfg: dict[str, dict[str, float | bool]], + hysteresis: float = 0.0, +) -> tuple[bool, tuple[str, float, float] | None]: """ - Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None) + Retourne (is_on, trigger) avec trigger = (sonde, temperature, seuil). """ for row in last_values: sonde = str(row["Sonde"]) meta = cfg.get(sonde) if not meta or not meta.get("active", False): continue + temp = _to_float(row["Temperature"]) seuil = _to_float(meta["temp_max"]) - if temp > seuil + float(hysteresis): + if temp > seuil + hysteresis: return True, (sonde, temp, seuil) + return False, None -def lire_seuils_depuis_db(site: str) -> dict[str, float]: - sql = """ - SELECT Sonde, Temp_Max - FROM Sondes.Chambres_froides - WHERE Lieu=%s AND Etat='ON' - """ - cnx = get_db() - seuils: dict[str, float] = {} - 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)) + True si la sonde est > seuil de façon continue depuis CONT_MIN minutes. """ cont_min = int(_env_str("ALERT_CONTINUOUS_MINUTES", "30")) - lookback = int(_env_str("ALERT_LOOKBACK_MINUTES", str(max(60, int(_env_str("ALERT_CONTINUOUS_MINUTES", "30"))*3)))) + lookback = int( + _env_str( + "ALERT_LOOKBACK_MINUTES", + str(max(60, int(_env_str("ALERT_CONTINUOUS_MINUTES", "30")) * 3)), + ) + ) - table = site cnx = get_db() try: cur = cnx.cursor() - cur.execute(f""" + cur.execute( + f""" SELECT Temperature, Date - FROM `{table}` + FROM `{site}` WHERE Sonde=%s AND Date >= (NOW() - INTERVAL %s MINUTE) ORDER BY Date DESC - """, (sonde, lookback)) + """, + (sonde, lookback), + ) rows = cur.fetchall() if not rows: return False @@ -265,13 +274,12 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: first_row = cast(tuple[Any, Any], rows[0]) last_temp = _to_float(first_row[0]) last_dt = _to_datetime(first_row[1]) - if last_temp <= float(seuil): + if last_temp <= seuil: return False - # Début de la séquence continue > seuil start_dt = last_dt for temp, row_dt in rows[1:]: - if _to_float(temp) > float(seuil): + if _to_float(temp) > seuil: start_dt = _to_datetime(row_dt) else: break @@ -279,63 +287,25 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: 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) + 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) + log.exception("Erreur DB (depassement_depuis_30min): %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()] - -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() - if not tok: - continue - if ":" in tok: - 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]: - only = _env_str("ALERT_SMS_ONLY") - 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)] - -def _human_labeled_list(labeled: list[tuple[str, str]]) -> str: - return ", ".join([f"{name}({phone})" if name else phone for name, phone in labeled]) # ========= Synology Chat ========= -def _env_str(name: str, default: str = "") -> str: - return (os.getenv(name, default) or "").strip() - -def synology_chat_enabled() -> bool: - return bool( - _env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or - _env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or - _env_str("SYNO_CHAT_WEBHOOK") - ) - def send_synology_chat(message: str, *, username: str | None = None) -> bool: - """ - Envoie un message sur Synology Chat via webhook entrant. - Variables supportées : - - SYNO_CHAT_WEBHOOK_{SITE} ou SYNO_CHAT_WEBHOOK_{SITE.upper()} - - SYNO_CHAT_WEBHOOK - - SYNO_CHAT_BOTNAME (optionnel) - - SYNO_CHAT_TIMEOUT (optionnel, défaut 10s) - """ webhook = ( _env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or _env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or @@ -345,164 +315,80 @@ def send_synology_chat(message: str, *, username: str | None = None) -> bool: log.info("Synology Chat non configuré.") return False - payload = {"text": message} botname = username or _env_str("SYNO_CHAT_BOTNAME") - if botname: - payload["username"] = botname - timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10")) + verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True) + + chat_payload: dict[str, str] = {"text": message} + if botname: + chat_payload["username"] = botname + + form_data = { + "payload": json.dumps(chat_payload, ensure_ascii=False) + } try: - r = requests.post(webhook, json=payload, timeout=timeout) - r.raise_for_status() - # Certains webhooks Synology répondent "ok" ou JSON {success:true} - body = (r.text or "").strip().lower() - if body and body not in ("ok", '{"success":true}'): - log.info("Réponse Synology Chat: %s", r.text[:200]) - log.info("Notification Synology Chat envoyée.") - return True - except requests.RequestException as e: - log.exception("Echec envoi Synology Chat: %s", e) + response = requests.post( + webhook, + data=form_data, + timeout=timeout, + verify=verify_ssl, + ) + txt = (response.text or "").strip() + log.info("Réponse Synology Chat: %s", txt[:300] if txt else "") + response.raise_for_status() + + try: + data = response.json() + if isinstance(data, dict): + success = bool(data.get("success", False)) + if not success: + log.warning("Synology Chat a répondu sans succès: %s", data) + return success + except ValueError: + pass + + return txt.lower() == "ok" or not txt + + except requests.RequestException as err: + log.exception("Echec envoi Synology Chat: %s", err) return False -# ========= Notifier (SMS interne + SMS client + Mail) ========= + +# ========= Notifier mail ========= class Notifier: - def __init__(self): - # OVH SMS - self.ovh_enabled = _ovh_available and all( - _env_str(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER") - ) - self.ovh_client: Any | None = None - self.ovh_service: str = "" - self.ovh_sender: str = "" - if self.ovh_enabled: - assert ovh is not None - self.ovh_client = ovh.Client( - endpoint=_env_str("OVH_ENDPOINT", "ovh-eu"), - application_key=_env_str("OVH_APPLICATION_KEY"), - application_secret=_env_str("OVH_APPLICATION_SECRET"), - consumer_key=_env_str("OVH_CONSUMER_KEY"), - ) - self.ovh_service = _env_str("OVH_SMS_SERVICE") - self.ovh_sender = _env_str("OVH_SMS_SENDER") - raw_sms = (_env_str(f"ALERT_SMS_TO_{SITE}") or _env_str(f"ALERT_SMS_TO_{SITE.upper()}") or _env_str("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 = ( - _env_str(f"ALERT_SMS_CLIENT_TO_{SITE}") or - _env_str(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or - _env_str("ALERT_SMS_CLIENT_TO") or - _env_str(f"ALERTE_CLIENT_{SITE}") or - _env_str("ALERTE_CLIENT") - ) - self.sms_client_labeled = _parse_labeled_phones(raw_sms_client) - self.sms_client_enabled = (_env_str("ALERT_SMS_CLIENT_ENABLED", "1") == "1") - - # SMTP - self.smtp_host: str = _env_str("SMTP_HOST") + def __init__(self) -> None: + self.smtp_host = _env_str("SMTP_HOST") self.smtp_port = int(_env_str("SMTP_PORT", "465")) - self.smtp_user: str = _env_str("SMTP_USER") - self.smtp_pass: str = _env_str("SMTP_PASS") - self.smtp_security: str = _env_str("SMTP_SECURITY", "SSL").upper() + self.smtp_user = _env_str("SMTP_USER") + self.smtp_pass = _env_str("SMTP_PASS") + self.smtp_security = _env_str("SMTP_SECURITY", "SSL").upper() - raw_mail_to = (_env_str(f"MAIL_TO_{SITE}") or _env_str(f"MAIL_TO_{SITE.upper()}") or _env_str("MAIL_TO")) - self.mail_to = _split_list(raw_mail_to) - self.mail_from: str = (_env_str(f"MAIL_FROM_{SITE}") or _env_str(f"MAIL_FROM_{SITE.upper()}") or _env_str("MAIL_FROM") or self.smtp_user) + raw_mail_to = ( + _env_str(f"MAIL_TO_{SITE}") + or _env_str(f"MAIL_TO_{SITE.upper()}") + or _env_str("MAIL_TO") + ) + self.mail_to = [x.strip() for x in raw_mail_to.replace(";", ",").split(",") if x.strip()] + self.mail_from = ( + _env_str(f"MAIL_FROM_{SITE}") + or _env_str(f"MAIL_FROM_{SITE.upper()}") + or _env_str("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 = 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) - if not receivers: - 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, - "priority": "high", - "coding": "7bit", - "class": "phoneDisplay", - "noStopClause": True, - "senderForResponse": False, - "validityPeriod": 2880, - "tag": tag, - } - try: - ovh_client = cast(Any, self.ovh_client) - log.info("Envoi SMS vers: %s", _human_labeled_list([(name, phone) for (name, phone) in self.sms_labeled if phone in receivers])) - resp = ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload) - 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 = 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 - time.sleep(1.5) - 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 - - 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 = _env_str("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)) - ovh_client = cast(Any, self.ovh_client) - resp = 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 + self.smtp_enabled = all([ + self.smtp_host, + self.smtp_port, + self.smtp_user, + self.smtp_pass, + self.mail_to, + ]) 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 @@ -511,18 +397,27 @@ class Notifier: msg.set_content(body) timeout = int(_env_str("SMTP_TIMEOUT", "60")) - debug = _env_str("SMTP_DEBUG", "0") == "1" + debug = _env_bool("SMTP_DEBUG", False) - 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) + def _send_ssl() -> None: + with smtplib.SMTP_SSL( + self.smtp_host, + self.smtp_port, + 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(): + def _send_starttls() -> None: 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() + 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) @@ -535,24 +430,36 @@ class Notifier: _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 + log.exception("Erreur SMTP: %s", err) + return False except Exception as err: - log.exception("Echec envoi email: %s", err); return False + log.exception("Échec envoi email: %s", err) + return False + # ========= 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" + +def fmt_deg(value: float) -> str: + return f"{float(value):.1f}".replace(".", ",") + "°C" + def now_paris() -> dt.datetime: return dt.datetime.now(tz=PARIS) -def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> tuple[str, str, str]: + +def build_alert_text( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> tuple[str, str, str]: when_dt = when if when is not None else now_paris() subject = f"[ALERTE {site}] {sonde} au-dessus du seuil" lines = [ @@ -560,12 +467,19 @@ def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt. f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})", f"Site: {site}", - f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" + f"Heure: {when_dt.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) -> tuple[str, str, str]: + +def build_ok_text( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> tuple[str, str, str]: when_dt = when if when is not None else now_paris() subject = f"[OK {site}] {sonde} revenue normale" lines = [ @@ -573,67 +487,79 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}", f"Site: {site}", - f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" + f"Heure: {when_dt.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_dt = when if when is not None else 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_dt.strftime('%H:%M')}" -def build_gyro_chat_alert(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str: +def build_gyro_chat_alert( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> str: when_dt = when if when is not None else now_paris() return ( f":rotating_light: [{site}] GYRO DECLENCHE\n" f"Sonde: {sonde}\n" - f"Temperature: {fmt_deg(temp)} > seuil {fmt_deg(seuil)}\n" + f"Température: {fmt_deg(temp)} > seuil {fmt_deg(seuil)}\n" f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" ) -def build_gyro_chat_ok(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str: + +def build_gyro_chat_ok( + site: str, + sonde: str, + temp: float, + seuil: float, + when: dt.datetime | None = None, +) -> str: when_dt = when if when is not None else now_paris() return ( f":white_check_mark: [{site}] GYRO RETOUR NORMALE\n" f"Sonde: {sonde}\n" - f"Temperature: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}\n" + f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}\n" f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}" ) + # ========= Gyrophare MQTT ========= class MQTTPublisher: def __init__(self, site: str): self.enabled = bool(_mqtt_ok) self.site = site - self.topic: str = ( - _env_str(f"GYRO_MQTT_TOPIC_{site}") or - _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}") or - _env_str("GYRO_MQTT_TOPIC") or - f"Sondes/{site}/Gyro/cmd" + self.topic = ( + _env_str(f"GYRO_MQTT_TOPIC_{site}") + or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}") + or _env_str("GYRO_MQTT_TOPIC") + or f"Sondes/{site}/Gyro/cmd" ) self.last_state: bool | None = None + self.client: Any | None = None if not self.enabled: 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) + log.warning("Topic MQTT manquant pour %s.", site) self.enabled = False return host = _env_str("MQTT_HOST", "localhost") port = int(_env_str("MQTT_PORT", "1883")) user = _env_str("MQTT_USER") - pwd = _env_str("MQTT_PASS") - tls = (_env_str("MQTT_TLS", "0") == "1") + pwd = _env_str("MQTT_PASS") + tls = _env_bool("MQTT_TLS", False) - # --- Création du client MQTT : compatible paho 1.x et 2.x --- try: - self.client = mqtt.Client() - except TypeError: - self.client = mqtt.Client(client_id="") - # ------------------------------------------------------------ + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) # type: ignore[union-attr] + except Exception: + try: + self.client = mqtt.Client() # type: ignore[union-attr] + except TypeError: + self.client = mqtt.Client(client_id="") # type: ignore[union-attr] if user and pwd: self.client.username_pw_set(user, pwd) @@ -641,48 +567,44 @@ 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 = ( - _env_str(f"GYRO_MQTT_SUB_{site}") or - _env_str(f"GYRO_MQTT_SUB_{site.upper()}") or - _env_str("GYRO_MQTT_SUB") + _env_str(f"GYRO_MQTT_SUB_{site}") + or _env_str(f"GYRO_MQTT_SUB_{site.upper()}") + or _env_str("GYRO_MQTT_SUB") ) - subs = [t.strip() for t in subs_env.split(",") if t.strip()] + subs = [topic.strip() for topic in subs_env.split(",") if topic.strip()] if not subs: subs = [ - self.topic, # ex: Sondes/Saclay/Gyro/cmd + self.topic, f"Sondes/{site}/Gyro/#", f"{site}/Gyro/#", "Gyro/#", ] - for t in subs: + + for topic 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.subscribe(topic, qos=2) + log.info("MQTT subscribe: %s", topic) + except Exception as err: + log.warning("Subscribe échoué (%s): %s", topic, err) 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) + except Exception as err: + log.exception("MQTT connexion impossible: %s", err) self.enabled = False - # --- Callback réception MQTT --- - def _on_message(self, _client, _userdata, msg): + def _on_message(self, _client, _userdata, msg) -> None: lieu = self.site topic = str(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") + 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( @@ -692,35 +614,35 @@ class MQTTPublisher: payload_raw=payload_raw, qos=getattr(msg, "qos", None), retained=getattr(msg, "retain", None), - when=now_paris() + when=now_paris(), ) - except Exception as e: - log.exception("Insert Gyro échoué: %s", e) - return # ne pas poursuivre vers un parse température ici + except Exception as err: + log.exception("Insert Gyro échoué: %s", err) + return - # 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: + def set(self, on: bool) -> None: + if not self.enabled or self.client is None: 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) + result = self.client.publish(self.topic, payload=payload, qos=2, retain=True) try: - r.wait_for_publish(timeout=3) + result.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) + + if getattr(result, "rc", 0) != 0: + log.warning("MQTT publish rc=%s (topic=%s)", getattr(result, "rc", None), self.topic) else: - log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper()) - # Enregistrer en base l'événement gyro + log.info("Gyro %s -> %s (MQTT)", self.site, payload) try: insert_gyro_log( lieu=self.site, @@ -728,41 +650,47 @@ class MQTTPublisher: topic=self.topic, payload_raw=payload, qos=2, - retained=1 if getattr(r, 'is_published', lambda: False)() else None, - when=now_paris() + retained=1 if getattr(result, "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) + except Exception as err: + log.exception("Insert événement gyro en base a échoué: %s", err) + self.last_state = on - except Exception as e: - log.exception("MQTT publish erreur: %s", e) + except Exception as err: + log.exception("MQTT publish erreur: %s", err) + # ========= 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. + - MODE CONTINU : ON tant que l’alarme persiste, OFF quand retour normal confirmé. + - MODE PULSE : ON puis OFF pendant cooldown tant que l’alarme persiste. - Ajouts : - - SMS ALERTE immédiat à l’allumage - - SMS OK immédiat à l’extinction (activé par défaut) + Notifications conservées : + - Synology Chat immédiat au déclenchement Gyro + - Synology Chat immédiat au retour à la normale """ - def __init__(self, site: str, beacon, notifier, *, - check_sec: int = int(_env_str("GYRO_CHECK_SEC", "20")), - pulse_sec: int = int(_env_str("GYRO_PULSE_SEC", "60")), - cooldown_sec: int = int(_env_str("GYRO_COOLDOWN_SEC", "600")), - normal_confirm: int = int(_env_str("GYRO_NORMAL_CONFIRM", "2"))): + + def __init__( + self, + site: str, + beacon: MQTTPublisher, + *, + check_sec: int = int(_env_str("GYRO_CHECK_SEC", "20")), + pulse_sec: int = int(_env_str("GYRO_PULSE_SEC", "60")), + cooldown_sec: int = int(_env_str("GYRO_COOLDOWN_SEC", "600")), + normal_confirm: int = int(_env_str("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 @@ -774,92 +702,74 @@ class GyroPulseController: self._normal_count = 0 self._stop = threading.Event() self._thread: threading.Thread | None = None - self._current: bool | None = None # dernier état effectif + self._current: bool | None = None + self._last_trigger: tuple[str, float, float] | None = None - # Anti-spam SMS & SMS OK activé par défaut - self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi} - self._sms_min_sec = int(_env_str("ALERT_SMS_COOLDOWN_SEC") or _env_str("GYRO_SMS_MIN_SEC", "120")) - self._send_ok = (_env_str("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): + def _set_gyro(self, on: bool) -> None: if self._current is not on: self.beacon.set(on) self._current = on - def start(self): + def start(self) -> None: 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) + 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): + def stop(self) -> None: 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: + def _send_chat_alert(self, trigger: tuple[str, float, float] | None) -> None: + if not trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): return sonde, temp, seuil = trigger + chat_msg = build_gyro_chat_alert(self.site, sonde, temp, seuil, when=now_paris()) + send_synology_chat(chat_msg) - # Notification Synology Chat immediate sur declenchement Gyro - if _env_bool("SYNO_CHAT_GYRO_ENABLED", True): - chat_msg = build_gyro_chat_alert(self.site, sonde, temp, seuil, when=now_paris()) - send_synology_chat(chat_msg) - - if _env_bool("ALERT_INTERNAL_SMS_ENABLED", True) and 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: + def _send_chat_ok_from_last_trigger(self) -> None: + if not self._last_trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): 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 + curr_temp: float | None = None + for row in rows: + if str(row["Sonde"]) == sonde: + curr_temp = float(row["Temperature"]) + break if curr_temp is None: - curr_temp = seuil - 0.1 # fallback léger + curr_temp = seuil - 0.1 - if _env_bool("SYNO_CHAT_GYRO_ENABLED", True): - chat_msg = build_gyro_chat_ok(self.site, sonde, curr_temp, seuil, when=now_paris()) - send_synology_chat(chat_msg) - - if _env_bool("ALERT_OK_SMS_GYRO", True) and 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 + chat_msg = build_gyro_chat_ok(self.site, sonde, curr_temp, seuil, when=now_paris()) + send_synology_chat(chat_msg) + self._last_trigger = None 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(_env_str("GYRO_HYSTERESIS", "0.0"))) + cfg = lire_cfg_chambres(self.site) + return compute_site_alarm( + last_rows, + cfg, + hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")), + ) - def _run(self): + def _run(self) -> None: 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) + except Exception as err: + log.exception("Gyro fast-loop: erreur lecture état: %s", err) + active, trigger = False, None if self.state == _GyroState.IDLE: if active: @@ -869,12 +779,10 @@ class GyroPulseController: 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 _env_str("GYRO_MODE_CONTINUOUS", "1") == "1" else "PULSE") - # SMS alerte immédiat (optionnel) - if _env_str("ALERT_INTERNAL_SMS_ENABLED", "0") == "1": - self._send_alert_sms(trigger) + sonde, temp, seuil = trigger + mode = "CONTINU" if _env_bool("GYRO_MODE_CONTINUOUS", True) else "PULSE" + log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)", sonde, temp, seuil, mode) + self._send_chat_alert(trigger) elif self.state == _GyroState.PULSE_ON: if not active: @@ -884,17 +792,14 @@ class GyroPulseController: self.state = _GyroState.IDLE self._normal_count = 0 log.info("Gyro → OFF (retour à la normale confirmé)") - # SMS OK immédiat - if _env_str("ALERT_OK_SMS_GYRO", "0") == "1": - self._send_ok_sms_from_last_trigger() + self._send_chat_ok_from_last_trigger() else: self._normal_count = 0 - if _env_str("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) + if not _env_bool("GYRO_MODE_CONTINUOUS", True) and 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: @@ -913,63 +818,57 @@ class GyroPulseController: 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): - """ - 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 _env_str("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_sur_depassement(site: str, sonde: str, temp: float, seuil: float) -> None: + """ + Mail quand l'alerte est confirmée (≥ 30 min) et ouverte en base. + """ + subject, _mail_text, email_body = build_alert_text(site, sonde, temp, seuil) + notifier.send_email(subject, email_body) -def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): + +def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float) -> None: """ - MAIL lorsque l’alerte est acquittée en base. - (Le SMS "OK" est envoyé immédiatement par la boucle gyro.) + Mail lorsque l’alerte est acquittée en base. """ - 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 _env_str("ALERT_OK_SMS", "0") == "1": - notifier.send_sms(sms_text) + subject, _mail_text, email_body = build_ok_text(site, sonde, temp, seuil) + notifier.send_email(subject, email_body) + # ========= Cycle & boucle ========= -def run_monitor_cycle(site: str = SITE): - # 1) Lecture dernières mesures + config +def run_monitor_cycle(site: str = SITE) -> None: last_rows = lire_sondes_depuis_db(site) - cfg = lire_cfg_chambres(site) + cfg = lire_cfg_chambres(site) - # 2) Info: état instantané (le gyro est piloté par la boucle rapide) try: - _gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0"))) + _gyro_on, trigger = compute_site_alarm( + last_rows, + cfg, + hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")), + ) if trigger: - s, t, se = trigger - log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se) + sonde, temp, seuil = trigger + log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", sonde, temp, seuil) else: log.info("Aucun dépassement au moment du cycle") - except Exception as e: - log.exception("Erreur calcul alarme (info): %s", e) + except Exception as err: + log.exception("Erreur calcul alarme (info): %s", err) - # 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)} + seuils = {sonde: float(meta["temp_max"]) for sonde, meta in cfg.items() if meta.get("active", False)} - for r in last_rows: - nom = str(r["Sonde"]) - temp = float(r["Temperature"]) + for row in last_rows: + nom = str(row["Sonde"]) + temp = float(row["Temperature"]) if nom not in seuils: - continue # sonde non gérée dans Chambres_froides → ignorée + continue - seuil = float(seuils[nom]) + seuil = seuils[nom] now_ = now_paris() if temp > seuil: @@ -992,16 +891,16 @@ def run_monitor_cycle(site: str = SITE): if conn: conn.close() -def run_monitor_loop(site: str = SITE, period_sec: int = 300): + +def run_monitor_loop(site: str = SITE, period_sec: int = 300) -> None: 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 = GyroPulseController(site, beacon) _gyro_controller.start() - except Exception as e: - log.exception("Impossible de démarrer le GyroPulseController: %s", e) + except Exception as err: + log.exception("Impossible de démarrer le GyroPulseController: %s", err) while True: t0 = time.time() @@ -1009,25 +908,23 @@ def run_monitor_loop(site: str = SITE, period_sec: int = 300): run_monitor_cycle(site) except Exception as err: log.exception("Erreur cycle monitoring: %s", err) - time.sleep(max(0, period_sec - (time.time() - t0))) + time.sleep(max(0.0, period_sec - (time.time() - t0))) + # ========= CLI ========= 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("--test-chat", action="store_true") - p.add_argument("--once", action="store_true") - args = p.parse_args() - if args.test_sms: - n = Notifier() - n.send_sms("TEST DOMO91 (transactionnel)") - elif args.test_mail: + parser = argparse.ArgumentParser(description=PROGRAM_NAME) + parser.add_argument("--period", type=int, default=300) + 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("--test-chat", action="store_true") + parser.add_argument("--once", action="store_true") + args = parser.parse_args() + + if args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK") elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) diff --git a/app/Monitor_Saclay_old.py b/app/Monitor_Saclay_old.py deleted file mode 100644 index 849d9a8..0000000 --- a/app/Monitor_Saclay_old.py +++ /dev/null @@ -1,958 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# ========= 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 datetime import datetime -from dotenv import load_dotenv, find_dotenv -load_dotenv(find_dotenv(usecwd=True), override=False) -from utils_sms import normaliser_sms - -def _env_bool(name: str, default: bool) -> bool: - v = os.getenv(name, str(int(default))).strip().lower() - return v in ("1", "true", "yes", "on") - -# MySQL -import mysql.connector -from mysql.connector import Error as MySQLError -from dotenv import load_dotenv -load_dotenv() - -# OVH (SMS) -try: - import ovh - from ovh.exceptions import APIError as OVHAPIError - _ovh_available = True -except Exception: - ovh = None # type: ignore - class OVHAPIError(Exception): ... - _ovh_available = False - -# MQTT -try: - import paho.mqtt.client as mqtt - _mqtt_ok = True -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=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 & SMS client). - """ - cur = conn.cursor() - cur.execute( - f"SELECT 1 FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' LIMIT 1", - (sonde,) - ) - if cur.fetchone(): - cur.close() - 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 - -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 par mail). - """ - cur = conn.cursor() - cur.execute( - f"UPDATE `{table_alertes}` SET Etat='Acquitté' " - f"WHERE Sonde=%s AND Etat='En cours' " - f"ORDER BY Debut_defaut DESC LIMIT 1", - (sonde,) - ) - changed = (cur.rowcount == 1) - conn.commit() - cur.close() - return changed - -def get_db(): - return mysql.connector.connect( - host=os.getenv("DB_HOST"), - user=os.getenv("DB_USER"), - password=os.getenv("DB_PASS"), - database=os.getenv("DB_NAME", "Sondes"), - port=int(os.getenv("DB_PORT", "3306")), - 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""" - SELECT t1.Sonde, t1.Temperature, t1.Date - FROM `{table}` t1 - 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: - cur = cnx.cursor(dictionary=True) - cur.execute(sql) - rows = cur.fetchall() - for r in rows: - r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL - return rows - except MySQLError as err: - log.exception("Erreur DB (lire_sondes_depuis_db): %s", err) - return [] - finally: - cnx.close() - - -def lire_cfg_chambres(site: str): - """ - Retourne {sonde: {"temp_max": float, "active": bool}} - depuis Chambres_froides pour le site. - """ - dbname = os.getenv("DB_NAME", "Sondes") - sql = f""" - SELECT Sonde, Temp_Max, Etat - FROM `{dbname}`.`Chambres_froides` - WHERE Lieu=%s - """ - cnx = get_db() - cfg: dict[str, dict] = {} - try: - cur = cnx.cursor() - cur.execute(sql, (site,)) - for sonde, temp_max, etat in cur.fetchall(): - cfg[str(sonde)] = { - "temp_max": float(temp_max), - "active": str(etat).upper() == "ON", - } - return cfg - except MySQLError as err: - log.exception("Erreur DB (lire_cfg_chambres): %s", err) - return cfg - finally: - cnx.close() - -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) - """ - for row in last_values: - sonde = str(row["Sonde"]) - meta = cfg.get(sonde) - if not meta or not meta.get("active", False): - continue - temp = float(row["Temperature"]) - seuil = float(meta["temp_max"]) - if temp > seuil + float(hysteresis): - return True, (sonde, temp, seuil) - return False, None - -def lire_seuils_depuis_db(site: str): - sql = """ - SELECT Sonde, Temp_Max - FROM Sondes.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 - """, (sonde, LOOKBACK)) - rows = cur.fetchall() - if not rows: - return False - - last_temp, last_dt = float(rows[0][0]), rows[0][1] - if last_temp <= float(seuil): - 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 - - except MySQLError as err: - log.exception("Erreur DB (depassement_depuis_30min, continu): %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()] - -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() - if not tok: - continue - if ":" in tok: - 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]: - only = os.getenv("ALERT_SMS_ONLY") - 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)] - -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 interne + SMS client + Mail) ========= -class Notifier: - def __init__(self): - # 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") - ) - if self.ovh_enabled: - self.ovh_client = ovh.Client( - 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(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")) - self.smtp_user = os.getenv("SMTP_USER") - self.smtp_pass = os.getenv("SMTP_PASS") - self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() - - 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(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]) - - 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) - if not receivers: - 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, - "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])) - 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) - 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 - time.sleep(1.5) - 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 - - 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 - - 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 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": - 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: - _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 - -# ========= 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" - -def now_paris() -> dt.datetime: - return dt.datetime.now(tz=PARIS) - -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 - -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 - -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): - 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" - ) - self.last_state: bool | None = None - - if not self.enabled: - 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) - 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: - api_v = ( - 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: - self.client = mqtt.Client() - else: - self.client = mqtt.Client() - # ------------------------------------------------------------ - - if user and pwd: - self.client.username_pw_set(user, pwd) - if tls: - 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 - 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) - 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) - -# ========= 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("ALERT_SMS_COOLDOWN_SEC") or 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 _env_bool("ALERT_INTERNAL_SMS_ENABLED", True): - return - 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 _env_bool("ALERT_OK_SMS_GYRO", True): - return - 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 (optionnel) - if os.getenv("ALERT_INTERNAL_SMS_ENABLED", "0") == "1": - 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 - if os.getenv("ALERT_OK_SMS_GYRO", "0") == "1": - 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): - """ - 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): - """ - 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 - # Optionnel: SMS "OK" côté cycle si souhaité - if os.getenv("ALERT_OK_SMS", "0") == "1": - notifier.send_sms(sms_text) - -# ========= Cycle & boucle ========= -def run_monitor_cycle(site: str = SITE): - # 1) Lecture dernières mesures + config - last_rows = lire_sondes_depuis_db(site) - cfg = lire_cfg_chambres(site) - - # 2) Info: état instantané (le gyro est piloté par la boucle rapide) - try: - gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) - if trigger: - s, t, se = trigger - log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se) - else: - log.info("Aucun dépassement au moment du cycle") - except Exception as e: - log.exception("Erreur calcul alarme (info): %s", e) - - # 3) Alertes "officielles" 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: - nom = str(r["Sonde"]) - temp = float(r["Temperature"]) - - if nom not in seuils: - continue # sonde non gérée dans Chambres_froides → ignorée - - seuil = float(seuils[nom]) - now_ = now_paris() - - if temp > seuil: - if depassement_depuis_30min(site, nom, seuil): - conn = None - try: - conn = get_db() - if open_alert(conn, f"Alertes_{site}", nom, now_): - notifier_sur_depassement(site, nom, temp, seuil) - finally: - if conn: - conn.close() - else: - conn = None - try: - conn = get_db() - if close_alert(conn, f"Alertes_{site}", nom): - notifier_acquittement(site, nom, temp, seuil) - finally: - if conn: - 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: - run_monitor_cycle(site) - except Exception as err: - log.exception("Erreur cycle monitoring: %s", err) - time.sleep(max(0, period_sec - (time.time() - t0))) - -# ========= CLI ========= -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: - 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, "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/gyro_control.py b/app/gyro_control.py index d120094..77ea74c 100644 --- a/app/gyro_control.py +++ b/app/gyro_control.py @@ -1,44 +1,144 @@ -# gyro_control.py -import os, time, logging, threading +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import json +import logging +import os +import re +import threading +import time +from typing import Any + import mysql.connector import paho.mqtt.client as mqtt +import requests +from dotenv import find_dotenv, load_dotenv +load_dotenv(find_dotenv(usecwd=True), override=False) + +_ALLOWED_SITE_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def safe_site(site: str) -> str: + site = (site or "").strip() + if not site or not _ALLOWED_SITE_RE.fullmatch(site): + raise ValueError(f"Nom de site invalide: {site!r}") + return site + + +def _env_str(name: str, default: str = "") -> str: + return (os.getenv(name, default) or "").strip() + + +def _env_bool(name: str, default: bool = False) -> bool: + value = _env_str(name, "1" if default else "0").lower() + return value in ("1", "true", "yes", "on") + + +logging.basicConfig( + level=getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) log = logging.getLogger("gyro") -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") -DEF_CHECK_SEC = int(os.getenv("GYRO_CHECK_SEC", "20")) -DEF_NORMAL_CONFIRM = int(os.getenv("GYRO_NORMAL_CONFIRM", "2")) +DEF_CHECK_SEC = int(_env_str("GYRO_CHECK_SEC", "20")) +DEF_NORMAL_CONFIRM = int(_env_str("GYRO_NORMAL_CONFIRM", "6")) + + +def send_synology_chat(message: str, *, site: str, username: str | None = None) -> bool: + webhook = ( + _env_str(f"SYNO_CHAT_WEBHOOK_{site}") + or _env_str(f"SYNO_CHAT_WEBHOOK_{site.upper()}") + or _env_str("SYNO_CHAT_WEBHOOK") + ) + if not webhook: + log.info("[%s] Synology Chat non configuré.", site) + return False + + botname = username or _env_str("SYNO_CHAT_BOTNAME", "Gestion Gyro") + timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10")) + verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True) + + chat_payload: dict[str, str] = {"text": message} + if botname: + chat_payload["username"] = botname + + form_data = {"payload": json.dumps(chat_payload, ensure_ascii=False)} + + try: + response = requests.post( + webhook, + data=form_data, + timeout=timeout, + verify=verify_ssl, + ) + response.raise_for_status() + + txt = (response.text or "").strip() + log.info("[%s] Réponse Synology Chat: %s", site, txt[:300] if txt else "") + + try: + data = response.json() + if isinstance(data, dict): + success = bool(data.get("success", False)) + if not success: + log.warning("[%s] Synology Chat a répondu sans succès: %s", site, data) + return success + except ValueError: + pass + + return txt.lower() == "ok" or not txt + + except requests.RequestException as exc: + log.exception("[%s] Echec envoi Synology Chat: %s", site, exc) + return False + class MqttGyroDriver: - def __init__(self, host, port, user, password, topic_cmd): + def __init__(self, host: str, port: int, user: str, password: str, topic_cmd: str): self.topic_cmd = topic_cmd - self.client = mqtt.Client() + try: + self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + except Exception: + 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.connect(host, int(port), keepalive=30) self.client.loop_start() + log.info("MQTT connecté (%s:%s), topic=%s", host, port, topic_cmd) - def set(self, on: bool): + def set(self, on: bool) -> None: payload = "ON" if on else "OFF" - res = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False) - res.wait_for_publish(timeout=5) + result = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False) + result.wait_for_publish(timeout=5) log.info("MQTT → %s : %s", self.topic_cmd, payload) - def close(self): + def close(self) -> None: try: self.client.loop_stop() self.client.disconnect() except Exception: pass + class GyroController: """ Gyro ON en continu tant qu'il existe au moins une alerte Etat='En cours'. Gyro OFF après 'normal_confirm' lectures consécutives sans alerte. + Notification Chat sur transition ON/OFF. """ - def __init__(self, *, site_name: str, db_cfg: dict, alertes_table: str, - mqtt_driver: MqttGyroDriver, check_sec: int = DEF_CHECK_SEC, - normal_confirm: int = DEF_NORMAL_CONFIRM): + + def __init__( + self, + *, + site_name: str, + db_cfg: dict[str, Any], + alertes_table: str, + mqtt_driver: MqttGyroDriver, + check_sec: int = DEF_CHECK_SEC, + normal_confirm: int = DEF_NORMAL_CONFIRM, + ): self.site = site_name self.db_cfg = db_cfg self.alertes_table = alertes_table @@ -47,29 +147,68 @@ class GyroController: self.normal_confirm = normal_confirm self._stop = threading.Event() - self._thread = None - self._current_on = None + self._thread: threading.Thread | None = None + self._current_on: bool | None = None self._normal_count = 0 - def _set_gyro(self, on: bool): - if self._current_on is not on: - self.mqtt.set(on) - self._current_on = on + def _send_chat_on(self) -> None: + if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): + return + message = ( + f":rotating_light: [{self.site}] GYRO DECLENCHE\n" + f"Table alertes: {self.alertes_table}\n" + "Etat: au moins une alerte en cours" + ) + send_synology_chat(message, site=self.site) + + def _send_chat_off(self) -> None: + if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True): + return + message = ( + f":white_check_mark: [{self.site}] GYRO RETOUR NORMALE\n" + f"Table alertes: {self.alertes_table}\n" + "Etat: plus d'alerte en cours" + ) + send_synology_chat(message, site=self.site) + + def _set_gyro(self, on: bool) -> None: + if self._current_on is on: + return + + previous = self._current_on + self.mqtt.set(on) + self._current_on = on + + if previous is None: + log.info("[%s] Etat initial Gyro: %s", self.site, "ON" if on else "OFF") + return + + if on: + log.info("[%s] Transition Gyro OFF → ON", self.site) + self._send_chat_on() + else: + log.info("[%s] Transition Gyro ON → OFF", self.site) + self._send_chat_off() 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 + row = cur.fetchone() + return bool(row and row[0] > 0) - def start(self): + def start(self) -> None: 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, confirm=%d)", - self.site, self.check_sec, self.normal_confirm) + log.info( + "[%s] GyroController démarré (check=%ss, confirm=%d)", + self.site, + self.check_sec, + self.normal_confirm, + ) - def stop(self): + def stop(self) -> None: self._stop.set() def _connect_mysql(self): @@ -78,18 +217,17 @@ class GyroController: cnx = mysql.connector.connect(autocommit=True, **self.db_cfg) cur = cnx.cursor() return cnx, cur - except Exception as e: - log.error("[%s] Connexion MySQL KO (%s). Retry 5s…", self.site, e) + except Exception as exc: + log.error("[%s] Connexion MySQL KO (%s). Retry 5s...", self.site, exc) time.sleep(5) return None, None - def _run(self): + def _run(self) -> None: cnx, cur = self._connect_mysql() - if not cnx: + if not cnx or not cur: return try: - # au démarrage, on force OFF par sécurité (optionnel) try: self._set_gyro(False) except Exception: @@ -98,14 +236,15 @@ class GyroController: while not self._stop.is_set(): try: active = self._has_active_alert(cur) - except Exception as e: - log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, e) + except Exception as exc: + log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, exc) try: - cur.close(); cnx.close() + cur.close() + cnx.close() except Exception: pass cnx, cur = self._connect_mysql() - if not cnx: + if not cnx or not cur: break active = False @@ -125,38 +264,67 @@ class GyroController: except Exception: pass try: - cur.close(); cnx.close() + cur.close() + cnx.close() except Exception: pass log.info("[%s] GyroController stoppé", self.site) -if __name__ == "__main__": - # ---- CONFIG À ADAPTER ---- - SITE = "Meudon" - ALERTES_TABLE = "Alertes_Meudon" # adaptez au nom réel - DB_CFG = dict( - host=(os.getenv("DB_HOST") or "162.19.78.131").strip(), - user=(os.getenv("DB_USER") or "sondes").strip(), - password=os.getenv("DB_PASSWORD") or "TX.)-U1!zq5Axdk4", - database=(os.getenv("DB_NAME") or "Sondes").strip(), - port=int(os.getenv("DB_PORT") or 3306), +def build_db_cfg() -> dict[str, Any]: + return { + "host": _env_str("DB_HOST", "162.19.78.131"), + "user": _env_str("DB_USER", "sondes"), + "password": _env_str("DB_PASS"), + "database": _env_str("DB_NAME", "Sondes"), + "port": int(_env_str("DB_PORT", "3306")), + } + + +def build_topic(site: str) -> str: + return ( + _env_str(f"GYRO_MQTT_TOPIC_{site}") + or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}") + or _env_str("GYRO_MQTT_TOPIC") + or f"{site}/gyrophare" ) - MQTT_HOST = (os.getenv("MQTT_HOST") or "162.19.78.131").strip() - MQTT_PORT = int(os.getenv("MQTT_PORT") or 1883) - MQTT_USER = os.getenv("MQTT_USER") or "sondes" - MQTT_PASS = os.getenv("MQTT_PASSWORD") or "3J@bjYP0" - TOPIC_CMD = "Meudon/gyrophare/cmd" +if __name__ == "__main__": + import argparse - print("MQTT_HOST =", repr(MQTT_HOST)) - print("MQTT_PORT =", repr(MQTT_PORT)) + parser = argparse.ArgumentParser(description="Contrôle du gyrophare via table d'alertes") + parser.add_argument("--site", default=_env_str("SITE", "Saclay")) + parser.add_argument("--test-chat", action="store_true") + args = parser.parse_args() - drv = MqttGyroDriver(MQTT_HOST, MQTT_PORT, MQTT_USER, MQTT_PASS, TOPIC_CMD) - ctl = GyroController(site_name=SITE, db_cfg=DB_CFG, alertes_table=ALERTES_TABLE, - mqtt_driver=drv, check_sec=DEF_CHECK_SEC, normal_confirm=DEF_NORMAL_CONFIRM) - ctl.start() + site = safe_site(args.site) + + if args.test_chat: + send_synology_chat(f":speech_balloon: [TEST {site}] Notification Synology Chat OK", site=site) + raise SystemExit(0) + + alertes_table = _env_str("ALERTES_TABLE", f"Alertes_{site}") + db_cfg = build_db_cfg() + + mqtt_host = _env_str("MQTT_HOST", "162.19.78.131") + mqtt_port = int(_env_str("MQTT_PORT", "1883")) + mqtt_user = _env_str("MQTT_USER", "sondes") + mqtt_pass = _env_str("MQTT_PASS") + topic_cmd = build_topic(site) + + log.info("[%s] MQTT host=%s port=%s topic=%s", site, mqtt_host, mqtt_port, topic_cmd) + + driver = MqttGyroDriver(mqtt_host, mqtt_port, mqtt_user, mqtt_pass, topic_cmd) + controller = GyroController( + site_name=site, + db_cfg=db_cfg, + alertes_table=alertes_table, + mqtt_driver=driver, + check_sec=DEF_CHECK_SEC, + normal_confirm=DEF_NORMAL_CONFIRM, + ) + controller.start() try: while True: @@ -164,5 +332,5 @@ if __name__ == "__main__": except KeyboardInterrupt: pass finally: - ctl.stop() - drv.close() + controller.stop() + driver.close() diff --git a/app/utils_sms.py b/app/utils_sms.py deleted file mode 100644 index 483c8bc..0000000 --- a/app/utils_sms.py +++ /dev/null @@ -1,71 +0,0 @@ -import os -import ovh -from dotenv import load_dotenv -import unicodedata, re - -load_dotenv() - -def normaliser_sms(message: str, prefix: str = "") -> str: - REPL = {"°": "C", "’": "'", "“": '"', "”": '"', "–": "-", "—": "-", "…": "..."} - for k, v in REPL.items(): - message = message.replace(k, v) - message = unicodedata.normalize("NFKD", message).encode("ascii", "ignore").decode("ascii") - rules = [ - (r"\bTemperature\b", "T"), - (r"\bTemp[ée]rature\b", "T"), - (r"\bSeuil\b", "S"), - (r"\bHeure\b", "H"), - (r"\s{2,}", " "), - ] - for pat, repl in rules: - message = re.sub(pat, repl, message, flags=re.IGNORECASE) - if prefix: - message = f"[{prefix}] {message}" - return message[:160] - -def envoyer_sms(message: str, lieu: str = ""): - try: - client = ovh.Client( - endpoint=os.getenv("OVH_ENDPOINT"), - application_key=os.getenv("OVH_APP_KEY"), - application_secret=os.getenv("OVH_APP_SECRET"), - consumer_key=os.getenv("OVH_CONSUMER_KEY"), - ) - - services = client.get('/sms/') - if not services: - print("❌ Aucun service SMS OVH trouvé", flush=True) - return - - service_name = services[0] - numero_dest = os.getenv("SMS_RECEIVER") - sender = os.getenv("OVH_SMS_SENDER") - - if numero_dest.startswith('+'): - numero_dest = '00' + numero_dest[1:] - - if not numero_dest or not numero_dest.isdigit(): - print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True) - return - message = normaliser_sms(message, prefix=lieu) - payload = { - "sender": sender, - "receivers": [numero_dest], - "message": message, # Pas d'encodage ni de nettoyage ici - "priority": "high", - "noStopClause": False - - } - - print("📤 Requête envoyée à OVH :") - print(payload) - - result = client.post(f'/sms/{service_name}/jobs', **payload) - - print(f"📱 SMS envoyé à {numero_dest} pour {lieu}. Job ID : {result['ids']}", flush=True) - - except Exception as e: - print(f"❌ Erreur envoi SMS : {e}", flush=True) - -if __name__ == "__main__": - envoyer_sms("Test SMS OVH", lieu="utils_sms")