Révision des codes alertes

This commit is contained in:
2025-09-22 15:02:35 +02:00
parent bb461a2ed1
commit 9131758db7
4 changed files with 339 additions and 39 deletions

19
.env
View File

@@ -1,3 +1,4 @@
# OVH_SMS_SENDER=DOMO91FR
#connexion mysql
DB_HOST=162.19.78.131
@@ -6,10 +7,15 @@ DB_PASS=TX.)-U1!zq5Axdk4
DB_NAME=Sondes
# MQTT
GYRO_MODE=mqtt
MQTT_HOST=54.36.188.119
MQTT_USER=Bwps
MQTT_PASS=scJ5ACj2keRfI^
MQTT_PASS="scJ5ACj2keRfI^"
# Boucle rapide du gyro
GYRO_MODE=mqtt
GYRO_CHECK_SEC=20
GYRO_NORMAL_CONFIRM=2
GYRO_MODE_CONTINUOUS=1
GYRO_HYSTERESIS=0.3
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
@@ -20,7 +26,7 @@ SMTP_SECURITY=STARTTLS
SMTP_USER=services@domo91.fr
SMTP_PASS=6ZiCsVtSf9@nEHv@$^0
MAIL_FROM=services@domo91.fr
MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com,
MAIL_TO_SACLAY=robots@domo91.fr
MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>"
MAIL_TO_MEUDON=robots@domo91.fr
MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>"
@@ -36,5 +42,8 @@ SMS_RECEIVER=+33635164680
ALERT_SMS_TO_SACLAY==Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680
RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248
# --- Réserves destinataires ----
SMS_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
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

