Remise en état des alertes

This commit is contained in:
2025-09-20 12:09:36 +02:00
parent 35a7d13d02
commit 511e377dc8
6 changed files with 414 additions and 473 deletions

16
.env
View File

@@ -2,14 +2,16 @@
#connexion mysql
DB_HOST=162.19.78.131
DB_USER=sondes
DB_PASSWORD=TX.)-U1!zq5Axdk4
DB_PASS=TX.)-U1!zq5Axdk4
DB_NAME=Sondes
# MQTT
MQTT_HOST=162.19.78.131
MQTT_USER=sondes
MQTT_PASS=3J@bjYP0
GYRO_PUBLISH_GLOBAL=1
GYRO_MODE=mqtt
MQTT_HOST=54.36.188.119
MQTT_USER=Bwps
MQTT_PASS=scJ5ACj2keRfI^
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# paramètres mail
SMTP_HOST=ssl0.ovh.net
@@ -30,5 +32,7 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR
ALERT_SMS_TO_SACLAY==Michel:+33635164680,Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
ALERT_SMS_TO_SACLAY==Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680
RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248

View File

@@ -1,42 +1,38 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ========= Imports & chargement .env =========
import os
import re
import time
import ssl
import smtplib
import logging
SITE = "Meudon"
PROGRAM_NAME = f"Monitor_{SITE}"
import os, re, time, ssl, smtplib, logging
import datetime as dt
from email.message import EmailMessage
from typing import List
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
SITE = "Meudon"
PROGRAM_NAME = f"Monitor_{SITE}"
# MySQL
import mysql.connector
from mysql.connector import Error as MySQLError
# OVH (robuste même si la lib n'est pas installée)
try:
import ovh
from ovh.exceptions import APIError as OVHAPIError
_ovh_available = True
except Exception:
ovh = None # type: ignore
class OVHAPIError(Exception): # fallback pour les except
pass
class OVHAPIError(Exception): pass
_ovh_available = False
# ========= Logger =========
log = logging.getLogger("monitor_saclay")
try:
import paho.mqtt.client as mqtt
_mqtt_ok = True
except Exception:
_mqtt_ok = False
log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ========= Utilitaires DB =========
def get_db():
cnx = mysql.connector.connect(
host=os.getenv("DB_HOST", "localhost"),
@@ -49,10 +45,6 @@ def get_db():
return cnx
def lire_sondes_depuis_db(site: str):
"""
Relevés les + récents par sonde pour le site (table = nom du site, ex. 'Saclay').
Retour: [{"Sonde": str, "Temperature": float, "Date": datetime}]
"""
table = site
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
@@ -78,10 +70,6 @@ def lire_sondes_depuis_db(site: str):
cnx.close()
def lire_seuils_depuis_db(site: str):
"""
Lit 'Chambres_froides' pour le site (Etat='ON').
Retour: dict {sonde: seuil_float}
"""
sql = """
SELECT Sonde, Temp_Max
FROM Chambres_froides
@@ -102,17 +90,10 @@ def lire_seuils_depuis_db(site: str):
cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
Vrai si dépassement continu >= 30 minutes.
Approche : cherche la première mesure > seuil dans les 120 dernières minutes,
vérifie que la dernière mesure est toujours > seuil et que l'écart >= 30 min.
"""
table = site
cnx = get_db()
try:
cur = cnx.cursor()
# Dernière valeur
cur.execute(f"""
SELECT Temperature, Date
FROM `{table}`
@@ -121,29 +102,20 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
LIMIT 1
""", (sonde,))
last = cur.fetchone()
if not last:
return False
if not last: return False
last_temp, last_date = float(last[0]), last[1]
if last_temp <= float(seuil):
return False
# Première mesure > seuil (fenêtre 120 min)
if last_temp <= float(seuil): return False
cur.execute(f"""
SELECT MIN(Date)
FROM `{table}`
WHERE Sonde=%s
AND Temperature > %s
AND Date >= (NOW() - INTERVAL 120 MINUTE)
WHERE Sonde=%s AND Temperature > %s AND Date >= (NOW() - INTERVAL 120 MINUTE)
""", (sonde, float(seuil)))
first_over = cur.fetchone()[0]
if not first_over:
return False
if not first_over: return False
now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None))
return (now - first_over) >= dt.timedelta(minutes=30)
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min): %s", err)
return False
log.exception("Erreur DB (depassement_depuis_30min): %s", err); return False
finally:
cnx.close()
@@ -152,14 +124,22 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1",
(sonde,)
)
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,))
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (alerte_en_cours): %s", err)
return False
log.exception("Erreur DB (alerte_en_cours): %s", err); return False
finally:
cnx.close()
def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1")
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (any_alert_open): %s", err); return False
finally:
cnx.close()
@@ -168,10 +148,7 @@ def creer_alerte(site: str, sonde: str):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')",
(sonde,)
)
cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err)
@@ -183,305 +160,254 @@ def acquitter_alerte(site: str, sonde: str):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'",
(sonde,)
)
cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (acquitter_alerte): %s", err)
finally:
cnx.close()
# ========= Helpers destinataires =========
def _split_list(raw: str | None) -> list[str]:
"""Pour les emails (MAIL_TO) — accepte virgule ou point-virgule."""
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]]:
"""
Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')]
Si pas de nom fourni -> ('', '+336...')
"""
out: list[tuple[str, str]] = []
for tok in re.split(r"[;,]", raw or ""):
tok = tok.strip()
if not tok:
continue
if not tok: continue
if ":" in tok:
name, num = tok.split(":", 1)
out.append((name.strip(), num.strip()))
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]:
"""
Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs.
Si ALERT_SMS_ONLY absent -> tous les numéros.
"""
only = os.getenv("ALERT_SMS_ONLY")
if not only:
return [num for (_name, num) in labeled]
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)]
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:
"""Michel(+336...), Christian(+336...) pour les logs."""
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
return ", ".join([f"{n}({p})" if n else p for n,p in labeled])
# ========= Notifications (OVH + SMTP) =========
class Notifier:
def __init__(self):
# ...
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"
)
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"),
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("ALERT_SMS_TO_Meudon")
or os.getenv("ALERT_SMS_TO_MEUDON")
or os.getenv("ALERT_SMS_TO"))
raw_sms = (os.getenv("ALERT_SMS_TO_Meudon") or os.getenv("ALERT_SMS_TO_MEUDON") or os.getenv("ALERT_SMS_TO"))
self.sms_labeled = _parse_labeled_phones(raw_sms)
else:
self.sms_labeled = []
# SMTP
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT", "465"))
self.smtp_port = int(os.getenv("SMTP_PORT","465"))
self.smtp_user = os.getenv("SMTP_USER")
self.smtp_pass = os.getenv("SMTP_PASS")
self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user
self.mail_to = _split_list(os.getenv("MAIL_TO") or os.getenv("EMAIL_DESTINATAIRES"))
self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper()
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
site_key = SITE # "Saclay" ou "Meudon" selon le fichier
raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}")
or os.getenv(f"MAIL_TO_{site_key.upper()}")
or os.getenv("MAIL_TO")
or "")
raw_mail_to = (os.getenv("MAIL_TO_Meudon") or os.getenv("MAIL_TO_MEUDON") or os.getenv("MAIL_TO") or "")
self.mail_to = _split_list(raw_mail_to)
self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}")
or os.getenv(f"MAIL_FROM_{site_key.upper()}")
or os.getenv("MAIL_FROM")
or self.smtp_user)
self.mail_from = (os.getenv("MAIL_FROM_Meudon") or os.getenv("MAIL_FROM_MEUDON") 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 = "monitor-saclay") -> bool:
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) # liste de numéros
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 a filtré tous les destinataires (aucun envoi).")
return False
log.warning("ALERT_SMS_ONLY filtre tous les destinataires."); return False
payload = {
"sender": self.ovh_sender,
"receivers": receivers,
"message": message[:1600],
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True, # transactionnel (H24) si habilité chez OVH
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
"sender": self.ovh_sender, "receivers": receivers, "message": message[:1600],
"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]))
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)
# Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404
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
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 err:
log.debug("Suivi job OVH indisponible (OK): %s", err)
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
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_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)
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 s:
if debug: s.set_debuglevel(1); s.login(self.smtp_user,self.smtp_pass); s.send_message(msg)
def _send_starttls():
with smtplib.SMTP(self.smtp_host,self.smtp_port,timeout=timeout) as s:
if debug: s.set_debuglevel(1); s.ehlo(); s.starttls(context=ssl.create_default_context()); s.ehlo()
s.login(self.smtp_user,self.smtp_pass); s.send_message(msg)
try:
if self.smtp_security == "STARTTLS":
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server:
server.ehlo()
server.starttls(context=ssl.create_default_context())
server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
else:
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server:
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
log.info("Email envoyé à %s", self.mail_to)
return True
except (smtplib.SMTPException, ssl.SSLError) as err:
log.exception("Erreur SMTP: %s", err)
return False
except Exception as err:
log.exception("Echec envoi email: %s", err)
return False
if self.smtp_security=="STARTTLS":
try: _send_starttls()
except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
log.warning("STARTTLS/587 a échoué (%s). Tentative 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
# ========= Helpers de mise en forme des 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,sonde,temp,seuil,when=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,sonde,temp,seuil,when=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 fmt_deg(v: float) -> str:
s = f"{float(v):.1f}".replace(".", ",")
return f"{s}°C"
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt"))
self.site = site
self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}"))
self.last_state: bool | None = None
def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS)
if not self.enabled:
log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou 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
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
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")
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
# ========= Fonctions de notification haut niveau =========
# notifier = Notifier()
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text)
mail_ok = notifier.send_email(subject, email_body)
if sms_ok and mail_ok:
log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde)
elif sms_ok:
log.warning("Alerte: SMS OK, mail KO pour %s/%s", site, sonde)
elif mail_ok:
log.warning("Alerte: mail OK, SMS KO pour %s/%s", site, sonde)
else:
log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde)
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul
if sms_ok:
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde)
else:
log.warning("Acquittement: SMS KO pour %s/%s", site, sonde)
# ========= Cycle & boucle de monitoring =========
def run_monitor_cycle(site: str):
sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site)
for r in sondes:
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom):
creer_alerte(site, nom)
notifier_sur_depassement(site, nom, temp, seuil)
# --- Création du client MQTT : compatible paho 1.x et 2.x ---
cbver = getattr(mqtt, "CallbackAPIVersion", None)
if cbver is not None:
# paho >= 2.x : on choisit la meilleure constante disponible
api_v = (
getattr(cbver, "VERSION2", None) # paho 2.x
or getattr(cbver, "V5", None) # certaines builds
or getattr(cbver, "v5", None) # fallback
or getattr(cbver, "V311", None) # dernier recours
)
try:
self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client()
except TypeError:
# vieux paho ne supporte pas largument callback_api_version
self.client = mqtt.Client()
else:
if alerte_en_cours(site, nom):
acquitter_alerte(site, nom)
notifier_acquittement(site, nom, temp, seuil)
# paho 1.x
self.client = mqtt.Client()
# ------------------------------------------------------------
if user and pwd:
self.client.username_pw_set(user, pwd)
if tls:
self.client.tls_set()
def run_monitor_loop(site: str, period_sec: int = 300):
log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", site, period_sec)
while True:
t0 = time.time()
try:
run_monitor_cycle(site)
except Exception as err: # volontaire : ne jamais tuer le service
log.exception("Erreur cycle monitoring: %s", err)
time.sleep(max(0, period_sec - (time.time() - t0)))
self.client.connect(host, port, keepalive=30)
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
# ========= Entrée CLI =========
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Monitor_Saclay")
parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay"))
parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)")
# tests
parser.add_argument("--test-sms", action="store_true")
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("--once", action="store_true", help="exécuter un seul cycle puis quitter")
args = parser.parse_args()
# Faire en sorte que --site pilote aussi la liste SMS
import os
def set(self, on: bool):
if not self.enabled:
return
if self.last_state is not None and self.last_state == on:
return
os.environ["SITE_NAME"] = args.site
payload = "on" if on else "off"
try:
r = self.client.publish(self.topic, payload=payload, qos=1, retain=True)
r.wait_for_publish(timeout=3)
if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
else:
log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper())
self.last_state = on
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
# Créer le notifier maintenant (après avoir fixé SITE_NAME)
from typing import cast
notifier = Notifier() # type: ignore[assignment]
if args.test_sms:
notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail:
notifier.send_email("[TEST DOMO91] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(args.site, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(args.site, "Congelateur", -15.2, -15.0)
else:
if args.once:
run_monitor_cycle(args.site)
notifier = Notifier()
beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site,sonde,temp,seuil):
subject,sms_text,email_body=build_alert_text(site,sonde,temp,seuil)
notifier.send_sms(sms_text); notifier.send_email(subject,email_body)
try: beacon.set(True)
except Exception: pass
def notifier_acquittement(site,sonde,temp,seuil):
subject,sms_text,_=build_ok_text(site,sonde,temp,seuil)
notifier.send_sms(sms_text)
try:
if not any_alert_open(site): beacon.set(False)
except Exception: pass
def run_monitor_cycle(site: str = SITE):
sondes=lire_sondes_depuis_db(site); seuils=lire_seuils_depuis_db(site)
for r in sondes:
nom=str(r["Sonde"]); temp=float(r["Temperature"]); seuil=float(seuils.get(nom,6.0))
if temp>seuil:
if depassement_depuis_30min(site,nom,seuil) and not alerte_en_cours(site,nom):
creer_alerte(site,nom); notifier_sur_depassement(site,nom,temp,seuil)
else:
run_monitor_loop(args.site, period_sec=args.period)
if alerte_en_cours(site,nom):
acquitter_alerte(site,nom); notifier_acquittement(site,nom,temp,seuil)
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)
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)))
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: notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail","OK")
elif args.test_alert: notifier_sur_depassement(SITE,"Congelateur",-14.5,-15.0)
elif args.test_ok: notifier_acquittement(SITE,"Congelateur",-15.2,-15.0)
else:
if args.once: run_monitor_cycle(SITE)
else: run_monitor_loop(SITE,period_sec=args.period)

