Ajout de chat a Monitor_Saclay

This commit is contained in:
2026-04-20 09:36:11 +02:00
parent 5f9d1c0911
commit f1203012df
7 changed files with 1520 additions and 272 deletions

View File

@@ -7,6 +7,8 @@ PROGRAM_NAME = f"Monitor_{SITE}"
# ========= Imports & .env =========
import os, re, time, ssl, smtplib, logging
from typing import Any, cast
import requests
import datetime as dt
from email.message import EmailMessage
from datetime import datetime
@@ -14,6 +16,7 @@ from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
from utils_sms import normaliser_sms
def _env_bool(name: str, default: bool) -> bool:
v = os.getenv(name, str(int(default))).strip().lower()
return v in ("1", "true", "yes", "on")
@@ -42,7 +45,7 @@ except Exception:
_mqtt_ok = False
# ========= Logger =========
level = getattr(logging, os.getenv("LOGLEVEL", "INFO").upper(), logging.INFO)
level = getattr(logging, (os.getenv("LOGLEVEL", "INFO") or "INFO").upper(), logging.INFO)
log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers:
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
@@ -86,6 +89,14 @@ def close_alert(conn, table_alertes: str, sonde: str) -> bool:
cur.close()
return changed
def _to_float(value: Any) -> float:
return float(cast(Any, value))
def _to_datetime(value: Any) -> datetime:
if isinstance(value, datetime):
return value
raise TypeError(f"datetime attendu, reçu: {type(value)!r}")
def get_db():
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
@@ -107,7 +118,7 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str,
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
lieu,
os.getenv("GYRO_SONDE_NAME", "Gyro"),
_env_str("GYRO_SONDE_NAME", "Gyro"),
etat, # 'ON' ou 'OFF'
when.strftime('%Y-%m-%d %H:%M:%S'),
topic,
@@ -135,7 +146,7 @@ def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool:
cnx.close()
# --- Lecture des dernières mesures de température (en ignorant lignes d'état) ---
def lire_sondes_depuis_db(site: str):
def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]:
table = site
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
@@ -152,9 +163,9 @@ def lire_sondes_depuis_db(site: str):
try:
cur = cnx.cursor(dictionary=True)
cur.execute(sql)
rows = cur.fetchall()
rows = cast(list[dict[str, Any]], cur.fetchall())
for r in rows:
r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL
r["Temperature"] = float(r["Temperature"])
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
@@ -163,19 +174,19 @@ def lire_sondes_depuis_db(site: str):
cnx.close()
def lire_cfg_chambres(site: str):
def lire_cfg_chambres(site: str) -> dict[str, dict[str, float | bool]]:
"""
Retourne {sonde: {"temp_max": float, "active": bool}}
depuis Chambres_froides pour le site.
"""
dbname = os.getenv("DB_NAME", "Sondes")
dbname = _env_str("DB_NAME", "Sondes")
sql = f"""
SELECT Sonde, Temp_Max, Etat
FROM `{dbname}`.`Chambres_froides`
WHERE Lieu=%s
"""
cnx = get_db()
cfg: dict[str, dict] = {}
cfg: dict[str, dict[str, float | bool]] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
@@ -191,7 +202,7 @@ def lire_cfg_chambres(site: str):
finally:
cnx.close()
def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0):
def compute_site_alarm(last_values: list[dict[str, Any]], cfg: dict[str, dict[str, float | bool]], hysteresis: float = 0.0) -> tuple[bool, tuple[str, float, float] | None]:
"""
Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None)
"""
@@ -200,20 +211,20 @@ def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis
meta = cfg.get(sonde)
if not meta or not meta.get("active", False):
continue
temp = float(row["Temperature"])
seuil = float(meta["temp_max"])
temp = _to_float(row["Temperature"])
seuil = _to_float(meta["temp_max"])
if temp > seuil + float(hysteresis):
return True, (sonde, temp, seuil)
return False, None
def lire_seuils_depuis_db(site: str):
def lire_seuils_depuis_db(site: str) -> dict[str, float]:
sql = """
SELECT Sonde, Temp_Max
FROM Sondes.Chambres_froides
WHERE Lieu=%s AND Etat='ON'
"""
cnx = get_db()
seuils = {}
seuils: dict[str, float] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site, ))
@@ -233,8 +244,8 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
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))))
cont_min = int(_env_str("ALERT_CONTINUOUS_MINUTES", "30"))
lookback = int(_env_str("ALERT_LOOKBACK_MINUTES", str(max(60, int(_env_str("ALERT_CONTINUOUS_MINUTES", "30"))*3))))
table = site
cnx = get_db()
@@ -246,20 +257,22 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC
""", (sonde, LOOKBACK))
""", (sonde, lookback))
rows = cur.fetchall()
if not rows:
return False
last_temp, last_dt = float(rows[0][0]), rows[0][1]
first_row = cast(tuple[Any, Any], rows[0])
last_temp = _to_float(first_row[0])
last_dt = _to_datetime(first_row[1])
if last_temp <= float(seuil):
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
for temp, row_dt in rows[1:]:
if _to_float(temp) > float(seuil):
start_dt = _to_datetime(row_dt)
else:
break
@@ -267,8 +280,8 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
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
sonde, start_dt, now, dur_min, cont_min)
return dur_min >= cont_min
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err)
@@ -294,57 +307,110 @@ def _parse_labeled_phones(raw: str | None) -> list[tuple[str, str]]:
return out
def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
only = os.getenv("ALERT_SMS_ONLY")
only = _env_str("ALERT_SMS_ONLY")
if not only:
return [num for (_n, num) in labeled]
allow = {x.strip() for x in re.split(r"[;,]", only) if x.strip()}
return [num for (name, num) in labeled if (name and name in allow) or (num in allow)]
def _human_labeled_list(labeled: list[tuple[str, str]]) -> str:
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
return ", ".join([f"{name}({phone})" if name else phone for name, phone in labeled])
# ========= Synology Chat =========
def _env_str(name: str, default: str = "") -> str:
return (os.getenv(name, default) or "").strip()
def synology_chat_enabled() -> bool:
return bool(
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK")
)
def send_synology_chat(message: str, *, username: str | None = None) -> bool:
"""
Envoie un message sur Synology Chat via webhook entrant.
Variables supportées :
- SYNO_CHAT_WEBHOOK_{SITE} ou SYNO_CHAT_WEBHOOK_{SITE.upper()}
- SYNO_CHAT_WEBHOOK
- SYNO_CHAT_BOTNAME (optionnel)
- SYNO_CHAT_TIMEOUT (optionnel, défaut 10s)
"""
webhook = (
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK")
)
if not webhook:
log.info("Synology Chat non configuré.")
return False
payload = {"text": message}
botname = username or _env_str("SYNO_CHAT_BOTNAME")
if botname:
payload["username"] = botname
timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10"))
try:
r = requests.post(webhook, json=payload, timeout=timeout)
r.raise_for_status()
# Certains webhooks Synology répondent "ok" ou JSON {success:true}
body = (r.text or "").strip().lower()
if body and body not in ("ok", '{"success":true}'):
log.info("Réponse Synology Chat: %s", r.text[:200])
log.info("Notification Synology Chat envoyée.")
return True
except requests.RequestException as e:
log.exception("Echec envoi Synology Chat: %s", e)
return False
# ========= Notifier (SMS interne + SMS client + Mail) =========
class Notifier:
def __init__(self):
# OVH SMS
self.ovh_enabled = _ovh_available and all(
os.getenv(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER")
_env_str(k) for k in ("OVH_APPLICATION_KEY","OVH_APPLICATION_SECRET","OVH_CONSUMER_KEY","OVH_SMS_SERVICE","OVH_SMS_SENDER")
)
self.ovh_client: Any | None = None
self.ovh_service: str = ""
self.ovh_sender: str = ""
if self.ovh_enabled:
assert ovh is not None
self.ovh_client = ovh.Client(
endpoint=os.getenv("OVH_ENDPOINT","ovh-eu"),
application_key=os.getenv("OVH_APPLICATION_KEY"),
application_secret=os.getenv("OVH_APPLICATION_SECRET"),
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
endpoint=_env_str("OVH_ENDPOINT", "ovh-eu"),
application_key=_env_str("OVH_APPLICATION_KEY"),
application_secret=_env_str("OVH_APPLICATION_SECRET"),
consumer_key=_env_str("OVH_CONSUMER_KEY"),
)
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
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.ovh_service = _env_str("OVH_SMS_SERVICE")
self.ovh_sender = _env_str("OVH_SMS_SENDER")
raw_sms = (_env_str(f"ALERT_SMS_TO_{SITE}") or _env_str(f"ALERT_SMS_TO_{SITE.upper()}") or _env_str("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")
_env_str(f"ALERT_SMS_CLIENT_TO_{SITE}") or
_env_str(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or
_env_str("ALERT_SMS_CLIENT_TO") or
_env_str(f"ALERTE_CLIENT_{SITE}") or
_env_str("ALERTE_CLIENT")
)
self.sms_client_labeled = _parse_labeled_phones(raw_sms_client)
self.sms_client_enabled = (os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
self.sms_client_enabled = (_env_str("ALERT_SMS_CLIENT_ENABLED", "1") == "1")
# SMTP
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT","465"))
self.smtp_user = os.getenv("SMTP_USER")
self.smtp_pass = os.getenv("SMTP_PASS")
self.smtp_security = (os.getenv("SMTP_SECURITY","SSL") or "SSL").upper()
self.smtp_host: str = _env_str("SMTP_HOST")
self.smtp_port = int(_env_str("SMTP_PORT", "465"))
self.smtp_user: str = _env_str("SMTP_USER")
self.smtp_pass: str = _env_str("SMTP_PASS")
self.smtp_security: str = _env_str("SMTP_SECURITY", "SSL").upper()
raw_mail_to = (os.getenv(f"MAIL_TO_{SITE}") or os.getenv(f"MAIL_TO_{SITE.upper()}") or os.getenv("MAIL_TO") or "")
raw_mail_to = (_env_str(f"MAIL_TO_{SITE}") or _env_str(f"MAIL_TO_{SITE.upper()}") or _env_str("MAIL_TO"))
self.mail_to = _split_list(raw_mail_to)
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.mail_from: str = (_env_str(f"MAIL_FROM_{SITE}") or _env_str(f"MAIL_FROM_{SITE.upper()}") or _env_str("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])
@@ -373,15 +439,16 @@ class Notifier:
"tag": tag,
}
try:
log.info("Envoi SMS vers: %s", _human_labeled_list([(n,p) for (n,p) in self.sms_labeled if p in receivers]))
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
ovh_client = cast(Any, self.ovh_client)
log.info("Envoi SMS vers: %s", _human_labeled_list([(name, phone) for (name, phone) in self.sms_labeled if phone in receivers]))
resp = ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
ids = resp.get("ids") or []
log.info("SMS OVH envoyé (job ids=%s)", ids)
try:
if ids:
job_id = ids[0]
for _ in range(3):
job = self.ovh_client.get(f"/sms/{self.ovh_service}/jobs/{job_id}")
job = ovh_client.get(f"/sms/{self.ovh_service}/jobs/{job_id}")
if job.get("status") in ("done","error","cancelled"):
log.info("Statut job SMS: %s", job.get("status")); break
time.sleep(1.5)
@@ -399,7 +466,7 @@ class Notifier:
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")
only = _env_str("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)]
@@ -426,7 +493,8 @@ class Notifier:
}
try:
log.info("Envoi SMS CLIENT vers: %s", _human_labeled_list(labeled))
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
ovh_client = cast(Any, self.ovh_client)
resp = 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:
@@ -442,8 +510,8 @@ class Notifier:
msg["Subject"] = subject
msg.set_content(body)
timeout = int(os.getenv("SMTP_TIMEOUT","60"))
debug = os.getenv("SMTP_DEBUG","0") == "1"
timeout = int(_env_str("SMTP_TIMEOUT", "60"))
debug = _env_str("SMTP_DEBUG", "0") == "1"
def _send_ssl():
with smtplib.SMTP_SSL(self.smtp_host, 465, context=ssl.create_default_context(), timeout=timeout) as server:
@@ -484,46 +552,64 @@ def fmt_deg(v: float) -> str:
def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS)
def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris()
def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> tuple[str, str, str]:
when_dt = when if when is not None else 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')}"
f"Heure: {when_dt.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()
def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> tuple[str, str, str]:
when_dt = when if when is not None else 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')}"
f"Heure: {when_dt.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()
when_dt = when if when is not None else now_paris()
# Court, 1 ligne; accents/° nettoyés par normaliser_sms
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when.strftime('%H:%M')}"
return f"ALERTE CLIENT {sonde}: T={fmt_deg(temp)} > S={fmt_deg(seuil)} H:{when_dt.strftime('%H:%M')}"
def build_gyro_chat_alert(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
when_dt = when if when is not None else now_paris()
return (
f":rotating_light: [{site}] GYRO DECLENCHE\n"
f"Sonde: {sonde}\n"
f"Temperature: {fmt_deg(temp)} > seuil {fmt_deg(seuil)}\n"
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}"
)
def build_gyro_chat_ok(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None) -> str:
when_dt = when if when is not None else now_paris()
return (
f":white_check_mark: [{site}] GYRO RETOUR NORMALE\n"
f"Sonde: {sonde}\n"
f"Temperature: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}\n"
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}"
)
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
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
self.topic: str = (
_env_str(f"GYRO_MQTT_TOPIC_{site}") or
_env_str(f"GYRO_MQTT_TOPIC_{site.upper()}") or
_env_str("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None
@@ -536,27 +622,17 @@ class MQTTPublisher:
self.enabled = False
return
host = os.getenv("MQTT_HOST", "localhost")
port = int(os.getenv("MQTT_PORT", "1883"))
user = os.getenv("MQTT_USER")
pwd = os.getenv("MQTT_PASS")
tls = (os.getenv("MQTT_TLS", "0") == "1")
host = _env_str("MQTT_HOST", "localhost")
port = int(_env_str("MQTT_PORT", "1883"))
user = _env_str("MQTT_USER")
pwd = _env_str("MQTT_PASS")
tls = (_env_str("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:
api_v = (
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:
self.client = mqtt.Client()
else:
try:
self.client = mqtt.Client()
except TypeError:
self.client = mqtt.Client(client_id="")
# ------------------------------------------------------------
if user and pwd:
@@ -572,10 +648,9 @@ class MQTTPublisher:
# Abonnements (depuis env ou valeurs par défaut raisonnables)
subs_env = (
os.getenv(f"GYRO_MQTT_SUB_{site}") or
os.getenv(f"GYRO_MQTT_SUB_{site.upper()}") or
os.getenv("GYRO_MQTT_SUB") or
""
_env_str(f"GYRO_MQTT_SUB_{site}") or
_env_str(f"GYRO_MQTT_SUB_{site.upper()}") or
_env_str("GYRO_MQTT_SUB")
)
subs = [t.strip() for t in subs_env.split(",") if t.strip()]
if not subs:
@@ -599,9 +674,9 @@ class MQTTPublisher:
self.enabled = False
# --- Callback réception MQTT ---
def _on_message(self, client, userdata, msg):
def _on_message(self, _client, _userdata, msg):
lieu = self.site
topic = msg.topic
topic = str(msg.topic)
payload_raw = msg.payload.decode(errors="ignore").strip()
upper = payload_raw.upper()
@@ -681,10 +756,10 @@ class GyroPulseController:
- SMS OK immédiat à lextinction (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"))):
check_sec: int = int(_env_str("GYRO_CHECK_SEC", "20")),
pulse_sec: int = int(_env_str("GYRO_PULSE_SEC", "60")),
cooldown_sec: int = int(_env_str("GYRO_COOLDOWN_SEC", "600")),
normal_confirm: int = int(_env_str("GYRO_NORMAL_CONFIRM", "2"))):
self.site = site
self.beacon = beacon
self.notifier = notifier
@@ -698,13 +773,13 @@ class GyroPulseController:
self._t_cooldown_end = 0.0
self._normal_count = 0
self._stop = threading.Event()
self._thread = None
self._current = None # dernier état effectif
self._thread: threading.Thread | None = None
self._current: bool | None = 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("ALERT_SMS_COOLDOWN_SEC") or os.getenv("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (os.getenv("ALERT_OK_SMS_GYRO", "1") == "1")
self._sms_min_sec = int(_env_str("ALERT_SMS_COOLDOWN_SEC") or _env_str("GYRO_SMS_MIN_SEC", "120"))
self._send_ok = (_env_str("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)
@@ -735,18 +810,20 @@ class GyroPulseController:
return False
def _send_alert_sms(self, trigger: tuple[str, float, float] | None):
if not _env_bool("ALERT_INTERNAL_SMS_ENABLED", True):
return
if not trigger:
return
sonde, temp, seuil = trigger
if self._sms_can_send(sonde):
# Notification Synology Chat immediate sur declenchement Gyro
if _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
chat_msg = build_gyro_chat_alert(self.site, sonde, temp, seuil, when=now_paris())
send_synology_chat(chat_msg)
if _env_bool("ALERT_INTERNAL_SMS_ENABLED", True) and 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 _env_bool("ALERT_OK_SMS_GYRO", True):
return
if not self._send_ok or not self._last_trigger:
return
sonde, _temp_prev, seuil = self._last_trigger
@@ -760,7 +837,11 @@ class GyroPulseController:
if curr_temp is None:
curr_temp = seuil - 0.1 # fallback léger
if self._sms_can_send(sonde):
if _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
chat_msg = build_gyro_chat_ok(self.site, sonde, curr_temp, seuil, when=now_paris())
send_synology_chat(chat_msg)
if _env_bool("ALERT_OK_SMS_GYRO", True) and 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)
@@ -769,7 +850,7 @@ class GyroPulseController:
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")))
return compute_site_alarm(last_rows, cfg, hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")))
def _run(self):
while not self._stop.is_set():
@@ -790,9 +871,9 @@ class GyroPulseController:
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")
s, t, se, "CONTINU" if _env_str("GYRO_MODE_CONTINUOUS", "1") == "1" else "PULSE")
# SMS alerte immédiat (optionnel)
if os.getenv("ALERT_INTERNAL_SMS_ENABLED", "0") == "1":
if _env_str("ALERT_INTERNAL_SMS_ENABLED", "0") == "1":
self._send_alert_sms(trigger)
elif self.state == _GyroState.PULSE_ON:
@@ -804,11 +885,11 @@ class GyroPulseController:
self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)")
# SMS OK immédiat
if os.getenv("ALERT_OK_SMS_GYRO", "0") == "1":
if _env_str("ALERT_OK_SMS_GYRO", "0") == "1":
self._send_ok_sms_from_last_trigger()
else:
self._normal_count = 0
if os.getenv("GYRO_MODE_CONTINUOUS", "1") != "1":
if _env_str("GYRO_MODE_CONTINUOUS", "1") != "1":
if now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
@@ -846,7 +927,7 @@ def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
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":
if _env_str("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()}")
@@ -858,7 +939,7 @@ def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
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 souhaité
if os.getenv("ALERT_OK_SMS", "0") == "1":
if _env_str("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text)
# ========= Cycle & boucle =========
@@ -869,7 +950,7 @@ def run_monitor_cycle(site: str = SITE):
# 2) Info: état instantané (le gyro est piloté par la boucle rapide)
try:
gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
_gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")))
if trigger:
s, t, se = trigger
log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se)
@@ -939,6 +1020,7 @@ if __name__ == "__main__":
p.add_argument("--test-mail", action="store_true")
p.add_argument("--test-alert", action="store_true")
p.add_argument("--test-ok", action="store_true")
p.add_argument("--test-chat", action="store_true")
p.add_argument("--once", action="store_true")
args = p.parse_args()
@@ -951,6 +1033,8 @@ if __name__ == "__main__":
notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
elif args.test_chat:
send_synology_chat(f":speech_balloon: [TEST {SITE}] Notification Synology Chat OK")
else:
if args.once:
run_monitor_cycle(SITE)