@@ -129,7 +129,7 @@ def lire_cfg_chambres(site: str):
cfg: dict[str, dict] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
cur.execute(sql, (site, ))
for sonde, temp_max, etat, en_entretien in cur.fetchall():
cfg[str(sonde)] = {
"temp_max": float(temp_max),
@@ -154,7 +154,7 @@ def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis
if not meta or not meta["active"] or meta["entretien"]:
continue
temp = float(row["Temperature"])
if temp > float(meta["temp_max"]) + 0.0:
if temp > float(meta["temp_max"]) + float(hysteresis):
return True, (sonde, temp, float(meta["temp_max"]))
return False, None
@@ -168,7 +168,7 @@ def lire_seuils_depuis_db(site: str):
seuils = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
cur.execute(sql, (site, ))
for sonde, s in cur.fetchall():
seuils[str(sonde)] = float(s)
return seuils
@@ -190,7 +190,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
WHERE Sonde=%s
ORDER BY Date DESC
LIMIT 1
""", (sonde,))
""", (sonde, ))
last = cur.fetchone()
if not last:
return False
@@ -229,6 +229,7 @@ def any_alert_open(site: str) -> bool:
return False
finally:
cnx.close()
# ========= Helpers listes/numéros =========
def _split_list(raw: str | None) -> list[str]:
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
@@ -401,18 +402,18 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt"))
self.enabled = bool(_mqtt_ok)
self.site = site
self.topic = (
os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
os.getenv("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd"
os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
os.getenv("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None
if not self.enabled:
log.info("Gyro MQTT désactivé (GYRO_MODE != mqtt ou paho-mqtt absent).")
log.info("Gyro MQTT désactivé (paho-mqtt absent).")
return
if not self.topic:
log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site)
@@ -428,20 +429,17 @@ class MQTTPublisher:
# --- 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
getattr(cbver, "VERSION2", None)
or getattr(cbver, "V5", None)
or getattr(cbver, "v5", None)
or getattr(cbver, "V311", None)
)
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()
# ------------------------------------------------------------
@@ -476,6 +474,125 @@ class MQTTPublisher:
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
# ========= Contrôleur Gyro réactif (pulse/cooldown ou continu) =========
import enum, threading
class _GyroState(enum.Enum):
IDLE = 0
PULSE_ON = 1
COOLDOWN = 2
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.
"""
def __init__(self, site: str, beacon, *,
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.check_sec = check_sec
self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec
self.normal_confirm = normal_confirm
self.state = _GyroState.IDLE
self._t_pulse_end = 0.0
self._t_cooldown_end = 0.0
self._normal_count = 0
self._stop = threading.Event()
self._thread = None
self._current = None # dernier état envoyé pour éviter les doublons
def _set_gyro(self, on: bool):
if self._current is not on:
self.beacon.set(on)
self._current = on
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info("GyroPulseController démarré (site=%s, check=%ss, pulse=%ss, cooldown=%ss, confirm=%d)",
self.site, self.check_sec, self.pulse_sec, self.cooldown_sec, self.normal_confirm)
def stop(self):
self._stop.set()
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}}
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:
active, trigger = self._is_alarm_now()
except Exception as e:
log.exception("Gyro fast-loop: erreur lecture état: %s", e)
active, trigger = (False, None)
if self.state == _GyroState.IDLE:
if active:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self._normal_count = 0
self.state = _GyroState.PULSE_ON
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")
elif self.state == _GyroState.PULSE_ON:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self._set_gyro(False)
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)")
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 now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
self.state = _GyroState.COOLDOWN
log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec)
elif self.state == _GyroState.COOLDOWN:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro: retour IDLE (plus dalerte)")
else:
self._normal_count = 0
if now >= self._t_cooldown_end:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self.state = _GyroState.PULSE_ON
log.info("Gyro → ON (re-pulse)")
time.sleep(self.check_sec)
# ========= Notifs haut-niveau =========
notifier = Notifier()
@@ -496,20 +613,18 @@ def run_monitor_cycle(site: str = SITE):
last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}]
cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}}
# 2) Gyro instantané : ON si >=1 sonde active & non en entretien dépasse son seuil
# 2) Gyro géré par le contrôleur rapide → ici, on ne touche plus au gyro
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("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se)
log.info("Dépassement détecté (pilotage gyro par boucle rapide) : %s %.2f > %.2f", s, t, se)
else:
log.info("Gyro %s => OFF (aucun dépassement)", site)
beacon.set(gyro_on)
log.info("Aucun dépassement au moment du cycle")
except Exception as e:
log.exception("Erreur calcul/publish gyrophare: %s", e)
log.exception("Erreur calcul alarme (info): %s", e)
# 3) Alertes "officielles" (inchangées) avec temporisation 30 min
# On reconstitue un dict seuils pour réutiliser ta logique existante en dessous.
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows:
@@ -520,7 +635,6 @@ def run_monitor_cycle(site: str = SITE):
now = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
# Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
@@ -528,7 +642,6 @@ def run_monitor_cycle(site: str = SITE):
finally:
conn.close()
else:
# Fermer si ouvert → notifier seulement si fermeture réelle
try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
@@ -536,10 +649,17 @@ def run_monitor_cycle(site: str = SITE):
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)
try:
global _gyro_controller
_gyro_controller = GyroPulseController(site, beacon)
_gyro_controller.start()
except Exception as e:
log.exception("Impossible de démarrer le GyroPulseController: %s", e)
while True:
t0 = time.time()
try:
@@ -560,10 +680,16 @@ if __name__ == "__main__":
p.add_argument("--once", action="store_true")
args = p.parse_args()
if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
if args.test_sms:
notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
else:
if args.once: run_monitor_cycle(SITE)
else: run_monitor_loop(SITE, period_sec=args.period)
if args.once:
run_monitor_cycle(SITE)
else:
run_monitor_loop(SITE, period_sec=args.period)

165
app/gyro_control.py Normal file
View File

@@ -0,0 +1,165 @@
# gyro_control.py
import os, time, enum, logging, threading
import mysql.connector # pip install mysql-connector-python
import paho.mqtt.client as mqtt # pip install paho-mqtt
log = logging.getLogger("gyro")
# Paramètres par défaut (surclassables via env ou arguments)
DEF_CHECK_SEC = int(os.getenv("GYRO_CHECK_SEC", "20"))
DEF_PULSE_SEC = int(os.getenv("GYRO_PULSE_SEC", "60"))
DEF_COOLDOWN_SEC = int(os.getenv("GYRO_COOLDOWN_SEC", "600"))
DEF_NORMAL_CONFIRM = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))
class GyroState(enum.Enum):
IDLE = 0
PULSE_ON = 1
COOLDOWN = 2
class MqttGyroDriver:
def __init__(self, host, port, user, password, topic_command):
self.topic_command = topic_command
self.client = mqtt.Client()
if user:
self.client.username_pw_set(user, password or "")
self.client.connect(host, int(port or 1883), keepalive=30)
self.client.loop_start()
def set(self, on: bool):
payload = "ON" if on else "OFF"
res = self.client.publish(self.topic_command, payload=payload, qos=1, retain=False)
res.wait_for_publish(timeout=5)
log.info("MQTT → %s : %s", self.topic_command, payload)
def close(self):
try:
self.client.loop_stop(); self.client.disconnect()
except Exception:
pass
class GyroController:
"""
Boucle indépendante et légère : lit l'état d'alerte en SQL et pulse le gyro via MQTT.
"""
def __init__(
self,
*,
site_name: str,
db_cfg: dict,
alertes_table: str,
mqtt_driver: MqttGyroDriver,
check_sec: int = DEF_CHECK_SEC,
pulse_sec: int = DEF_PULSE_SEC,
cooldown_sec: int = DEF_COOLDOWN_SEC,
normal_confirm: int = DEF_NORMAL_CONFIRM,
):
self.site = site_name
self.db_cfg = db_cfg
self.alertes_table = alertes_table
self.mqtt = mqtt_driver
self.check_sec = check_sec
self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec
self.normal_confirm = normal_confirm
self.state = GyroState.IDLE
self._t_pulse_end = 0.0
self._t_cooldown_end = 0.0
self._normal_count = 0
self._stop = threading.Event()
self._current_gyro_on = None
self._thread = None
# --- helpers ---
def _set_gyro(self, on: bool):
if self._current_gyro_on is not on:
self.mqtt.set(on)
self._current_gyro_on = on
def _has_active_alert(self, cur) -> bool:
cur.execute(f"SELECT COUNT(*) FROM `{self.alertes_table}` WHERE Etat='En cours'")
return cur.fetchone()[0] > 0
# --- lifecycle ---
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info("[%s] GyroController démarré (check=%ss, pulse=%ss, cooldown=%ss, confirm=%d)",
self.site, self.check_sec, self.pulse_sec, self.cooldown_sec, self.normal_confirm)
def stop(self):
self._stop.set()
# --- main loop ---
def _run(self):
# Ouverture connexion MySQL persistante
while not self._stop.is_set():
try:
cnx = mysql.connector.connect(autocommit=True, **self.db_cfg)
cur = cnx.cursor()
break
except Exception as e:
log.error("[%s] Connexion MySQL KO (%s). Retry 5s…", self.site, e)
time.sleep(5)
try:
while not self._stop.is_set():
now = time.time()
try:
active = self._has_active_alert(cur)
except Exception as e:
log.error("[%s] Lecture alertes KO: %s", self.site, e)
active = False # prudence
if self.state == GyroState.IDLE:
if active:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self.state = GyroState.PULSE_ON
self._normal_count = 0
log.info("[%s] Gyro ON (pulse %ss)", self.site, self.pulse_sec)
elif self.state == GyroState.PULSE_ON:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self._set_gyro(False)
self.state = GyroState.IDLE
self._normal_count = 0
log.info("[%s] Gyro OFF (retour à la normale)", self.site)
else:
self._normal_count = 0
if now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
self.state = GyroState.COOLDOWN
log.info("[%s] Gyro OFF → cooldown %ss", self.site, self.cooldown_sec)
elif self.state == GyroState.COOLDOWN:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self.state = GyroState.IDLE
self._normal_count = 0
log.info("[%s] Retour IDLE", self.site)
else:
self._normal_count = 0
if now >= self._t_cooldown_end:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self.state = GyroState.PULSE_ON
log.info("[%s] Gyro ON (re-pulse)", self.site)
time.sleep(self.check_sec)
finally:
try:
self._set_gyro(False)
except Exception:
pass
try:
cur.close(); cnx.close()
except Exception:
pass
log.info("[%s] GyroController stoppé", self.site)

View File

@@ -34,7 +34,7 @@ SERVICE_NAME = os.getenv('OVH_SMS_SERVICE')
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
SMS_SENDER = os.getenv('OVH_SMS_SENDER')
tables = ['Saclay', 'Meudon', 'Chaufferie']
tables = ['Saclay', 'Meudon']
DELAI_MINUTES = 15
RAPPEL_HEURES = 6