Révision des codes alertes
This commit is contained in:
19
.env
19
.env
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# OVH_SMS_SENDER=DOMO91FR
|
# OVH_SMS_SENDER=DOMO91FR
|
||||||
#connexion mysql
|
#connexion mysql
|
||||||
DB_HOST=162.19.78.131
|
DB_HOST=162.19.78.131
|
||||||
@@ -6,10 +7,15 @@ DB_PASS=TX.)-U1!zq5Axdk4
|
|||||||
DB_NAME=Sondes
|
DB_NAME=Sondes
|
||||||
|
|
||||||
# MQTT
|
# MQTT
|
||||||
GYRO_MODE=mqtt
|
|
||||||
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
|
||||||
|
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_SACLAY=Saclay/gyrophare
|
||||||
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
|
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
|
||||||
|
|
||||||
@@ -20,7 +26,7 @@ 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,nicolas.thibaut@bw-paris-saclay.com,
|
MAIL_TO_SACLAY=robots@domo91.fr
|
||||||
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
|
||||||
MAIL_FROM_MEUDON="DOMO91 Meudon <services@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_SACLAY==Michel:+33635164680
|
||||||
ALERT_SMS_TO_MEUDON=Michel:+33635164680
|
ALERT_SMS_TO_MEUDON=Michel:+33635164680
|
||||||
|
|
||||||
RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
|
# --- Réserves destinataires ----
|
||||||
RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248
|
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
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ def lire_cfg_chambres(site: str):
|
|||||||
cfg: dict[str, dict] = {}
|
cfg: dict[str, dict] = {}
|
||||||
try:
|
try:
|
||||||
cur = cnx.cursor()
|
cur = cnx.cursor()
|
||||||
cur.execute(sql, (site,))
|
cur.execute(sql, (site, ))
|
||||||
for sonde, temp_max, etat, en_entretien in cur.fetchall():
|
for sonde, temp_max, etat, en_entretien in cur.fetchall():
|
||||||
cfg[str(sonde)] = {
|
cfg[str(sonde)] = {
|
||||||
"temp_max": float(temp_max),
|
"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"]:
|
if not meta or not meta["active"] or meta["entretien"]:
|
||||||
continue
|
continue
|
||||||
temp = float(row["Temperature"])
|
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 True, (sonde, temp, float(meta["temp_max"]))
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ def lire_seuils_depuis_db(site: str):
|
|||||||
seuils = {}
|
seuils = {}
|
||||||
try:
|
try:
|
||||||
cur = cnx.cursor()
|
cur = cnx.cursor()
|
||||||
cur.execute(sql, (site,))
|
cur.execute(sql, (site, ))
|
||||||
for sonde, s in cur.fetchall():
|
for sonde, s in cur.fetchall():
|
||||||
seuils[str(sonde)] = float(s)
|
seuils[str(sonde)] = float(s)
|
||||||
return seuils
|
return seuils
|
||||||
@@ -190,7 +190,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
|
|||||||
WHERE Sonde=%s
|
WHERE Sonde=%s
|
||||||
ORDER BY Date DESC
|
ORDER BY Date DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", (sonde,))
|
""", (sonde, ))
|
||||||
last = cur.fetchone()
|
last = cur.fetchone()
|
||||||
if not last:
|
if not last:
|
||||||
return False
|
return False
|
||||||
@@ -229,6 +229,7 @@ def any_alert_open(site: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
cnx.close()
|
cnx.close()
|
||||||
|
|
||||||
# ========= Helpers listes/numéros =========
|
# ========= Helpers listes/numéros =========
|
||||||
def _split_list(raw: str | None) -> list[str]:
|
def _split_list(raw: str | None) -> list[str]:
|
||||||
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
|
return [x.strip() for x in re.split(r"[;,]", raw or "") if x.strip()]
|
||||||
@@ -401,18 +402,18 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat
|
|||||||
# ========= Gyrophare MQTT =========
|
# ========= Gyrophare MQTT =========
|
||||||
class MQTTPublisher:
|
class MQTTPublisher:
|
||||||
def __init__(self, site: str):
|
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.site = site
|
||||||
self.topic = (
|
self.topic = (
|
||||||
os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
|
os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
|
||||||
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
|
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
|
||||||
os.getenv("GYRO_MQTT_TOPIC") or
|
os.getenv("GYRO_MQTT_TOPIC") or
|
||||||
f"Sondes/{site}/Gyro/cmd"
|
f"Sondes/{site}/Gyro/cmd"
|
||||||
)
|
)
|
||||||
self.last_state: bool | None = None
|
self.last_state: bool | None = None
|
||||||
|
|
||||||
if not self.enabled:
|
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
|
return
|
||||||
if not self.topic:
|
if not self.topic:
|
||||||
log.warning("Topic MQTT manquant pour %s (GYRO_MQTT_TOPIC_%s)", site, site)
|
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 ---
|
# --- Création du client MQTT : compatible paho 1.x et 2.x ---
|
||||||
cbver = getattr(mqtt, "CallbackAPIVersion", None)
|
cbver = getattr(mqtt, "CallbackAPIVersion", None)
|
||||||
if cbver is not None:
|
if cbver is not None:
|
||||||
# paho >= 2.x : on choisit la meilleure constante disponible
|
|
||||||
api_v = (
|
api_v = (
|
||||||
getattr(cbver, "VERSION2", None) # paho 2.x
|
getattr(cbver, "VERSION2", None)
|
||||||
or getattr(cbver, "V5", None) # certaines builds
|
or getattr(cbver, "V5", None)
|
||||||
or getattr(cbver, "v5", None) # fallback
|
or getattr(cbver, "v5", None)
|
||||||
or getattr(cbver, "V311", None) # dernier recours
|
or getattr(cbver, "V311", None)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client()
|
self.client = mqtt.Client(callback_api_version=api_v) if api_v else mqtt.Client()
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# vieux paho ne supporte pas l’argument callback_api_version
|
|
||||||
self.client = mqtt.Client()
|
self.client = mqtt.Client()
|
||||||
else:
|
else:
|
||||||
# paho 1.x
|
|
||||||
self.client = mqtt.Client()
|
self.client = mqtt.Client()
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -476,6 +474,125 @@ 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) =========
|
||||||
|
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 l’alarme persiste, OFF rapide si retour normal.
|
||||||
|
- MODE PULSE : IDLE → PULSE_ON (ON PULSE_SEC) → COOLDOWN (OFF COOLDOWN_SEC) → re-PULSE tant que l’alarme 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 l’alerte 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 d’alerte)")
|
||||||
|
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 =========
|
# ========= Notifs haut-niveau =========
|
||||||
notifier = Notifier()
|
notifier = Notifier()
|
||||||
@@ -496,20 +613,18 @@ def run_monitor_cycle(site: str = SITE):
|
|||||||
last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}]
|
last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}]
|
||||||
cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}}
|
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:
|
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("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:
|
else:
|
||||||
log.info("Gyro %s => OFF (aucun dépassement)", site)
|
log.info("Aucun dépassement au moment du cycle")
|
||||||
beacon.set(gyro_on)
|
|
||||||
except Exception as e:
|
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
|
# 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)}
|
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:
|
||||||
@@ -520,7 +635,6 @@ def run_monitor_cycle(site: str = SITE):
|
|||||||
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):
|
||||||
# Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle
|
|
||||||
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):
|
||||||
@@ -528,7 +642,6 @@ def run_monitor_cycle(site: str = SITE):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
else:
|
else:
|
||||||
# Fermer si ouvert → notifier seulement si fermeture réelle
|
|
||||||
try:
|
try:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
if close_alert(conn, f"Alertes_{site}", nom):
|
if close_alert(conn, f"Alertes_{site}", nom):
|
||||||
@@ -536,10 +649,17 @@ def run_monitor_cycle(site: str = SITE):
|
|||||||
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)
|
||||||
|
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:
|
while True:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -560,10 +680,16 @@ if __name__ == "__main__":
|
|||||||
p.add_argument("--once", action="store_true")
|
p.add_argument("--once", action="store_true")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
if args.test_sms: notifier.send_sms("TEST DOMO91 (transactionnel)")
|
if args.test_sms:
|
||||||
elif args.test_mail: notifier.send_email(f"[TEST {SITE}] Mail", "OK")
|
notifier.send_sms("TEST DOMO91 (transactionnel)")
|
||||||
elif args.test_alert: notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
|
elif args.test_mail:
|
||||||
elif args.test_ok: notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
|
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:
|
else:
|
||||||
if args.once: run_monitor_cycle(SITE)
|
if args.once:
|
||||||
else: run_monitor_loop(SITE, period_sec=args.period)
|
run_monitor_cycle(SITE)
|
||||||
|
else:
|
||||||
|
run_monitor_loop(SITE, period_sec=args.period)
|
||||||
|
|||||||
165
app/gyro_control.py
Normal file
165
app/gyro_control.py
Normal 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)
|
||||||
@@ -34,7 +34,7 @@ SERVICE_NAME = os.getenv('OVH_SMS_SERVICE')
|
|||||||
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
|
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
|
||||||
SMS_SENDER = os.getenv('OVH_SMS_SENDER')
|
SMS_SENDER = os.getenv('OVH_SMS_SENDER')
|
||||||
|
|
||||||
tables = ['Saclay', 'Meudon', 'Chaufferie']
|
tables = ['Saclay', 'Meudon']
|
||||||
DELAI_MINUTES = 15
|
DELAI_MINUTES = 15
|
||||||
RAPPEL_HEURES = 6
|
RAPPEL_HEURES = 6
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user