Règlages des alertes dans Monitor_Meudon
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ========= Site =========
|
||||
SITE = "Meudon"
|
||||
PROGRAM_NAME = f"Monitor_{SITE}"
|
||||
@@ -11,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:
|
||||
@@ -34,15 +38,16 @@ except Exception:
|
||||
_mqtt_ok = False
|
||||
|
||||
# ========= Logger =========
|
||||
level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO)
|
||||
log = logging.getLogger(PROGRAM_NAME.lower())
|
||||
if not log.handlers:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
# ========= DB utils =========
|
||||
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 & SMS client).
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
@@ -51,20 +56,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(
|
||||
@@ -76,10 +80,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"),
|
||||
@@ -87,7 +91,6 @@ def get_db():
|
||||
port=int(os.getenv("DB_PORT", "3306")),
|
||||
autocommit=True,
|
||||
)
|
||||
return cnx
|
||||
|
||||
def lire_sondes_depuis_db(site: str):
|
||||
table = site
|
||||
@@ -114,29 +117,9 @@ def lire_sondes_depuis_db(site: str):
|
||||
finally:
|
||||
cnx.close()
|
||||
|
||||
def lire_seuils_depuis_db(site: str):
|
||||
sql = """
|
||||
SELECT Sonde, Temp_Max
|
||||
FROM Chambres_froides
|
||||
WHERE Lieu=%s AND Etat='ON'
|
||||
"""
|
||||
cnx = get_db()
|
||||
seuils = {}
|
||||
try:
|
||||
cur = cnx.cursor()
|
||||
cur.execute(sql, (site,))
|
||||
for sonde, s in cur.fetchall():
|
||||
seuils[str(sonde)] = float(s)
|
||||
return seuils
|
||||
except MySQLError as err:
|
||||
log.exception("Erreur DB (lire_seuils_depuis_db): %s", err)
|
||||
return seuils
|
||||
finally:
|
||||
cnx.close()
|
||||
|
||||
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,10 +128,10 @@ def lire_cfg_chambres(site: str):
|
||||
WHERE Lieu=%s
|
||||
"""
|
||||
cnx = get_db()
|
||||
cfg = {}
|
||||
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),
|
||||
@@ -164,7 +147,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: (sonde, temp, seuil) | None)
|
||||
Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None)
|
||||
"""
|
||||
for row in last_values:
|
||||
sonde = str(row["Sonde"])
|
||||
@@ -172,61 +155,80 @@ 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
|
||||
|
||||
def lire_seuils_depuis_db(site: str):
|
||||
sql = """
|
||||
SELECT Sonde, Temp_Max
|
||||
FROM Chambres_froides
|
||||
WHERE Lieu=%s AND Etat='ON'
|
||||
"""
|
||||
cnx = get_db()
|
||||
seuils = {}
|
||||
try:
|
||||
cur = cnx.cursor()
|
||||
cur.execute(sql, (site, ))
|
||||
for sonde, s in cur.fetchall():
|
||||
seuils[str(sonde)] = float(s)
|
||||
return seuils
|
||||
except MySQLError as err:
|
||||
log.exception("Erreur DB (lire_seuils_depuis_db): %s", err)
|
||||
return seuils
|
||||
finally:
|
||||
cnx.close()
|
||||
|
||||
# --- Dépassement continu (configurable) ---
|
||||
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
|
||||
"""
|
||||
True si la sonde est > seuil de façon CONTINUE depuis CONT_MIN minutes.
|
||||
CONT_MIN = ALERT_CONTINUOUS_MINUTES (defaut 30)
|
||||
LOOKBACK = ALERT_LOOKBACK_MINUTES (defaut max(60, CONT_MIN*3))
|
||||
"""
|
||||
CONT_MIN = int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))
|
||||
LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, int(os.getenv("ALERT_CONTINUOUS_MINUTES", "30"))*3))))
|
||||
|
||||
table = site
|
||||
cnx = get_db()
|
||||
try:
|
||||
cur = cnx.cursor()
|
||||
|
||||
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, LOOKBACK))
|
||||
rows = cur.fetchall()
|
||||
if not rows:
|
||||
return False
|
||||
last_temp, last_date = float(last[0]), last[1]
|
||||
|
||||
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
|
||||
# Début de la séquence continue > seuil
|
||||
start_dt = last_dt
|
||||
for temp, d in rows[1:]:
|
||||
if float(temp) > float(seuil):
|
||||
start_dt = d
|
||||
else:
|
||||
break
|
||||
|
||||
tzinfo = getattr(start_dt, "tzinfo", None)
|
||||
now = dt.datetime.now(tz=tzinfo)
|
||||
dur_min = (now - start_dt).total_seconds() / 60.0
|
||||
log.debug("Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d",
|
||||
sonde, start_dt, now, dur_min, CONT_MIN)
|
||||
return dur_min >= CONT_MIN
|
||||
|
||||
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()
|
||||
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()
|
||||
# ========= 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()]
|
||||
@@ -234,7 +236,7 @@ def _split_list(raw: str | None) -> list[str]:
|
||||
def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
|
||||
out: list[tuple[str, str]] = []
|
||||
for tok in re.split(r"[;,]", raw or ""):
|
||||
tok = tok.strip()
|
||||
tok = tok.strip().strip('"').strip("'")
|
||||
if not tok:
|
||||
continue
|
||||
if ":" in tok:
|
||||
@@ -254,7 +256,7 @@ def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
|
||||
def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
|
||||
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
|
||||
|
||||
# ========= Notifier (SMS + Mail) =========
|
||||
# ========= Notifier (SMS interne + SMS client + Mail) =========
|
||||
class Notifier:
|
||||
def __init__(self):
|
||||
# OVH SMS
|
||||
@@ -270,11 +272,22 @@ class Notifier:
|
||||
)
|
||||
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
|
||||
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"))
|
||||
raw_sms = (os.getenv(f"ALERT_SMS_TO_{SITE}") or os.getenv(f"ALERT_SMS_TO_{SITE.upper()}") or os.getenv("ALERT_SMS_TO"))
|
||||
self.sms_labeled = _parse_labeled_phones(raw_sms)
|
||||
else:
|
||||
self.sms_labeled = []
|
||||
|
||||
# SMS CLIENTS (site-spécifique + génériques + compat FR)
|
||||
raw_sms_client = (
|
||||
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or
|
||||
os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or
|
||||
os.getenv("ALERT_SMS_CLIENT_TO") or
|
||||
os.getenv(f"ALERTE_CLIENT_{SITE}") or
|
||||
os.getenv("ALERTE_CLIENT")
|
||||
)
|
||||
self.sms_client_labeled = _parse_labeled_phones(raw_sms_client)
|
||||
self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
|
||||
|
||||
# SMTP
|
||||
self.smtp_host = os.getenv("SMTP_HOST")
|
||||
self.smtp_port = int(os.getenv("SMTP_PORT","465"))
|
||||
@@ -282,9 +295,9 @@ class Notifier:
|
||||
self.smtp_pass = os.getenv("SMTP_PASS")
|
||||
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
|
||||
|
||||
raw_mail_to = (os.getenv("MAIL_TO_Saclay") or os.getenv("MAIL_TO_SACLAY") or os.getenv("MAIL_TO") or "")
|
||||
raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "")
|
||||
self.mail_to = _split_list(raw_mail_to)
|
||||
self.mail_from = (os.getenv("MAIL_FROM_Saclay") or os.getenv("MAIL_FROM_SACLAY") or os.getenv("MAIL_FROM") or self.smtp_user)
|
||||
self.mail_from = (os.getenv(f"MAIL_FROM_{SITE}") or os.getenv(f"MAIL_FROM_{SITE.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])
|
||||
|
||||
@@ -297,10 +310,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",
|
||||
@@ -330,6 +346,45 @@ class Notifier:
|
||||
except Exception as err:
|
||||
log.exception("Echec envoi SMS OVH: %s", err); return False
|
||||
|
||||
def send_sms_client(self, message: str, tag: str = f"monitor-client-{SITE.lower()}") -> bool:
|
||||
if not self.ovh_enabled:
|
||||
log.warning("SMS client: OVH non configuré."); return False
|
||||
if not self.sms_client_enabled or not self.sms_client_labeled:
|
||||
log.info("SMS client: désactivé ou aucun destinataire."); return False
|
||||
|
||||
only = os.getenv("ALERT_SMS_CLIENT_ONLY")
|
||||
if only:
|
||||
allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()}
|
||||
labeled = [(n, p) for (n, p) in self.sms_client_labeled if (n and n in allow) or (p in allow)]
|
||||
else:
|
||||
labeled = self.sms_client_labeled
|
||||
|
||||
receivers = [num for (_n, num) in labeled]
|
||||
if not receivers:
|
||||
log.info("SMS client: filtre vide → aucun envoi."); return False
|
||||
|
||||
message = normaliser_sms(message, prefix=SITE)
|
||||
|
||||
payload = {
|
||||
"sender": self.ovh_sender,
|
||||
"receivers": receivers,
|
||||
"message": message,
|
||||
"priority": "high",
|
||||
"coding": "7bit",
|
||||
"class": "phoneDisplay",
|
||||
"noStopClause": True,
|
||||
"senderForResponse": False,
|
||||
"validityPeriod": 2880,
|
||||
"tag": tag,
|
||||
}
|
||||
try:
|
||||
log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled))
|
||||
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
|
||||
log.info("SMS CLIENT OVH envoyé (job ids=%s)", resp.get("ids"))
|
||||
return True
|
||||
except Exception as err:
|
||||
log.exception("Echec SMS CLIENT OVH: %s", err); return False
|
||||
|
||||
def send_email(self, subject: str, body: str) -> bool:
|
||||
if not self.smtp_enabled:
|
||||
log.warning("SMTP non configuré, email non envoyé."); return False
|
||||
@@ -385,32 +440,48 @@ 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
|
||||
|
||||
def build_client_alert_sms(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
|
||||
when = when or now_paris()
|
||||
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}"
|
||||
|
||||
# ========= 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)
|
||||
@@ -423,25 +494,20 @@ class MQTTPublisher:
|
||||
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
|
||||
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 l’argument 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)
|
||||
@@ -461,7 +527,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,68 +539,251 @@ class MQTTPublisher:
|
||||
except Exception as e:
|
||||
log.exception("MQTT publish erreur: %s", e)
|
||||
|
||||
# ========= Contrôleur Gyro réactif =========
|
||||
import enum, threading
|
||||
|
||||
class _GyroState(enum.Enum):
|
||||
IDLE = 0
|
||||
PULSE_ON = 1
|
||||
COOLDOWN = 2
|
||||
|
||||
class GyroPulseController:
|
||||
"""
|
||||
Boucle rapide indépendante :
|
||||
- MODE CONTINU (défaut) : ON tant que l’alarme persiste, OFF quand normal confirmé.
|
||||
- MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que l’alarme persiste.
|
||||
|
||||
Ajouts :
|
||||
- SMS ALERTE immédiat à l’allumage
|
||||
- SMS OK immédiat à l’extinction (activé par défaut)
|
||||
"""
|
||||
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
|
||||
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 effectif
|
||||
|
||||
# Anti-spam SMS & SMS OK activé par défaut
|
||||
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:
|
||||
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 _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]:
|
||||
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):
|
||||
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
|
||||
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:
|
||||
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é)")
|
||||
# SMS OK immédiat
|
||||
self._send_ok_sms_from_last_trigger()
|
||||
else:
|
||||
self._normal_count = 0
|
||||
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
|
||||
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 =========
|
||||
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)
|
||||
notifier.send_email(subject, email_body)
|
||||
"""
|
||||
MAIL quand l'alerte est confirmée (≥30 min) et ouverte en base.
|
||||
+ SMS CLIENT couplé (ALERTE_CLIENT_{SITE}).
|
||||
(Le SMS d'alerte interne est envoyé immédiatement par la boucle gyro.)
|
||||
"""
|
||||
subject, _sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
|
||||
notifier.send_email(subject, email_body) # MAIL (≥30 min)
|
||||
|
||||
# ➕ SMS client couplé au mail 30 min
|
||||
if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1":
|
||||
client_msg = build_client_alert_sms(site, sonde, temp, seuil)
|
||||
notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}")
|
||||
|
||||
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 l’alerte est acquittée en base.
|
||||
(Le SMS "OK" est envoyé immédiatement 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
|
||||
if os.getenv("ALERT_OK_SMS", "0") == "1":
|
||||
notifier.send_sms(sms_text)
|
||||
|
||||
# ========= Cycle & boucle =========
|
||||
def run_monitor_cycle(site: str = SITE):
|
||||
# 1) Lecture mesures + config
|
||||
sondes = lire_sondes_depuis_db(site)
|
||||
cfg = lire_cfg_chambres(site)
|
||||
# 1) Lecture dernières mesures + config
|
||||
last_rows = lire_sondes_depuis_db(site)
|
||||
cfg = lire_cfg_chambres(site)
|
||||
|
||||
# 2) Gyro instantané
|
||||
# 2) Info: état instantané (le gyro est piloté par la boucle rapide)
|
||||
try:
|
||||
gyro_on, trigger = compute_site_alarm(sondes, 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:
|
||||
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é (gyro géré 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 à partir de la cfg (et on ignore les sondes OFF).
|
||||
# 3) Alertes "officielles" temporisées (≥30 min) → mail + SMS client
|
||||
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
|
||||
|
||||
for r in sondes:
|
||||
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):
|
||||
# Ouvrir si pas déjà ouverte → notifier seulement si création
|
||||
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 + SMS client
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# Fermer si ouverte → notifier seulement si fermeture réelle
|
||||
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) + notifier pour SMS immédiats
|
||||
try:
|
||||
global _gyro_controller
|
||||
_gyro_controller = GyroPulseController(site, beacon, notifier)
|
||||
_gyro_controller.start()
|
||||
except Exception as e:
|
||||
log.exception("Impossible de démarrer le GyroPulseController: %s", e)
|
||||
|
||||
while True:
|
||||
t0 = time.time()
|
||||
try:
|
||||
@@ -556,10 +804,17 @@ 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:
|
||||
n = Notifier()
|
||||
n.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, "Chambre_N1", -14.5, -15.0)
|
||||
elif args.test_ok:
|
||||
notifier_acquittement(SITE, "Chambre_N1", -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)
|
||||
|
||||
Reference in New Issue
Block a user