Règlages des alertes dans Monitor_Meudon

This commit is contained in:
2025-09-24 08:51:24 +02:00
parent 072a0cbbc5
commit d776f1bf12
3 changed files with 479 additions and 163 deletions

View File

@@ -38,15 +38,16 @@ except Exception:
_mqtt_ok = False
# ========= Logger =========
level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO)
log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
# ========= DB utils =========
def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
"""
Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde.
Retourne True si une nouvelle alerte a été créée (→ notifier par mail).
Retourne True si une nouvelle alerte a été créée (→ notifier par mail & SMS client).
"""
cur = conn.cursor()
cur.execute(
@@ -178,46 +179,49 @@ def lire_seuils_depuis_db(site: str):
finally:
cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: int = 180) -> bool:
# --- Dépassement continu (configurable) ---
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
True si la sonde est au-dessus du seuil de façon CONTINUE depuis ≥ 30 min.
On lit l'historique (par défaut 3h) et on remonte jusqu'au début de la séquence > seuil.
True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes.
CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30)
LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3))
"""
CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))
LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3))))
table = site
cnx = get_db()
try:
cur = cnx.cursor()
# 1) Récupère l'historique récent de la sonde (ordre : plus récent -> plus ancien)
cur.execute(f"""
SELECT Temperature, Date
FROM `{table}`
WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC
""", (sonde, int(window_min)))
""", (sonde, LOOKBACK))
rows = cur.fetchall()
if not rows:
return False
# 2) La dernière valeur doit être > seuil, sinon aucun dépassement en cours
last_temp, last_dt = float(rows[0][0]), rows[0][1]
if last_temp <= float(seuil):
return False
# 3) Remonter jusqu'au début de la séquence continue > seuil
# (dès qu'on rencontre une valeur <= seuil, on s'arrête)
# Début de la séquence continue > seuil
start_dt = last_dt
for temp, d in rows[1:]:
if float(temp) > float(seuil):
start_dt = d # on prolonge la séquence vers le passé
start_dt = d
else:
break # séquence continue terminée
break
# 4) Durée de la séquence continue
tzinfo = getattr(start_dt, "tzinfo", None)
now = dt.datetime.now(tz=tzinfo) # naïf si la date DB est naïve
return (now - start_dt) >= dt.timedelta(minutes=30)
now = dt.datetime.now(tz=tzinfo)
dur_min = (now - start_dt).total_seconds() / 60.0
log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d",
sonde, start_dt, now, dur_min, CONT_MIN)
return dur_min >= CONT_MIN
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err)
@@ -225,20 +229,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: in
finally:
cnx.close()
def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1")
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (any_alert_open): %s", err)
return False
finally:
cnx.close()
# ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -266,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
# ========= Notifier (SMS + Mail) =========
# ========= Notifier (SMS interne + SMS client + Mail) =========
class Notifier:
def __init__(self):
# OVH SMS
@@ -282,11 +272,22 @@ class Notifier:
)
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
raw_sms = (os.getenv("ALERT_SMS_TO_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO"))
raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO"))
self.sms_labeled = _parse_labeled_phones(raw_sms)
else:
self.sms_labeled = []
# SMS CLIENTS (site-spécifique + génériques + compat FR)
raw_sms_client = (
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or
os.getenv("ALERT_SMS_CLIENT_TO") or
os.getenv(f"ALERTE_CLIENT_{SITE}") or
os.getenv("ALERTE_CLIENT")
)
self.sms_client_labeled = _parse_labeled_phones(raw_sms_client)
self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
# SMTP
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT","465"))
@@ -294,9 +295,9 @@ class Notifier:
self.smtp_pass = os.getenv("SMTP_PASS")
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "")
raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "")
self.mail_to = _split_list(raw_mail_to)
self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user)
self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.upper()}") or os.getenv("MAIL_FROM") or self.smtp_user)
self.smtp_enabled = all([self.smtp_host, self.smtp_port, self.smtp_user, self.smtp_pass, self.mail_to])
@@ -345,6 +346,45 @@ class Notifier:
except Exception as err:
log.exception("Echec envoi SMS OVH: %s", err); return False
def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool:
if not self.ovh_enabled:
log.warning("SMS client: OVH non configuré."); return False
if not self.sms_client_enabled or not self.sms_client_labeled:
log.info("SMS client: désactivé ou aucun destinataire."); return False
only = os.getenv("ALERT_SMS_CLIENT_ONLY")
if only:
allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()}
labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)]
else:
labeled = self.sms_client_labeled
receivers = [num for (_n, num) in labeled]
if not receivers:
log.info("SMS client: filtre vide → aucun envoi."); return False
message = normaliser_sms(message, prefix=SITE)
payload = {
"sender": self.ovh_sender,
"receivers": receivers,
"message": message,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True,
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
}
try:
log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled))
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids"))
return True
except Exception as err:
log.exception("Echec SMS CLIENT OVH: %s", err); return False
def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled:
log.warning("SMTP non configuré, email non envoyé."); return False
@@ -423,6 +463,11 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat
txt = "\n".join(lines)
return subject, txt, txt
def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
when = when or now_paris()
# Court, 1 ligne; accents/° nettoyés par normaliser_sms
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}"
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
@@ -513,7 +558,7 @@ class GyroPulseController:
Ajouts :
- SMS ALERTE immédiat à lallumage
- SMS OK immédiat à lextinction (option activée par défaut)
- SMS OK immédiat à lextinction (activé par défaut)
"""
def __init__(self, site: str, beacon, notifier, *,
check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")),
@@ -536,7 +581,7 @@ class GyroPulseController:
self._thread = None
self._current = None # dernier état effectif
# Anti-spam SMS & SMS OK activé par défaut (tranquilliser)
# Anti-spam SMS & SMS OK activé par défaut
self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi}
self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1")
@@ -668,19 +713,25 @@ beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
"""
MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base.
(Le SMS d'alerte est déjà envoyé par la boucle gyro, immédiat.)
+ SMS CLIENT couplé (ALERTE_CLIENT_{SITE}).
(Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.)
"""
subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body)
notifier.send_email(subject, email_body) # MAIL (≥30 min)
# SMS client couplé au mail 30 min
if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1":
client_msg = build_client_alert_sms(site, sonde, temp, seuil)
notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}")
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
"""
MAIL lorsque lalerte est acquittée en base.
(Le SMS "OK" a déjà été géré par la boucle gyro.)
(Le SMS "OK" est envoyé immédiatement par la boucle gyro.)
"""
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) # mail d'acquittement
# Optionnel: SMS "OK" côté cycle si tu souhaites un doublon (désactivé par défaut)
# Optionnel: SMS "OK" côté cycle si souhaité
if os.getenv("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text)
@@ -701,7 +752,7 @@ def run_monitor_cycle(site: str = SITE):
except Exception as e:
log.exception("Erreur calcul alarme (info): %s", e)
# 3) Alertes "officielles" temporisées (≥30 min) → mails
# 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows:
@@ -715,7 +766,7 @@ def run_monitor_cycle(site: str = SITE):
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil) # MAIL alerte
notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client
finally:
conn.close()
else:
@@ -771,3 +822,4 @@ if __name__ == "__main__":
run_monitor_cycle(SITE)
else:
run_monitor_loop(SITE, period_sec=args.period)