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 #connexion mysql
DB_HOST=162.19.78.131 DB_HOST=162.19.78.131
DB_USER=sondes DB_USER=sondes
DB_PASSWORD=TX.)-U1!zq5Axdk4 DB_PASS=TX.)-U1!zq5Axdk4
DB_NAME=Sondes DB_NAME=Sondes
# MQTT # MQTT
MQTT_HOST=162.19.78.131 GYRO_MODE=mqtt
MQTT_USER=sondes MQTT_HOST=54.36.188.119
MQTT_PASS=3J@bjYP0 MQTT_USER=Bwps
GYRO_PUBLISH_GLOBAL=1 MQTT_PASS=scJ5ACj2keRfI^
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# paramètres mail # paramètres mail
SMTP_HOST=ssl0.ovh.net SMTP_HOST=ssl0.ovh.net
@@ -30,5 +32,7 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR 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 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 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ========= Imports & chargement .env ========= SITE = "Meudon"
import os PROGRAM_NAME = f"Monitor_{SITE}"
import re
import time import os, re, time, ssl, smtplib, logging
import ssl
import smtplib
import logging
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import List from typing import List
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)
SITE = "Meudon"
PROGRAM_NAME = f"Monitor_{SITE}"
# MySQL
import mysql.connector import mysql.connector
from mysql.connector import Error as MySQLError from mysql.connector import Error as MySQLError
# OVH (robuste même si la lib n'est pas installée)
try: try:
import ovh import ovh
from ovh.exceptions import APIError as OVHAPIError from ovh.exceptions import APIError as OVHAPIError
_ovh_available = True _ovh_available = True
except Exception: except Exception:
ovh = None # type: ignore ovh = None # type: ignore
class OVHAPIError(Exception): # fallback pour les except class OVHAPIError(Exception): pass
pass
_ovh_available = False _ovh_available = False
# ========= Logger ========= try:
log = logging.getLogger("monitor_saclay") import paho.mqtt.client as mqtt
_mqtt_ok = True
except Exception:
_mqtt_ok = False
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")
# ========= Utilitaires DB =========
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", "localhost"),
@@ -49,10 +45,6 @@ def get_db():
return cnx return cnx
def lire_sondes_depuis_db(site: str): 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 table = site
sql = f""" sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date SELECT t1.Sonde, t1.Temperature, t1.Date
@@ -78,10 +70,6 @@ def lire_sondes_depuis_db(site: str):
cnx.close() cnx.close()
def lire_seuils_depuis_db(site: str): def lire_seuils_depuis_db(site: str):
"""
Lit 'Chambres_froides' pour le site (Etat='ON').
Retour: dict {sonde: seuil_float}
"""
sql = """ sql = """
SELECT Sonde, Temp_Max SELECT Sonde, Temp_Max
FROM Chambres_froides FROM Chambres_froides
@@ -102,17 +90,10 @@ def lire_seuils_depuis_db(site: str):
cnx.close() cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: 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 table = site
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
# Dernière valeur
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
@@ -121,29 +102,20 @@ 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: if not last: return False
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): if last_temp <= float(seuil): return False
return False
# Première mesure > seuil (fenêtre 120 min)
cur.execute(f""" cur.execute(f"""
SELECT MIN(Date) SELECT MIN(Date)
FROM `{table}` FROM `{table}`
WHERE Sonde=%s WHERE Sonde=%s AND Temperature > %s AND Date >= (NOW() - INTERVAL 120 MINUTE)
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: if not first_over: return False
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) log.exception("Erreur DB (depassement_depuis_30min): %s", err); return False
return False
finally: finally:
cnx.close() cnx.close()
@@ -152,14 +124,22 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,))
f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1",
(sonde,)
)
return cur.fetchone() is not None return cur.fetchone() is not None
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (alerte_en_cours): %s", err) log.exception("Erreur DB (alerte_en_cours): %s", err); return False
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: finally:
cnx.close() cnx.close()
@@ -168,10 +148,7 @@ def creer_alerte(site: str, sonde: str):
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,))
f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')",
(sonde,)
)
cnx.commit() cnx.commit()
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err) log.exception("Erreur DB (creer_alerte): %s", err)
@@ -183,62 +160,40 @@ def acquitter_alerte(site: str, sonde: str):
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,))
f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'",
(sonde,)
)
cnx.commit() cnx.commit()
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (acquitter_alerte): %s", err) log.exception("Erreur DB (acquitter_alerte): %s", err)
finally: finally:
cnx.close() cnx.close()
# ========= Helpers destinataires =========
def _split_list(raw: str | None) -> list[str]: 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()] 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]]: 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]] = [] 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: if not tok: continue
continue
if ":" in tok: if ":" in tok:
name, num = tok.split(":", 1) name, num = tok.split(":", 1); out.append((name.strip(), num.strip()))
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]:
"""
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") only = os.getenv("ALERT_SMS_ONLY")
if not only: if not only: return [num for (_n,num) in labeled]
return [num for (_name, 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:
"""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: class Notifier:
def __init__(self): def __init__(self):
# ...
self.ovh_enabled = _ovh_available and all( self.ovh_enabled = _ovh_available and all(
os.getenv(k) for k in ( os.getenv(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER")
"OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
"OVH_SMS_SERVICE", "OVH_SMS_SENDER"
)
) )
if self.ovh_enabled: if self.ovh_enabled:
self.ovh_client = ovh.Client( self.ovh_client = ovh.Client(
@@ -249,239 +204,210 @@ 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_Meudon")
or os.getenv("ALERT_SMS_TO_MEUDON")
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.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("MAIL_TO_Meudon") or os.getenv("MAIL_TO_MEUDON") or os.getenv("MAIL_TO") or "")
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.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(f"MAIL_FROM_{site_key}")
or os.getenv(f"MAIL_FROM_{site_key.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]) 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: if not self.ovh_enabled or not self.sms_labeled:
log.warning("SMS désactivé ou aucun destinataire.") log.warning("SMS désactivé ou aucun destinataire."); return False
return False receivers = _resolve_sms_receivers(self.sms_labeled)
receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros
if not receivers: if not receivers:
log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).") log.warning("ALERT_SMS_ONLY filtre tous les destinataires."); return False
return False
payload = { payload = {
"sender": self.ovh_sender, "sender": self.ovh_sender, "receivers": receivers, "message": message[:1600],
"receivers": receivers, "priority": "high", "coding": "7bit", "class": "phoneDisplay",
"message": message[:1600], "noStopClause": True, "senderForResponse": False, "validityPeriod": 2880, "tag": tag,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True, # transactionnel (H24) si habilité chez OVH
"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 [] ids = resp.get("ids") or []; log.info("SMS OVH envoyé (job ids=%s)", ids)
log.info("SMS OVH envoyé (job ids=%s)", ids)
# Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404
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"): if job.get("status") in ("done","error","cancelled"): log.info("Statut job SMS: %s", job.get("status")); break
log.info("Statut job SMS: %s", job.get("status"))
break
time.sleep(1.5) time.sleep(1.5)
except Exception as err: except Exception as e: log.debug("Suivi job OVH indisponible (OK): %s", e)
log.debug("Suivi job OVH indisponible (OK): %s", err)
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é.") log.warning("SMTP non configuré, email non envoyé."); return False
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() def _send_ssl():
msg["From"] = self.mail_from with smtplib.SMTP_SSL(self.smtp_host,465,context=ssl.create_default_context(),timeout=timeout) as s:
msg["To"] = ", ".join(self.mail_to) if debug: s.set_debuglevel(1); s.login(self.smtp_user,self.smtp_pass); s.send_message(msg)
msg["Subject"] = subject def _send_starttls():
msg.set_content(body) 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: try:
if self.smtp_security=="STARTTLS": if self.smtp_security=="STARTTLS":
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server: try: _send_starttls()
server.ehlo() except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
server.starttls(context=ssl.create_default_context()) log.warning("STARTTLS/587 a échoué (%s). Tentative SSL/465...", err); _send_ssl()
server.ehlo() else: _send_ssl()
server.login(self.smtp_user, self.smtp_pass) log.info("Email envoyé à %s", self.mail_to); return True
server.send_message(msg) except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err: log.exception("Erreur SMTP: %s", err); return False
else: except Exception as err: log.exception("Echec envoi email: %s", err); return False
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
# ========= Helpers de mise en forme des 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 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: # ========= Gyrophare MQTT =========
s = f"{float(v):.1f}".replace(".", ",") class MQTTPublisher:
return f"{s}°C" 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: if not self.enabled:
return dt.datetime.now(tz=PARIS) 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): host = os.getenv("MQTT_HOST", "localhost")
when = when or now_paris() port = int(os.getenv("MQTT_PORT", "1883"))
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil" user = os.getenv("MQTT_USER")
lines = [ pwd = os.getenv("MQTT_PASS")
subject + ":", tls = (os.getenv("MQTT_TLS", "0") == "1")
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): # --- Création du client MQTT : compatible paho 1.x et 2.x ---
when = when or now_paris() cbver = getattr(mqtt, "CallbackAPIVersion", None)
subject = f"[OK {site}] {sonde} revenue normale" if cbver is not None:
lines = [ # paho >= 2.x : on choisit la meilleure constante disponible
subject + ":", api_v = (
f"Sonde: {sonde}", getattr(cbver, "VERSION2", None) # paho 2.x
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}", or getattr(cbver, "V5", None) # certaines builds
f"Site: {site}", or getattr(cbver, "v5", None) # fallback
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}", or getattr(cbver, "V311", None) # dernier recours
] )
txt = "\n".join(lines) try:
return subject, txt, txt 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()
# ------------------------------------------------------------
# ========= Fonctions de notification haut niveau ========= if user and pwd:
# notifier = Notifier() self.client.username_pw_set(user, pwd)
if tls:
self.client.tls_set()
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): 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)
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) subject,sms_text,email_body=build_alert_text(site,sonde,temp,seuil)
sms_ok = notifier.send_sms(sms_text) notifier.send_sms(sms_text); notifier.send_email(subject,email_body)
mail_ok = notifier.send_email(subject, email_body) try: beacon.set(True)
if sms_ok and mail_ok: except Exception: pass
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): def notifier_acquittement(site,sonde,temp,seuil):
subject,sms_text,_=build_ok_text(site,sonde,temp,seuil) subject,sms_text,_=build_ok_text(site,sonde,temp,seuil)
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul notifier.send_sms(sms_text)
if sms_ok: try:
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde) if not any_alert_open(site): beacon.set(False)
else: except Exception: pass
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)
def run_monitor_cycle(site: str = 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"]) nom=str(r["Sonde"]); temp=float(r["Temperature"]); seuil=float(seuils.get(nom,6.0))
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant
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) and not alerte_en_cours(site,nom):
creer_alerte(site, nom) creer_alerte(site,nom); notifier_sur_depassement(site,nom,temp,seuil)
notifier_sur_depassement(site, nom, temp, seuil)
else: else:
if alerte_en_cours(site,nom): if alerte_en_cours(site,nom):
acquitter_alerte(site, nom) acquitter_alerte(site,nom); notifier_acquittement(site,nom,temp,seuil)
notifier_acquittement(site, nom, temp, seuil)
def run_monitor_loop(site: str, period_sec: int = 300): def run_monitor_loop(site: str = SITE, period_sec: int = 300):
log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", 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: try: run_monitor_cycle(site)
run_monitor_cycle(site) except Exception as err: log.exception("Erreur cycle monitoring: %s",err)
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))) time.sleep(max(0, period_sec-(time.time()-t0)))
# ========= Entrée CLI =========
if __name__=="__main__": if __name__=="__main__":
import argparse import argparse
parser = argparse.ArgumentParser(description="Monitor_Saclay") p=argparse.ArgumentParser(description=PROGRAM_NAME)
parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay")) p.add_argument("--period",type=int,default=300)
parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)") p.add_argument("--test-sms",action="store_true")
# tests p.add_argument("--test-mail",action="store_true")
parser.add_argument("--test-sms", action="store_true") p.add_argument("--test-alert",action="store_true")
parser.add_argument("--test-mail", action="store_true") p.add_argument("--test-ok",action="store_true")
parser.add_argument("--test-alert", action="store_true") p.add_argument("--once",action="store_true")
parser.add_argument("--test-ok", action="store_true") args=p.parse_args()
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
os.environ["SITE_NAME"] = args.site if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail","OK")
# Créer le notifier maintenant (après avoir fixé SITE_NAME) elif args.test_alert: notifier_sur_depassement(SITE,"Congelateur",-14.5,-15.0)
from typing import cast elif args.test_ok: notifier_acquittement(SITE,"Congelateur",-15.2,-15.0)
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: else:
if args.once: if args.once: run_monitor_cycle(SITE)
run_monitor_cycle(args.site) else: run_monitor_loop(SITE,period_sec=args.period)
else:
run_monitor_loop(args.site, period_sec=args.period)

View File

@@ -1,46 +1,49 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# ========= Imports & chargement .env ========= # ========= Site =========
import os SITE = "Saclay"
import re PROGRAM_NAME = f"Monitor_{SITE}"
import time
import ssl # ========= Imports & .env =========
import smtplib import os, re, time, ssl, smtplib, logging
import logging
import datetime as dt import datetime as dt
from email.message import EmailMessage from email.message import EmailMessage
from typing import List from typing import List
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)
SITE = "Saclay"
PROGRAM_NAME = f"Monitor_{SITE}"
# MySQL # MySQL
import mysql.connector import mysql.connector
from mysql.connector import Error as MySQLError from mysql.connector import Error as MySQLError
# OVH (robuste même si la lib n'est pas installée) # OVH (SMS)
try: try:
import ovh import ovh
from ovh.exceptions import APIError as OVHAPIError from ovh.exceptions import APIError as OVHAPIError
_ovh_available = True _ovh_available = True
except Exception: except Exception:
ovh = None # type: ignore ovh = None # type: ignore
class OVHAPIError(Exception): # fallback pour les except class OVHAPIError(Exception): pass
pass
_ovh_available = False _ovh_available = False
# MQTT
try:
import paho.mqtt.client as mqtt
_mqtt_ok = True
except Exception:
_mqtt_ok = False
# ========= Logger ========= # ========= Logger =========
log = logging.getLogger("monitor_saclay") 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")
# ========= Utilitaires DB ========= # ========= DB utils =========
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"),
@@ -50,10 +53,6 @@ def get_db():
return cnx return cnx
def lire_sondes_depuis_db(site: str): 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 table = site
sql = f""" sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date SELECT t1.Sonde, t1.Temperature, t1.Date
@@ -79,10 +78,6 @@ def lire_sondes_depuis_db(site: str):
cnx.close() cnx.close()
def lire_seuils_depuis_db(site: str): def lire_seuils_depuis_db(site: str):
"""
Lit 'Chambres_froides' pour le site (Etat='ON').
Retour: dict {sonde: seuil_float}
"""
sql = """ sql = """
SELECT Sonde, Temp_Max SELECT Sonde, Temp_Max
FROM Chambres_froides FROM Chambres_froides
@@ -103,17 +98,11 @@ def lire_seuils_depuis_db(site: str):
cnx.close() cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: 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 table = site
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
# Dernière valeur
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
@@ -128,7 +117,6 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
if last_temp <= float(seuil): if last_temp <= float(seuil):
return False return False
# Première mesure > seuil (fenêtre 120 min)
cur.execute(f""" cur.execute(f"""
SELECT MIN(Date) SELECT MIN(Date)
FROM `{table}` FROM `{table}`
@@ -153,10 +141,7 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1", (sonde,))
f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1",
(sonde,)
)
return cur.fetchone() is not None return cur.fetchone() is not None
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (alerte_en_cours): %s", err) log.exception("Erreur DB (alerte_en_cours): %s", err)
@@ -164,15 +149,25 @@ def alerte_en_cours(site: str, sonde: str) -> bool:
finally: finally:
cnx.close() 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): def creer_alerte(site: str, sonde: str):
table = f"Alertes_{site}" table = f"Alertes_{site}"
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')", (sonde,))
f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')",
(sonde,)
)
cnx.commit() cnx.commit()
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err) log.exception("Erreur DB (creer_alerte): %s", err)
@@ -184,26 +179,18 @@ def acquitter_alerte(site: str, sonde: str):
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
cur.execute( cur.execute(f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'", (sonde,))
f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'",
(sonde,)
)
cnx.commit() cnx.commit()
except MySQLError as err: except MySQLError as err:
log.exception("Erreur DB (acquitter_alerte): %s", err) log.exception("Erreur DB (acquitter_alerte): %s", err)
finally: finally:
cnx.close() cnx.close()
# ========= Helpers destinataires ========= # ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]: 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()] 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]]: 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]] = [] 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()
@@ -217,29 +204,21 @@ def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
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]:
"""
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") only = os.getenv("ALERT_SMS_ONLY")
if not 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()} 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:
"""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) ========= # ========= Notifier (SMS + Mail) =========
class Notifier: class Notifier:
def __init__(self): def __init__(self):
# OVH # OVH SMS
self.ovh_enabled = _ovh_available and all( self.ovh_enabled = _ovh_available and all(
os.getenv(k) for k in ( os.getenv(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER")
"OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
"OVH_SMS_SERVICE", "OVH_SMS_SENDER"
)
) )
if self.ovh_enabled: if self.ovh_enabled:
self.ovh_client = ovh.Client( self.ovh_client = ovh.Client(
@@ -250,11 +229,7 @@ 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_Saclay") or os.getenv("ALERT_SMS_TO_SACLAY") or os.getenv("ALERT_SMS_TO"))
# <<< 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.sms_labeled = _parse_labeled_phones(raw_sms) self.sms_labeled = _parse_labeled_phones(raw_sms)
else: else:
self.sms_labeled = [] self.sms_labeled = []
@@ -264,32 +239,21 @@ class Notifier:
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.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()
# >>> 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.mail_from = (os.getenv(f"MAIL_FROM_{site_key}") raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "")
or os.getenv(f"MAIL_FROM_{site_key.upper()}") self.mail_to = _split_list(raw_mail_to)
or os.getenv("MAIL_FROM") self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user)
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 = "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: if not self.ovh_enabled or not self.sms_labeled:
log.warning("SMS désactivé ou aucun destinataire.") log.warning("SMS désactivé ou aucun destinataire.")
return False return False
receivers = _resolve_sms_receivers(self.sms_labeled)
receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros
if not receivers: 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 return False
payload = { payload = {
@@ -299,42 +263,35 @@ class Notifier:
"priority": "high", "priority": "high",
"coding": "7bit", "coding": "7bit",
"class": "phoneDisplay", "class": "phoneDisplay",
"noStopClause": True, # transactionnel (H24) si habilité chez OVH "noStopClause": True,
"senderForResponse": False, "senderForResponse": False,
"validityPeriod": 2880, "validityPeriod": 2880,
"tag": tag, "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 [] ids = resp.get("ids") or []
log.info("SMS OVH envoyé (job ids=%s)", ids) log.info("SMS OVH envoyé (job ids=%s)", ids)
# Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404
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"): if job.get("status") in ("done","error","cancelled"):
log.info("Statut job SMS: %s", job.get("status")) log.info("Statut job SMS: %s", job.get("status")); break
break
time.sleep(1.5) time.sleep(1.5)
except Exception as err: except Exception as e:
log.debug("Suivi job OVH indisponible (OK): %s", err) log.debug("Suivi job OVH indisponible (OK): %s", e)
return True return True
except OVHAPIError as err: except OVHAPIError as err:
log.exception("Erreur API OVH: %s", err) log.exception("Erreur API OVH: %s", err); return False
return False
except Exception as err: except Exception as err:
log.exception("Echec envoi SMS OVH: %s", err) log.exception("Echec envoi SMS OVH: %s", err); return False
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é.") log.warning("SMTP non configuré, email non envoyé."); return False
return False
msg = EmailMessage() msg = EmailMessage()
msg["From"] = self.mail_from msg["From"] = self.mail_from
@@ -342,34 +299,44 @@ class Notifier:
msg["Subject"] = subject msg["Subject"] = subject
msg.set_content(body) 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: try:
if self.smtp_security == "STARTTLS": if self.smtp_security == "STARTTLS":
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server: try:
server.ehlo() _send_starttls()
server.starttls(context=ssl.create_default_context()) except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
server.ehlo() log.warning("STARTTLS/587 a échoué (%s). Tentative en SSL/465...", err)
server.login(self.smtp_user, self.smtp_pass) _send_ssl()
server.send_message(msg)
else: else:
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server: _send_ssl()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
log.info("Email envoyé à %s", self.mail_to) log.info("Email envoyé à %s", self.mail_to)
return True return True
except (smtplib.SMTPException, ssl.SSLError) as err: except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err:
log.exception("Erreur SMTP: %s", err) log.exception("Erreur SMTP: %s", err); return False
return False
except Exception as err: except Exception as err:
log.exception("Echec envoi email: %s", err) log.exception("Echec envoi email: %s", err); return False
return False
# ========= Helpers de mise en forme des messages ========= # ========= 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: def fmt_deg(v: float) -> str:
s = f"{float(v):.1f}".replace(".", ",") s = f"{float(v):.1f}".replace(".", ","); return f"{s}°C"
return f"{s}°C"
def now_paris() -> dt.datetime: def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS) 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): def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris() when = when or now_paris()
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil" subject = f"[ALERTE {site}] {sonde} au-dessus du seuil"
lines = [ 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')}"]
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) txt = "\n".join(lines)
return subject, txt, txt return subject, txt, txt
def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None): def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris() when = when or now_paris()
subject = f"[OK {site}] {sonde} revenue normale" subject = f"[OK {site}] {sonde} revenue normale"
lines = [ 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')}"]
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) txt = "\n".join(lines)
return subject, txt, txt 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() notifier = Notifier()
beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float): 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)
sms_ok = notifier.send_sms(sms_text) notifier.send_sms(sms_text)
mail_ok = notifier.send_email(subject, email_body) notifier.send_email(subject, email_body)
if sms_ok and mail_ok: try: beacon.set(True)
log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde) except Exception: pass
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): 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)
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul notifier.send_sms(sms_text)
if sms_ok: try:
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde) if not any_alert_open(site):
else: beacon.set(False)
log.warning("Acquittement: SMS KO pour %s/%s", site, sonde) except Exception: pass
# ========= Cycle & boucle de monitoring ========= # ========= Cycle & boucle =========
def run_monitor_cycle(site: str): 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"]) nom = str(r["Sonde"]); temp = float(r["Temperature"]); seuil = float(seuils.get(nom, 6.0))
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant
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) and not alerte_en_cours(site, nom):
creer_alerte(site, nom) creer_alerte(site, nom); notifier_sur_depassement(site, nom, temp, seuil)
notifier_sur_depassement(site, nom, temp, seuil)
else: else:
if alerte_en_cours(site, nom): if alerte_en_cours(site, nom):
acquitter_alerte(site, nom) acquitter_alerte(site, nom); notifier_acquittement(site, nom, temp, seuil)
notifier_acquittement(site, nom, temp, seuil)
def run_monitor_loop(site: str, period_sec: int = 300): def run_monitor_loop(site: str = SITE, period_sec: int = 300):
log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", 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: try:
run_monitor_cycle(site) 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) 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)))
# ========= Entrée CLI ========= # ========= CLI =========
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
parser = argparse.ArgumentParser(description="Monitor_Saclay") p = argparse.ArgumentParser(description=PROGRAM_NAME)
parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay")) p.add_argument("--period", type=int, default=300)
parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)") p.add_argument("--test-sms", action="store_true")
# tests p.add_argument("--test-mail", action="store_true")
parser.add_argument("--test-sms", action="store_true") p.add_argument("--test-alert", action="store_true")
parser.add_argument("--test-mail", action="store_true") p.add_argument("--test-ok", action="store_true")
parser.add_argument("--test-alert", action="store_true") p.add_argument("--once", action="store_true")
parser.add_argument("--test-ok", action="store_true") args = p.parse_args()
parser.add_argument("--once", action="store_true", help="exécuter un seul cycle puis quitter")
args = parser.parse_args()
if args.test_sms: if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)")
notifier.send_sms("TEST DOMO91 (transactionnel)") elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_mail: elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
notifier.send_email("[TEST DOMO91] Mail", "OK") elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
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: else:
if args.once: if args.once: run_monitor_cycle(SITE)
run_monitor_cycle(args.site) else: run_monitor_loop(SITE, period_sec=args.period)
else:
run_monitor_loop(args.site, period_sec=args.period)

View File

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

View File

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

View File

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