Mise au point des monitoring

This commit is contained in:
2025-09-20 16:27:02 +02:00
parent 2fa848b4a7
commit 14b165ff06
2 changed files with 279 additions and 168 deletions

View File

@@ -1,19 +1,22 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ========= Site =========
SITE = "Meudon" SITE = "Meudon"
PROGRAM_NAME = f"Monitor_{SITE}" PROGRAM_NAME = f"Monitor_{SITE}"
# ========= Imports & .env =========
import os, re, time, ssl, smtplib, logging import os, re, time, ssl, smtplib, logging
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import List from datetime import datetime
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False) load_dotenv(find_dotenv(usecwd=True), override=False)
# MySQL
import mysql.connector import mysql.connector
from mysql.connector import Error as MySQLError from mysql.connector import Error as MySQLError
# OVH (SMS)
try: try:
import ovh import ovh
from ovh.exceptions import APIError as OVHAPIError from ovh.exceptions import APIError as OVHAPIError
@@ -23,19 +26,61 @@ except Exception:
class OVHAPIError(Exception): pass class OVHAPIError(Exception): pass
_ovh_available = False _ovh_available = False
# MQTT
try: try:
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
_mqtt_ok = True _mqtt_ok = True
except Exception: except Exception:
_mqtt_ok = False _mqtt_ok = False
# ========= Logger =========
log = logging.getLogger(PROGRAM_NAME.lower()) log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers: if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, 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).
"""
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 → pas de notif
cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt.strftime('%Y-%m-%d %H:%M:%S'))
)
conn.commit()
cur.close()
return True # nouvelle alerte → notifier
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).
"""
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 # True → notifier, False → rien
def get_db(): def get_db():
cnx = mysql.connector.connect( cnx = mysql.connector.connect(
host=os.getenv("DB_HOST", "localhost"), host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"), user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"), password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME", "Sondes"), database=os.getenv("DB_NAME", "Sondes"),
@@ -94,6 +139,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
@@ -102,32 +148,28 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
LIMIT 1 LIMIT 1
""", (sonde,)) """, (sonde,))
last = cur.fetchone() last = cur.fetchone()
if not last: return False if not last:
return False
last_temp, last_date = float(last[0]), last[1] last_temp, last_date = float(last[0]), last[1]
if last_temp <= float(seuil): return False if last_temp <= float(seuil):
return False
cur.execute(f""" cur.execute(f"""
SELECT MIN(Date) SELECT MIN(Date)
FROM `{table}` 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))) """, (sonde, float(seuil)))
first_over = cur.fetchone()[0] 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)) now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None))
return (now - first_over) >= dt.timedelta(minutes=30) return (now - first_over) >= dt.timedelta(minutes=30)
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min): %s", err); return False log.exception("Erreur DB (depassement_depuis_30min): %s", err)
finally: return False
cnx.close()
def alerte_en_cours(site: str, sonde: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
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
finally: finally:
cnx.close() cnx.close()
@@ -139,34 +181,11 @@ def any_alert_open(site: str) -> bool:
cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1") cur.execute(f"SELECT 1 FROM `{table}` WHERE `Etat`='En cours' LIMIT 1")
return cur.fetchone() is not None return cur.fetchone() is not None
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (any_alert_open): %s", err); return False log.exception("Erreur DB (any_alert_open): %s", err)
return False
finally: finally:
cnx.close() cnx.close()
# ========= Helpers listes/numéros =========
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,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err)
finally:
cnx.close()
def acquitter_alerte(site: str, sonde: str):
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
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()
def _split_list(raw: str | None) -> list[str]: def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -174,24 +193,29 @@ def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
for tok in re.split(r"[;,]", raw or ""): for tok in re.split(r"[;,]", raw or ""):
tok = tok.strip() tok = tok.strip()
if not tok: continue if not tok:
continue
if ":" in tok: 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: else:
out.append(("", tok)) out.append(("", tok))
return out return out
def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]: def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
only = os.getenv("ALERT_SMS_ONLY") only = os.getenv("ALERT_SMS_ONLY")
if not only: return [num for (_n,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()} 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: def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
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])
# ========= Notifier (SMS + Mail) =========
class Notifier: class Notifier:
def __init__(self): def __init__(self):
# OVH SMS
self.ovh_enabled = _ovh_available and all( 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")
) )
@@ -204,83 +228,131 @@ class Notifier:
) )
self.ovh_service = os.getenv("OVH_SMS_SERVICE") self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER") 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_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO"))
self.sms_labeled = _parse_labeled_phones(raw_sms) self.sms_labeled = _parse_labeled_phones(raw_sms)
else: else:
self.sms_labeled = [] self.sms_labeled = []
# SMTP
self.smtp_host = os.getenv("SMTP_HOST") 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_user = os.getenv("SMTP_USER")
self.smtp_pass = os.getenv("SMTP_PASS") self.smtp_pass = os.getenv("SMTP_PASS")
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper() self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
raw_mail_to = (os.getenv("MAIL_TO_Meudon") or os.getenv("MAIL_TO_MEUDON") or os.getenv("MAIL_TO") or "") 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_to = _split_list(raw_mail_to)
self.mail_from = (os.getenv("MAIL_FROM_Meudon") or os.getenv("MAIL_FROM_MEUDON") or os.getenv("MAIL_FROM") or self.smtp_user) 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]) 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: def send_sms(self, message: str, tag: str = f"monitor-{SITE.lower()}") -> bool:
if not self.ovh_enabled or not self.sms_labeled: if not self.ovh_enabled or not self.sms_labeled:
log.warning("SMS désactivé ou aucun destinataire."); return False log.warning("SMS désactivé ou aucun destinataire.")
return False
receivers = _resolve_sms_receivers(self.sms_labeled) receivers = _resolve_sms_receivers(self.sms_labeled)
if not receivers: if not receivers:
log.warning("ALERT_SMS_ONLY filtre tous les destinataires."); return False log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).")
return False
payload = { payload = {
"sender": self.ovh_sender, "receivers": receivers, "message": message[:1600], "sender": self.ovh_sender,
"priority": "high", "coding": "7bit", "class": "phoneDisplay", "receivers": receivers,
"noStopClause": True, "senderForResponse": False, "validityPeriod": 2880, "tag": tag, "message": message[:1600],
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True,
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
} }
try: 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) 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) ids = resp.get("ids") or []
log.info("SMS OVH envoyé (job ids=%s)", ids)
try: try:
if ids: if ids:
job_id = ids[0] job_id = ids[0]
for _ in range(3): for _ in range(3):
job = self.ovh_client.get(f"/sms/{self.ovh_service}/jobs/{job_id}") 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) time.sleep(1.5)
except Exception as e: log.debug("Suivi job OVH indisponible (OK): %s", e) except Exception as e:
log.debug("Suivi job OVH indisponible (OK): %s", e)
return True return True
except OVHAPIError as err: log.exception("Erreur API OVH: %s", err); return False except OVHAPIError as err:
except Exception as err: log.exception("Echec envoi SMS 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
def send_email(self, subject: str, body: str) -> bool: def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled: 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; 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" 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(): def _send_ssl():
with smtplib.SMTP_SSL(self.smtp_host,465,context=ssl.create_default_context(),timeout=timeout) as s: with smtplib.SMTP_SSL(self.smtp_host, 465, context=ssl.create_default_context(), timeout=timeout) as server:
if debug: s.set_debuglevel(1); s.login(self.smtp_user,self.smtp_pass); s.send_message(msg) if debug: server.set_debuglevel(1)
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
def _send_starttls(): def _send_starttls():
with smtplib.SMTP(self.smtp_host,self.smtp_port,timeout=timeout) as s: with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server:
if debug: s.set_debuglevel(1); s.ehlo(); s.starttls(context=ssl.create_default_context()); s.ehlo() if debug: server.set_debuglevel(1)
s.login(self.smtp_user,self.smtp_pass); s.send_message(msg) server.ehlo(); server.starttls(context=ssl.create_default_context()); server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
try: try:
if self.smtp_security == "STARTTLS": if self.smtp_security == "STARTTLS":
try: _send_starttls() try:
_send_starttls()
except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err: except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
log.warning("STARTTLS/587 a échoué (%s). Tentative SSL/465...", err); _send_ssl() log.warning("STARTTLS/587 a échoué (%s). Tentative en SSL/465...", err)
else: _send_ssl() _send_ssl()
log.info("Email envoyé à %s", self.mail_to); return True else:
except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err: log.exception("Erreur SMTP: %s", err); return False _send_ssl()
except Exception as err: log.exception("Echec envoi email: %s", err); return False 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 from zoneinfo import ZoneInfo
PARIS = ZoneInfo("Europe/Paris") 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 fmt_deg(v: float) -> str:
def build_alert_text(site,sonde,temp,seuil,when=None): s = f"{float(v):.1f}".replace(".", ","); return f"{s}°C"
when=when or now_paris(); subject=f"[ALERTE {site}] {sonde} au-dessus du seuil"
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')}"] 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 txt = "\n".join(lines)
def build_ok_text(site,sonde,temp,seuil,when=None): return subject, txt, txt
when=when or now_paris(); subject=f"[OK {site}] {sonde} revenue normale"
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 txt = "\n".join(lines)
return subject, txt, txt
# ========= Gyrophare MQTT ========= # ========= Gyrophare MQTT =========
class MQTTPublisher: class MQTTPublisher:
@@ -357,42 +429,65 @@ class MQTTPublisher:
log.exception("MQTT publish erreur: %s", e) log.exception("MQTT publish erreur: %s", e)
# ========= Notifs haut-niveau =========
notifier = Notifier() notifier = Notifier()
beacon = MQTTPublisher(SITE) beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site,sonde,temp,seuil): def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(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) notifier.send_sms(sms_text)
notifier.send_email(subject, email_body)
try: beacon.set(True) try: beacon.set(True)
except Exception: pass except Exception: pass
def notifier_acquittement(site,sonde,temp,seuil): def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil) subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
try: try:
if not any_alert_open(site): beacon.set(False) if not any_alert_open(site):
beacon.set(False)
except Exception: pass except Exception: pass
# ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE): def run_monitor_cycle(site: str = SITE):
sondes=lire_sondes_depuis_db(site); seuils=lire_seuils_depuis_db(site) sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site)
for r in sondes: for r in sondes:
nom=str(r["Sonde"]); temp=float(r["Temperature"]); seuil=float(seuils.get(nom,6.0)) nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0))
now = now_paris()
if temp > seuil: if temp > seuil:
if depassement_depuis_30min(site,nom,seuil) and not alerte_en_cours(site,nom): if depassement_depuis_30min(site, nom, seuil):
creer_alerte(site,nom); notifier_sur_depassement(site,nom,temp,seuil) # Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil)
finally:
conn.close()
else: else:
if alerte_en_cours(site,nom): # Fermer si ouvert → notifier seulement si fermeture réelle
acquitter_alerte(site,nom); notifier_acquittement(site,nom,temp,seuil) try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil)
finally:
conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300): 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) log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
while True: while True:
t0 = time.time() t0 = time.time()
try: run_monitor_cycle(site) try:
except Exception as err: log.exception("Erreur cycle monitoring: %s",err) 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, period_sec - (time.time() - t0)))
# ========= CLI =========
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
p = argparse.ArgumentParser(description=PROGRAM_NAME) p = argparse.ArgumentParser(description=PROGRAM_NAME)

View File

@@ -9,8 +9,7 @@ PROGRAM_NAME = f"Monitor_{SITE}"
import os, re, time, ssl, smtplib, logging import os, re, time, ssl, smtplib, logging
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import List from datetime import datetime
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False) load_dotenv(find_dotenv(usecwd=True), override=False)
@@ -41,6 +40,45 @@ if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ========= DB utils ========= # ========= 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).
"""
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 → pas de notif
cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt.strftime('%Y-%m-%d %H:%M:%S'))
)
conn.commit()
cur.close()
return True # nouvelle alerte → notifier
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).
"""
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 # True → notifier, False → rien
def get_db(): def get_db():
cnx = mysql.connector.connect( cnx = mysql.connector.connect(
host=os.getenv("DB_HOST"), host=os.getenv("DB_HOST"),
@@ -136,19 +174,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
finally: finally:
cnx.close() cnx.close()
def alerte_en_cours(site: str, sonde: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
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
finally:
cnx.close()
def any_alert_open(site: str) -> bool: def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}" table = f"Alertes_{site}"
cnx = get_db() cnx = get_db()
@@ -161,31 +186,6 @@ def any_alert_open(site: str) -> bool:
return False return False
finally: finally:
cnx.close() 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,))
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err)
finally:
cnx.close()
def acquitter_alerte(site: str, sonde: str):
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
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 listes/numéros ========= # ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]: def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()] return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -454,13 +454,29 @@ def run_monitor_cycle(site: str = SITE):
sondes = lire_sondes_depuis_db(site) sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site) seuils = lire_seuils_depuis_db(site)
for r in sondes: for r in sondes:
nom = str(r["Sonde"]); temp = float(r["Temperature"]); seuil = float(seuils.get(nom, 6.0)) nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0))
now = now_paris()
if temp > seuil: if temp > seuil:
if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom): if depassement_depuis_30min(site, nom, seuil):
creer_alerte(site, nom); notifier_sur_depassement(site, nom, temp, seuil) # Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil)
finally:
conn.close()
else: else:
if alerte_en_cours(site, nom): # Fermer si ouvert → notifier seulement si fermeture réelle
acquitter_alerte(site, nom); notifier_acquittement(site, nom, temp, seuil) try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil)
finally:
conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300): 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) log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)