View File

@@ -1,46 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ========= Imports & chargement .env =========
import os
import re
import time
import ssl
import smtplib
import logging
# ========= 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 typing import List
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
SITE = "Saclay"
PROGRAM_NAME = f"Monitor_{SITE}"
# MySQL
import mysql.connector
from mysql.connector import Error as MySQLError
# OVH (robuste même si la lib n'est pas installée)
# OVH (SMS)
try:
import ovh
from ovh.exceptions import APIError as OVHAPIError
_ovh_available = True
except Exception:
ovh = None # type: ignore
class OVHAPIError(Exception): # fallback pour les except
pass
class OVHAPIError(Exception): pass
_ovh_available = False
# MQTT
try:
import paho.mqtt.client as mqtt
_mqtt_ok = True
except Exception:
_mqtt_ok = False
# ========= Logger =========
log = logging.getLogger("monitor_saclay")
log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ========= Utilitaires DB =========
# ========= DB utils =========
def get_db():
cnx = mysql.connector.connect(
host=os.getenv("DB_HOST", "localhost"),
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME", "Sondes"),
@@ -50,10 +53,6 @@ def get_db():
return cnx
def lire_sondes_depuis_db(site: str):
"""
Relevés les + récents par sonde pour le site (table = nom du site, ex. 'Saclay').
Retour: [{"Sonde": str, "Temperature": float, "Date": datetime}]
"""
table = site
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
@@ -79,10 +78,6 @@ def lire_sondes_depuis_db(site: str):
cnx.close()
def lire_seuils_depuis_db(site: str):
"""
Lit 'Chambres_froides' pour le site (Etat='ON').
Retour: dict {sonde: seuil_float}
"""
sql = """
SELECT Sonde, Temp_Max
FROM Chambres_froides
@@ -103,17 +98,11 @@ def lire_seuils_depuis_db(site: str):
cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
Vrai si dépassement continu >= 30 minutes.
Approche : cherche la première mesure > seuil dans les 120 dernières minutes,
vérifie que la dernière mesure est toujours > seuil et que l'écart >= 30 min.
"""
table = site
cnx = get_db()
try:
cur = cnx.cursor()
# Dernière valeur
cur.execute(f"""
SELECT Temperature, Date
FROM `{table}`
@@ -128,7 +117,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
if last_temp <= float(seuil):
return False
# Première mesure > seuil (fenêtre 120 min)
cur.execute(f"""
SELECT MIN(Date)
FROM `{table}`
@@ -153,10 +141,7 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1",
(sonde,)
)
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,))
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (alerte_en_cours): %s", err)
@@ -164,15 +149,25 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
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()
def creer_alerte(site: str, sonde: str):
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')",
(sonde,)
)
cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err)
@@ -184,26 +179,18 @@ def acquitter_alerte(site: str, sonde: str):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'",
(sonde,)
)
cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (acquitter_alerte): %s", err)
finally:
cnx.close()
# ========= Helpers destinataires =========
# ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]:
"""Pour les emails (MAIL_TO) — accepte virgule ou point-virgule."""
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]]:
"""
Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')]
Si pas de nom fourni -> ('', '+336...')
"""
out: list[tuple[str, str]] = []
for tok in re.split(r"[;,]", raw or ""):
tok = tok.strip()
@@ -217,79 +204,56 @@ def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
return out
def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
"""
Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs.
Si ALERT_SMS_ONLY absent -> tous les numéros.
"""
only = os.getenv("ALERT_SMS_ONLY")
if not only:
return [num for (_name, num) in labeled]
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:
"""Michel(+336...), Christian(+336...) pour les logs."""
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
# ========= Notifications (OVH + SMTP) =========
# ========= Notifier (SMS + Mail) =========
class Notifier:
def __init__(self):
# OVH
# 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"
)
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"),
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")
# <<< LIGNE CLÉ POUR SACLAY >>>
raw_sms = (os.getenv("ALERT_SMS_TO_Saclay")
or os.getenv("ALERT_SMS_TO_SACLAY")
or os.getenv("ALERT_SMS_TO")) # fallback facultatif
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"))
self.sms_labeled = _parse_labeled_phones(raw_sms)
else:
self.sms_labeled = []
# SMTP
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT", "465"))
self.smtp_port = int(os.getenv("SMTP_PORT","465"))
self.smtp_user = os.getenv("SMTP_USER")
self.smtp_pass = os.getenv("SMTP_PASS")
self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user
self.mail_to = _split_list(os.getenv("MAIL_TO") or os.getenv("EMAIL_DESTINATAIRES"))
self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper()
# >>> NOUVEAU : destinataires/expéditeur par site, sinon valeur générique
site_key = SITE # "Saclay" ou "Meudon" selon le fichier
raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}")
or os.getenv(f"MAIL_TO_{site_key.upper()}")
or os.getenv("MAIL_TO")
or "")
self.mail_to = _split_list(raw_mail_to)
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}")
or os.getenv(f"MAIL_FROM_{site_key.upper()}")
or os.getenv("MAIL_FROM")
or self.smtp_user)
raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") 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.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 = "monitor-saclay") -> bool:
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) # liste de numéros
receivers = _resolve_sms_receivers(self.sms_labeled)
if not receivers:
log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).")
log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).")
return False
payload = {
@@ -299,42 +263,35 @@ class Notifier:
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True, # transactionnel (H24) si habilité chez OVH
"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]))
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)
# Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404
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
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 err:
log.debug("Suivi job OVH indisponible (OK): %s", err)
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
log.exception("Erreur API OVH: %s", err); return False
except Exception as err:
log.exception("Echec envoi SMS OVH: %s", err)
return False
log.exception("Echec envoi SMS 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
log.warning("SMTP non configuré, email non envoyé."); return False
msg = EmailMessage()
msg["From"] = self.mail_from
@@ -342,34 +299,44 @@ class Notifier:
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":
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server:
server.ehlo()
server.starttls(context=ssl.create_default_context())
server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
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:
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server:
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
_send_ssl()
log.info("Email envoyé à %s", self.mail_to)
return True
except (smtplib.SMTPException, ssl.SSLError) as err:
log.exception("Erreur SMTP: %s", err)
return False
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
log.exception("Echec envoi email: %s", err); return False
# ========= Helpers de mise en forme des messages =========
# ========= 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"
s = f"{float(v):.1f}".replace(".", ","); return f"{s}°C"
def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS)
@@ -377,106 +344,150 @@ def now_paris() -> dt.datetime:
def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris()
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}",
]
lines = [subject + ":", f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})", f"Site: {site}", f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"]
txt = "\n".join(lines)
return subject, txt, txt
def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris()
subject = f"[OK {site}] {sonde} revenue normale"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}",
]
lines = [subject + ":", f"Sonde: {sonde}", f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}", f"Site: {site}", f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}"]
txt = "\n".join(lines)
return subject, txt, txt
# ========= Fonctions de notification haut niveau =========
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt"))
self.site = site
self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}"))
self.last_state: bool | None = None
if not self.enabled:
log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou 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:
# paho >= 2.x : on choisit la meilleure constante disponible
api_v = (
getattr(cbver, "VERSION2", None) # paho 2.x
or getattr(cbver, "V5", None) # certaines builds
or getattr(cbver, "v5", None) # fallback
or getattr(cbver, "V311", None) # dernier recours
)
try:
self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client()
except TypeError:
# vieux paho ne supporte pas largument callback_api_version
self.client = mqtt.Client()
else:
# paho 1.x
self.client = mqtt.Client()
# ------------------------------------------------------------
if user and pwd:
self.client.username_pw_set(user, pwd)
if tls:
self.client.tls_set()
try:
self.client.connect(host, port, keepalive=30)
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
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=1, retain=True)
r.wait_for_publish(timeout=3)
if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
else:
log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper())
self.last_state = on
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
# ========= Notifs haut-niveau =========
notifier = Notifier()
beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text)
mail_ok = notifier.send_email(subject, email_body)
if sms_ok and mail_ok:
log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde)
elif sms_ok:
log.warning("Alerte: SMS OK, mail KO pour %s/%s", site, sonde)
elif mail_ok:
log.warning("Alerte: mail OK, SMS KO pour %s/%s", site, sonde)
else:
log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde)
notifier.send_sms(sms_text)
notifier.send_email(subject, email_body)
try: beacon.set(True)
except Exception: pass
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul
if sms_ok:
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde)
else:
log.warning("Acquittement: SMS KO pour %s/%s", site, sonde)
notifier.send_sms(sms_text)
try:
if not any_alert_open(site):
beacon.set(False)
except Exception: pass
# ========= Cycle & boucle de monitoring =========
def run_monitor_cycle(site: str):
# ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE):
sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site)
for r in sondes:
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant
nom = str(r["Sonde"]); temp = float(r["Temperature"]); seuil = float(seuils.get(nom, 6.0))
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom):
creer_alerte(site, nom)
notifier_sur_depassement(site, nom, temp, seuil)
creer_alerte(site, nom); notifier_sur_depassement(site, nom, temp, seuil)
else:
if alerte_en_cours(site, nom):
acquitter_alerte(site, nom)
notifier_acquittement(site, nom, temp, seuil)
acquitter_alerte(site, nom); notifier_acquittement(site, nom, temp, seuil)
def run_monitor_loop(site: str, period_sec: int = 300):
log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", site, period_sec)
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)
while True:
t0 = time.time()
try:
run_monitor_cycle(site)
except Exception as err: # volontaire : ne jamais tuer le service
except Exception as err:
log.exception("Erreur cycle monitoring: %s", err)
time.sleep(max(0, period_sec - (time.time() - t0)))
# ========= Entrée CLI =========
# ========= CLI =========
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Monitor_Saclay")
parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay"))
parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)")
# tests
parser.add_argument("--test-sms", action="store_true")
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("--once", action="store_true", help="exécuter un seul cycle puis quitter")
args = parser.parse_args()
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:
notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail:
notifier.send_email("[TEST DOMO91] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(args.site, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(args.site, "Congelateur", -15.2, -15.0)
if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
else:
if args.once:
run_monitor_cycle(args.site)
else:
run_monitor_loop(args.site, period_sec=args.period)
if args.once: run_monitor_cycle(SITE)
else: run_monitor_loop(SITE, period_sec=args.period)

View File

@@ -36,7 +36,7 @@ for key, default in {
db_config = {
"host": os.getenv("DB_HOST"),
"user": os.getenv("DB_USER"),
"password": os.getenv("DB_PASSWORD"),
"password": os.getenv("DB_PASS"),
"database": os.getenv("DB_NAME")
}

View File

@@ -41,7 +41,7 @@ COL_DATE = "date"
DB_CFG = dict(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME"),
port=int(os.getenv("MYSQL_PORT", "3306")),
)

View File

@@ -8,7 +8,7 @@ def connect_to_mysql():
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME")
)