Règlages des alertes dans Monitor_Saclay

This commit is contained in:
2025-09-23 13:40:14 +02:00
parent 9131758db7
commit 072a0cbbc5
3 changed files with 177 additions and 79 deletions

View File

@@ -12,10 +12,13 @@ from email.message import EmailMessage
from datetime import datetime
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
from utils_sms import normaliser_sms
# MySQL
import mysql.connector
from mysql.connector import Error as MySQLError
from dotenv import load_dotenv
load_dotenv()
# OVH (SMS)
try:
@@ -43,7 +46,7 @@ if not log.handlers:
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).
Retourne True si une nouvelle alerte a été créée (→ notifier par mail).
"""
cur = conn.cursor()
cur.execute(
@@ -52,20 +55,19 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
)
if cur.fetchone():
cur.close()
return False # déjà ouverte → pas de notif
return False # déjà ouverte
cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt.strftime('%Y-%m-%d %H:%M:%S'))
)
conn.commit()
cur.close()
return True # nouvelle alerte → notifier
return True
def close_alert(conn, table_alertes: str, sonde: str) -> bool:
"""
Ferme l'alerte 'En cours' si présente.
Retourne True si une alerte est passée à 'Acquitté' (→ notifier).
Retourne True si une alerte est passée à 'Acquitté' (→ notifier par mail).
"""
cur = conn.cursor()
cur.execute(
@@ -77,10 +79,10 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool:
changed = (cur.rowcount == 1)
conn.commit()
cur.close()
return changed # True → notifier, False → rien
return changed
def get_db():
cnx = mysql.connector.connect(
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"),
@@ -88,7 +90,6 @@ def get_db():
port=int(os.getenv("DB_PORT", "3306")),
autocommit=True,
)
return cnx
def lire_sondes_depuis_db(site: str):
table = site
@@ -117,7 +118,7 @@ def lire_sondes_depuis_db(site: str):
def lire_cfg_chambres(site: str):
"""
Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
depuis Chambres_froides pour le site.
"""
sql = """
@@ -145,8 +146,7 @@ def lire_cfg_chambres(site: str):
def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0):
"""
Retourne (is_on: bool, trigger: tuple[str,float,float] | None)
trigger = (sonde, temp, seuil) si dépassement détecté.
Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None)
"""
for row in last_values:
sonde = str(row["Sonde"])
@@ -178,45 +178,54 @@ def lire_seuils_depuis_db(site: str):
finally:
cnx.close()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
def depassement_depuis_30min(site: str, sonde: str, seuil: float, window_min: int = 180) -> bool:
"""
True si la sonde est au-dessus du seuil de façon CONTINUE depuis ≥ 30 min.
On lit l'historique (par défaut 3h) et on remonte jusqu'au début de la séquence > seuil.
"""
table = site
cnx = get_db()
try:
cur = cnx.cursor()
# 1) Récupère l'historique récent de la sonde (ordre : plus récent -> plus ancien)
cur.execute(f"""
SELECT Temperature, Date
FROM `{table}`
WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC
LIMIT 1
""", (sonde, ))
last = cur.fetchone()
if not last:
""", (sonde, int(window_min)))
rows = cur.fetchall()
if not rows:
return False
last_temp, last_date = float(last[0]), last[1]
# 2) La dernière valeur doit être > seuil, sinon aucun dépassement en cours
last_temp, last_dt = float(rows[0][0]), rows[0][1]
if last_temp <= float(seuil):
return False
cur.execute(f"""
SELECT MIN(Date)
FROM `{table}`
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
# 3) Remonter jusqu'au début de la séquence continue > seuil
# (dès qu'on rencontre une valeur <= seuil, on s'arrête)
start_dt = last_dt
for temp, d in rows[1:]:
if float(temp) > float(seuil):
start_dt = d # on prolonge la séquence vers le passé
else:
break # séquence continue terminée
# 4) Durée de la séquence continue
tzinfo = getattr(start_dt, "tzinfo", None)
now = dt.datetime.now(tz=tzinfo) # naïf si la date DB est naïve
return (now - start_dt) >= dt.timedelta(minutes=30)
now = dt.datetime.now(tz=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)
log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err)
return False
finally:
cnx.close()
def any_alert_open(site: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
@@ -300,10 +309,13 @@ class Notifier:
log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).")
return False
# ✅ Normalisation GSM-7 + préfixe site
message = normaliser_sms(message, prefix=SITE)
payload = {
"sender": self.ovh_sender,
"receivers": receivers,
"message": message[:1600],
"message": message,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
@@ -388,14 +400,26 @@ 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
@@ -461,7 +485,6 @@ class MQTTPublisher:
return
if self.last_state is not None and self.last_state == on:
return
payload = "ON" if on else "OFF"
try:
r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
@@ -474,7 +497,7 @@ class MQTTPublisher:
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
# ========= Contrôleur Gyro réactif (pulse/cooldown ou continu) =========
# ========= Contrôleur Gyro réactif =========
import enum, threading
class _GyroState(enum.Enum):
@@ -484,18 +507,22 @@ class _GyroState(enum.Enum):
class GyroPulseController:
"""
Boucle rapide indépendante : lit les dernières mesures + config
et applique un automate :
- MODE CONTINU (par défaut) : ON tant que lalarme persiste, OFF rapide si retour normal.
- MODE PULSE : IDLE → PULSE_ON (ON PULSE_SEC) → COOLDOWN (OFF COOLDOWN_SEC) → re-PULSE tant que lalarme persiste.
Boucle rapide indépendante :
- MODE CONTINU (défaut) : ON tant que lalarme persiste, OFF quand normal confirmé.
- MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que lalarme persiste.
Ajouts :
- SMS ALERTE immédiat à lallumage
- SMS OK immédiat à lextinction (option activée par défaut)
"""
def __init__(self, site: str, beacon, *,
def __init__(self, site: str, beacon, notifier, *,
check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")),
pulse_sec: int = int(os.getenv("GYRO_PULSE_SEC", "60")),
cooldown_sec: int = int(os.getenv("GYRO_COOLDOWN_SEC", "600")),
normal_confirm: int = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))):
self.site = site
self.beacon = beacon
self.notifier = notifier
self.check_sec = check_sec
self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec
@@ -507,7 +534,15 @@ class GyroPulseController:
self._normal_count = 0
self._stop = threading.Event()
self._thread = None
self._current = None # dernier état envoyé pour éviter les doublons
self._current = None # dernier état effectif
# Anti-spam SMS & SMS OK activé par défaut (tranquilliser)
self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi}
self._sms_min_sec = int(os.getenv("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1")
# Conserver le dernier déclencheur (pour SMS OK)
self._last_trigger: tuple[str, float, float] | None = None # (sonde, temp, seuil)
def _set_gyro(self, on: bool):
if self._current is not on:
@@ -526,16 +561,48 @@ class GyroPulseController:
def stop(self):
self._stop.set()
def _sms_can_send(self, sonde: str) -> bool:
t = time.time()
last = self._last_sms.get(sonde, 0.0)
if (t - last) >= self._sms_min_sec:
self._last_sms[sonde] = t
return True
return False
def _send_alert_sms(self, trigger: tuple[str, float, float] | None):
if not trigger:
return
sonde, temp, seuil = trigger
if self._sms_can_send(sonde):
_, sms_text, _ = build_alert_text(self.site, sonde, temp, seuil, when=now_paris())
self.notifier.send_sms(sms_text)
def _send_ok_sms_from_last_trigger(self):
if not self._send_ok or not self._last_trigger:
return
sonde, _temp_prev, seuil = self._last_trigger
# Température courante pour le SMS OK
rows = lire_sondes_depuis_db(self.site)
curr_temp = None
for r in rows:
if str(r["Sonde"]) == sonde:
curr_temp = float(r["Temperature"]); break
if curr_temp is None:
curr_temp = seuil - 0.1 # fallback léger
if self._sms_can_send(sonde):
_, sms_text, _ = build_ok_text(self.site, sonde, curr_temp, seuil, when=now_paris())
self.notifier.send_sms(sms_text)
self._last_trigger = None # reset
def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]:
"""
Réutilise tes fonctions existantes pour décider rapidement.
"""
last_rows = lire_sondes_depuis_db(self.site) # [{'Sonde','Temperature','Date'}]
cfg = lire_cfg_chambres(self.site) # {sonde: {temp_max, active, entretien}}
last_rows = lire_sondes_depuis_db(self.site)
cfg = lire_cfg_chambres(self.site)
return compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
def _run(self):
import time
while not self._stop.is_set():
now = time.time()
try:
@@ -550,10 +617,13 @@ class GyroPulseController:
self._t_pulse_end = now + self.pulse_sec
self._normal_count = 0
self.state = _GyroState.PULSE_ON
self._last_trigger = trigger
if trigger:
s, t, se = trigger
log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)",
s, t, se, "CONTINU" if os.getenv("GYRO_MODE_CONTINUOUS","1")=="1" else "PULSE")
# SMS alerte immédiat
self._send_alert_sms(trigger)
elif self.state == _GyroState.PULSE_ON:
if not active:
@@ -563,14 +633,11 @@ class GyroPulseController:
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)")
# SMS OK immédiat
self._send_ok_sms_from_last_trigger()
else:
self._normal_count = 0
# --- MODE CONTINU par défaut ---
if os.getenv("GYRO_MODE_CONTINUOUS", "1") == "1":
# Rester ON tant que lalerte persiste
pass
else:
# --- MODE PULSE ---
if os.getenv("GYRO_MODE_CONTINUOUS", "1") != "1":
if now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
@@ -599,63 +666,73 @@ 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)
notifier.send_sms(sms_text)
"""
MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base.
(Le SMS d'alerte est déjà envoyé par la boucle gyro, immédiat.)
"""
subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body)
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text)
"""
MAIL lorsque lalerte est acquittée en base.
(Le SMS "OK" a déjà été géré par la boucle gyro.)
"""
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body) # mail d'acquittement
# Optionnel: SMS "OK" côté cycle si tu souhaites un doublon (désactivé par défaut)
if os.getenv("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text)
# ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE):
# 1) Lecture dernières mesures + config chambres
last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}]
cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}}
# 1) Lecture dernières mesures + config
last_rows = lire_sondes_depuis_db(site)
cfg = lire_cfg_chambres(site)
# 2) Gyro géré par le contrôleur rapide → ici, on ne touche plus au gyro
# 2) Info: état instantané (le gyro est piloté par la boucle rapide)
try:
gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
if trigger:
s, t, se = trigger
log.info("Dépassement détecté (pilotage gyro par boucle rapide) : %s %.2f > %.2f", s, t, se)
log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se)
else:
log.info("Aucun dépassement au moment du cycle")
except Exception as e:
log.exception("Erreur calcul alarme (info): %s", e)
# 3) Alertes "officielles" (inchangées) avec temporisation 30 min
# 3) Alertes "officielles" temporisées (≥30 min) → mails
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows:
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0))
now = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
notifier_sur_depassement(site, nom, temp, seuil)
notifier_sur_depassement(site, nom, temp, seuil) # MAIL alerte
finally:
conn.close()
else:
try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil)
notifier_acquittement(site, nom, temp, seuil) # MAIL acquittement
finally:
conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300):
log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
# Démarrage du contrôleur gyro rapide (thread)
# Démarrage du contrôleur gyro rapide (thread) + notifier pour SMS immédiats
try:
global _gyro_controller
_gyro_controller = GyroPulseController(site, beacon)
_gyro_controller = GyroPulseController(site, beacon, notifier)
_gyro_controller.start()
except Exception as e:
log.exception("Impossible de démarrer le GyroPulseController: %s", e)
@@ -681,7 +758,8 @@ if __name__ == "__main__":
args = p.parse_args()
if args.test_sms:
notifier.send_sms("TEST DOMO91 (transactionnel)")
n = Notifier()
n.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert: