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

19
.env
View File

@@ -1,4 +1,3 @@
# OVH_SMS_SENDER=DOMO91FR # OVH_SMS_SENDER=DOMO91FR
#connexion mysql #connexion mysql
DB_HOST=162.19.78.131 DB_HOST=162.19.78.131
@@ -10,15 +9,20 @@ DB_NAME=Sondes
MQTT_HOST=54.36.188.119 MQTT_HOST=54.36.188.119
MQTT_USER=Bwps MQTT_USER=Bwps
MQTT_PASS="scJ5ACj2keRfI^" MQTT_PASS="scJ5ACj2keRfI^"
# Boucle rapide du gyro # Boucle rapide du gyro
GYRO_MODE=mqtt GYRO_MODE=mqtt
GYRO_CHECK_SEC=20 GYRO_CHECK_SEC=20
GYRO_NORMAL_CONFIRM=2 GYRO_NORMAL_CONFIRM=2
GYRO_MODE_CONTINUOUS=1 GYRO_MODE_CONTINUOUS=1
GYRO_HYSTERESIS=0.3 GYRO_HYSTERESIS=0.3
ALERT_OK_SMS_GYRO=1
ALERT_OK_SMS=0
GYRO_SMS_MIN_SEC=120
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# paramètres mail # paramètres mail
SMTP_HOST=ssl0.ovh.net SMTP_HOST=ssl0.ovh.net
SMTP_PORT=587 SMTP_PORT=587
@@ -26,9 +30,9 @@ SMTP_SECURITY=STARTTLS
SMTP_USER=services@domo91.fr SMTP_USER=services@domo91.fr
SMTP_PASS=6ZiCsVtSf9@nEHv@$^0 SMTP_PASS=6ZiCsVtSf9@nEHv@$^0
MAIL_FROM=services@domo91.fr MAIL_FROM=services@domo91.fr
MAIL_TO_SACLAY=robots@domo91.fr MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com
MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>" MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>"
MAIL_TO_MEUDON=robots@domo91.fr MAIL_TO_MEUDON=robots@domo91.fr,superviseur.restauration@parismeudonermitage.com,chef@parismeudonermitage.com
MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>" MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>"
# --- Paramètres SMS ---- # --- Paramètres SMS ----
@@ -41,9 +45,6 @@ OVH_SMS_SENDER=DOMO91FR
SMS_RECEIVER=+33635164680 SMS_RECEIVER=+33635164680
ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_SACLAY==Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680
# ---- Reserve SMS
# --- Réserves destinataires ---- #Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
SMS_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 #Sekou:+33625903364,Damien:+33680388259
SMS_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248
SACLAY_MAIL=nicolas.thibaut@bw-paris-saclay.com
MEUDON_MAIL=superviseur.restauration@parismeudonermitage.com,chef@parismeudonermitage.com

View File

@@ -12,10 +12,13 @@ from email.message import EmailMessage
from datetime import datetime 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)
from utils_sms import normaliser_sms
# MySQL # MySQL
import mysql.connector import mysql.connector
from mysql.connector import Error as MySQLError from mysql.connector import Error as MySQLError
from dotenv import load_dotenv
load_dotenv()
# OVH (SMS) # OVH (SMS)
try: try:
@@ -43,7 +46,7 @@ if not log.handlers:
def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool: 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. 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 = conn.cursor()
cur.execute( cur.execute(
@@ -52,20 +55,19 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
) )
if cur.fetchone(): if cur.fetchone():
cur.close() cur.close()
return False # déjà ouverte → pas de notif return False # déjà ouverte
cur.execute( cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')", f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt.strftime('%Y-%m-%d %H:%M:%S')) (sonde, dt.strftime('%Y-%m-%d %H:%M:%S'))
) )
conn.commit() conn.commit()
cur.close() cur.close()
return True # nouvelle alerte → notifier return True
def close_alert(conn, table_alertes: str, sonde: str) -> bool: def close_alert(conn, table_alertes: str, sonde: str) -> bool:
""" """
Ferme l'alerte 'En cours' si présente. 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 = conn.cursor()
cur.execute( cur.execute(
@@ -77,10 +79,10 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool:
changed = (cur.rowcount == 1) changed = (cur.rowcount == 1)
conn.commit() conn.commit()
cur.close() cur.close()
return changed # True → notifier, False → rien return changed
def get_db(): def get_db():
cnx = 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_PASS"), password=os.getenv("DB_PASS"),
@@ -88,7 +90,6 @@ def get_db():
port=int(os.getenv("DB_PORT", "3306")), port=int(os.getenv("DB_PORT", "3306")),
autocommit=True, autocommit=True,
) )
return cnx
def lire_sondes_depuis_db(site: str): def lire_sondes_depuis_db(site: str):
table = site table = site
@@ -117,7 +118,7 @@ def lire_sondes_depuis_db(site: str):
def lire_cfg_chambres(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. depuis Chambres_froides pour le site.
""" """
sql = """ 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): 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) Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None)
trigger = (sonde, temp, seuil) si dépassement détecté.
""" """
for row in last_values: for row in last_values:
sonde = str(row["Sonde"]) sonde = str(row["Sonde"])
@@ -178,45 +178,54 @@ def lire_seuils_depuis_db(site: str):
finally: finally:
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, 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 table = site
cnx = get_db() cnx = get_db()
try: try:
cur = cnx.cursor() cur = cnx.cursor()
# 1) Récupère l'historique récent de la sonde (ordre : plus récent -> plus ancien)
cur.execute(f""" cur.execute(f"""
SELECT Temperature, Date SELECT Temperature, Date
FROM `{table}` FROM `{table}`
WHERE Sonde=%s WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC ORDER BY Date DESC
LIMIT 1 """, (sonde, int(window_min)))
""", (sonde, )) rows = cur.fetchall()
last = cur.fetchone() if not rows:
if not last:
return False 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): if last_temp <= float(seuil):
return False return False
cur.execute(f""" # 3) Remonter jusqu'au début de la séquence continue > seuil
SELECT MIN(Date) # (dès qu'on rencontre une valeur <= seuil, on s'arrête)
FROM `{table}` start_dt = last_dt
WHERE Sonde=%s for temp, d in rows[1:]:
AND Temperature > %s if float(temp) > float(seuil):
AND Date >= (NOW() - INTERVAL 120 MINUTE) start_dt = d # on prolonge la séquence vers le passé
""", (sonde, float(seuil))) else:
first_over = cur.fetchone()[0] break # séquence continue terminée
if not first_over:
return False # 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: 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 return False
finally: finally:
cnx.close() 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()
@@ -300,10 +309,13 @@ class Notifier:
log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).")
return False return False
# ✅ Normalisation GSM-7 + préfixe site
message = normaliser_sms(message, prefix=SITE)
payload = { payload = {
"sender": self.ovh_sender, "sender": self.ovh_sender,
"receivers": receivers, "receivers": receivers,
"message": message[:1600], "message": message,
"priority": "high", "priority": "high",
"coding": "7bit", "coding": "7bit",
"class": "phoneDisplay", "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): 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 = [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) 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 = [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) txt = "\n".join(lines)
return subject, txt, txt return subject, txt, txt
@@ -461,7 +485,6 @@ class MQTTPublisher:
return return
if self.last_state is not None and self.last_state == on: if self.last_state is not None and self.last_state == on:
return return
payload = "ON" if on else "OFF" payload = "ON" if on else "OFF"
try: try:
r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
@@ -474,7 +497,7 @@ class MQTTPublisher:
except Exception as e: except Exception as e:
log.exception("MQTT publish erreur: %s", 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 import enum, threading
class _GyroState(enum.Enum): class _GyroState(enum.Enum):
@@ -484,18 +507,22 @@ class _GyroState(enum.Enum):
class GyroPulseController: class GyroPulseController:
""" """
Boucle rapide indépendante : lit les dernières mesures + config Boucle rapide indépendante :
et applique un automate : - MODE CONTINU (défaut) : ON tant que lalarme persiste, OFF quand normal confirmé.
- MODE CONTINU (par défaut) : ON tant que lalarme persiste, OFF rapide si retour normal. - MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que lalarme persiste.
- MODE PULSE : IDLE → PULSE_ON (ON PULSE_SEC) → COOLDOWN (OFF COOLDOWN_SEC) → re-PULSE 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")), check_sec: int = int(os.getenv("GYRO_CHECK_SEC", "20")),
pulse_sec: int = int(os.getenv("GYRO_PULSE_SEC", "60")), pulse_sec: int = int(os.getenv("GYRO_PULSE_SEC", "60")),
cooldown_sec: int = int(os.getenv("GYRO_COOLDOWN_SEC", "600")), cooldown_sec: int = int(os.getenv("GYRO_COOLDOWN_SEC", "600")),
normal_confirm: int = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))): normal_confirm: int = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))):
self.site = site self.site = site
self.beacon = beacon self.beacon = beacon
self.notifier = notifier
self.check_sec = check_sec self.check_sec = check_sec
self.pulse_sec = pulse_sec self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec self.cooldown_sec = cooldown_sec
@@ -507,7 +534,15 @@ class GyroPulseController:
self._normal_count = 0 self._normal_count = 0
self._stop = threading.Event() self._stop = threading.Event()
self._thread = None 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): def _set_gyro(self, on: bool):
if self._current is not on: if self._current is not on:
@@ -526,16 +561,48 @@ class GyroPulseController:
def stop(self): def stop(self):
self._stop.set() 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]: def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]:
""" last_rows = lire_sondes_depuis_db(self.site)
Réutilise tes fonctions existantes pour décider rapidement. cfg = lire_cfg_chambres(self.site)
"""
last_rows = lire_sondes_depuis_db(self.site) # [{'Sonde','Temperature','Date'}]
cfg = lire_cfg_chambres(self.site) # {sonde: {temp_max, active, entretien}}
return compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) return compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
def _run(self): def _run(self):
import time
while not self._stop.is_set(): while not self._stop.is_set():
now = time.time() now = time.time()
try: try:
@@ -550,10 +617,13 @@ class GyroPulseController:
self._t_pulse_end = now + self.pulse_sec self._t_pulse_end = now + self.pulse_sec
self._normal_count = 0 self._normal_count = 0
self.state = _GyroState.PULSE_ON self.state = _GyroState.PULSE_ON
self._last_trigger = trigger
if trigger: if trigger:
s, t, se = trigger s, t, se = trigger
log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)", 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") 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: elif self.state == _GyroState.PULSE_ON:
if not active: if not active:
@@ -563,14 +633,11 @@ class GyroPulseController:
self.state = _GyroState.IDLE self.state = _GyroState.IDLE
self._normal_count = 0 self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)") log.info("Gyro → OFF (retour à la normale confirmé)")
# SMS OK immédiat
self._send_ok_sms_from_last_trigger()
else: else:
self._normal_count = 0 self._normal_count = 0
# --- MODE CONTINU par défaut --- if os.getenv("GYRO_MODE_CONTINUOUS", "1") != "1":
if os.getenv("GYRO_MODE_CONTINUOUS", "1") == "1":
# Rester ON tant que lalerte persiste
pass
else:
# --- MODE PULSE ---
if now >= self._t_pulse_end: if now >= self._t_pulse_end:
self._set_gyro(False) self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec self._t_cooldown_end = now + self.cooldown_sec
@@ -599,63 +666,73 @@ notifier = Notifier()
beacon = MQTTPublisher(SITE) 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) """
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) notifier.send_email(subject, email_body)
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) """
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 ========= # ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE): def run_monitor_cycle(site: str = SITE):
# 1) Lecture dernières mesures + config chambres # 1) Lecture dernières mesures + config
last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}] last_rows = lire_sondes_depuis_db(site)
cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}} 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: try:
gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0"))) gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
if trigger: if trigger:
s, t, se = 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: else:
log.info("Aucun dépassement au moment du cycle") log.info("Aucun dépassement au moment du cycle")
except Exception as e: except Exception as e:
log.exception("Erreur calcul alarme (info): %s", 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)} seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows: for r in last_rows:
nom = str(r["Sonde"]) nom = str(r["Sonde"])
temp = float(r["Temperature"]) temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) seuil = float(seuils.get(nom, 6.0))
now = now_paris() now = now_paris()
if temp > seuil: if temp > seuil:
if depassement_depuis_30min(site, nom, seuil): if depassement_depuis_30min(site, nom, seuil):
try: try:
conn = get_db() conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now): 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: finally:
conn.close() conn.close()
else: else:
try: try:
conn = get_db() conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom): if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil) notifier_acquittement(site, nom, temp, seuil) # MAIL acquittement
finally: finally:
conn.close() 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)
# Démarrage du contrôleur gyro rapide (thread) # Démarrage du contrôleur gyro rapide (thread) + notifier pour SMS immédiats
try: try:
global _gyro_controller global _gyro_controller
_gyro_controller = GyroPulseController(site, beacon) _gyro_controller = GyroPulseController(site, beacon, notifier)
_gyro_controller.start() _gyro_controller.start()
except Exception as e: except Exception as e:
log.exception("Impossible de démarrer le GyroPulseController: %s", e) log.exception("Impossible de démarrer le GyroPulseController: %s", e)
@@ -681,7 +758,8 @@ if __name__ == "__main__":
args = p.parse_args() args = p.parse_args()
if args.test_sms: if args.test_sms:
notifier.send_sms("TEST DOMO91 (transactionnel)") n = Notifier()
n.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail: elif args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK") notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert: elif args.test_alert:

View File

@@ -1,9 +1,28 @@
import os import os
import ovh import ovh
from dotenv import load_dotenv from dotenv import load_dotenv
import unicodedata, re
load_dotenv() load_dotenv()
def normaliser_sms(message: str, prefix: str = "") -> str:
REPL = {"°": "C", "": "'", "": '"', "": '"', "": "-", "": "-", "": "..."}
for k, v in REPL.items():
message = message.replace(k, v)
message = unicodedata.normalize("NFKD", message).encode("ascii", "ignore").decode("ascii")
rules = [
(r"\bTemperature\b", "T"),
(r"\bTemp[ée]rature\b", "T"),
(r"\bSeuil\b", "S"),
(r"\bHeure\b", "H"),
(r"\s{2,}", " "),
]
for pat, repl in rules:
message = re.sub(pat, repl, message, flags=re.IGNORECASE)
if prefix:
message = f"[{prefix}] {message}"
return message[:160]
def envoyer_sms(message: str, lieu: str = ""): def envoyer_sms(message: str, lieu: str = ""):
try: try:
client = ovh.Client( client = ovh.Client(
@@ -28,7 +47,7 @@ def envoyer_sms(message: str, lieu: str = ""):
if not numero_dest or not numero_dest.isdigit(): if not numero_dest or not numero_dest.isdigit():
print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True) print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True)
return return
message = normaliser_sms(message, prefix=lieu)
payload = { payload = {
"sender": sender, "sender": sender,
"receivers": [numero_dest], "receivers": [numero_dest],