Arrangement des alertes chat

This commit is contained in:
2026-04-20 12:56:33 +02:00
parent f1203012df
commit c0b0770ddf
6 changed files with 1106 additions and 2180 deletions

41
.env
View File

@@ -1,4 +1,4 @@
#connexion mysql # connexion mysql
DB_HOST=162.19.78.131 DB_HOST=162.19.78.131
DB_USER=sondes DB_USER=sondes
DB_PASS=TX.)-U1!zq5Axdk4 DB_PASS=TX.)-U1!zq5Axdk4
@@ -9,25 +9,23 @@ AUTH_USERS=[{"user":"Michel","pass":"210462"}]
MQTT_HOST=162.19.78.131 MQTT_HOST=162.19.78.131
MQTT_USER=sondes MQTT_USER=sondes
MQTT_PASS=3J@bjYP0 MQTT_PASS=3J@bjYP0
MQTT_PORT_MEUDON=1883 MQTT_PORT=1883
#Synology Chat # Synology Chat
SYNOLOGY_CHAT_WEBHOOK_URL=https://192.168.1.250/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=UN7nhD70vrhrHFh1VeDdOpsklIHiIFRop2qB7b6YusMEY3clY3R8CXe4hFzz4KKc SYNO_CHAT_WEBHOOK=https://mj91.fr/chat/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=QpLWAZEqIW1EOBHkfDkmr1LqC3P3J1SASWfqpchZdd1xPY7xGbYerS4lCADJnPrm
SYNOLOGY_CHAT_VERIFY_SSL=false SYNO_CHAT_VERIFY_SSL=true
SYNO_CHAT_BOTNAME="Gestion Gyro"
SYNO_CHAT_TIMEOUT=10
SYNO_CHAT_GYRO_ENABLED=1
# Boucle rapide du gyro # Boucle rapide du gyro
GYRO_WINDOW_MIN=3
GYRO_NEEDED_POINTS=2
GYRO_CHECK_SEC=20 GYRO_CHECK_SEC=20
GYRO_NORMAL_CONFIRM=6 GYRO_NORMAL_CONFIRM=6
GYRO_MODE=mqtt
GYRO_MODE_CONTINUOUS=1 GYRO_MODE_CONTINUOUS=1
GYRO_HYSTERESIS=0.3 GYRO_HYSTERESIS=0.3
ALERT_OK_SMS_GYRO=0
ALERT_OK_SMS=0
GYRO_SMS_MIN_SEC=120
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# Délais # Délais
ALERT_CONTINUOUS_MINUTES=30 ALERT_CONTINUOUS_MINUTES=30
ALERT_LOOKBACK_MINUTES=120 ALERT_LOOKBACK_MINUTES=120
@@ -47,24 +45,3 @@ MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com
MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>" MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>"
MAIL_TO_MEUDON=robots@domo91.fr,chef@parismeudonermitage.com MAIL_TO_MEUDON=robots@domo91.fr,chef@parismeudonermitage.com
MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>" MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>"
# --- Paramètres SMS ----
OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=f725d07b2f98a195
OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR
SMS_RECEIVER=+33759600180
ALERT_SMS_TO_SACLAY=Michel:+33759600180
ALERT_SMS_TO_MEUDON=Michel:+33759600180
ALERT_SMS_CLIENT_TO_MEUDON=Sekou:+33625903364,Damien:+33680388259
ALERT_SMS_CLIENT_TO_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939
# Activer/désactiver globalement lenvoi client
ALERT_SMS_CLIENT_ENABLED=1
# 1) couper les SMS internes
ALERT_INTERNAL_SMS_ENABLED=0# 0 = coupe tous les SMS “internes” (déclenchement)
# 2) limiter le flux par cooldown (par sonde)
ALERT_SMS_COOLDOWN_SEC=3600

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,958 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ========= Site =========
SITE = "Saclay"
PROGRAM_NAME = f"Monitor_{SITE}"
# ========= Imports & .env =========
import os, re, time, ssl, smtplib, logging
import datetime as dt
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
def _env_bool(name: str, default: bool) -> bool:
v = os.getenv(name, str(int(default))).strip().lower()
return v in ("1", "true", "yes", "on")
# MySQL
import mysql.connector
from mysql.connector import Error as MySQLError
from dotenv import load_dotenv
load_dotenv()
# OVH (SMS)
try:
import ovh
from ovh.exceptions import APIError as OVHAPIError
_ovh_available = True
except Exception:
ovh = None # type: ignore
class OVHAPIError(Exception): ...
_ovh_available = False
# MQTT
try:
import paho.mqtt.client as mqtt
_mqtt_ok = True
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=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 par mail & SMS client).
"""
cur = conn.cursor()
cur.execute(
f"SELECT 1 FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' LIMIT 1",
(sonde,)
)
if cur.fetchone():
cur.close()
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
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 par mail).
"""
cur = conn.cursor()
cur.execute(
f"UPDATE `{table_alertes}` SET Etat='Acquitté' "
f"WHERE Sonde=%s AND Etat='En cours' "
f"ORDER BY Debut_defaut DESC LIMIT 1",
(sonde,)
)
changed = (cur.rowcount == 1)
conn.commit()
cur.close()
return changed
def get_db():
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME", "Sondes"),
port=int(os.getenv("DB_PORT", "3306")),
autocommit=True,
)
# --- Journalisation Gyro en table dédiée `Gyro` ---
def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str,
qos: int | None, retained: int | None, when: datetime):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
"INSERT INTO Sondes.Gyro (Lieu, Sonde, Etat, Date, Topic, Payload, QoS, Retained) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
lieu,
os.getenv("GYRO_SONDE_NAME", "Gyro"),
etat, # 'ON' ou 'OFF'
when.strftime('%Y-%m-%d %H:%M:%S'),
topic,
payload_raw,
qos,
retained
)
)
cnx.commit()
log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic)
except MySQLError as err:
log.exception("Erreur DB insert_gyro_log: %s", err)
finally:
cnx.close()
def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool:
sql = "SELECT Etat FROM Sondes.Gyro WHERE Lieu=%s AND Sonde=%s ORDER BY Date DESC LIMIT 1"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(sql, (lieu, sonde))
row = cur.fetchone()
return (row is None) or (row[0] != etat)
finally:
cnx.close()
# --- Lecture des dernières mesures de température (en ignorant lignes d'état) ---
def lire_sondes_depuis_db(site: str):
table = site
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
FROM `{table}` t1
JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM `{table}`
WHERE Temperature IS NOT NULL
GROUP BY Sonde
) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate
WHERE t1.Temperature IS NOT NULL
"""
cnx = get_db()
try:
cur = cnx.cursor(dictionary=True)
cur.execute(sql)
rows = cur.fetchall()
for r in rows:
r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
return []
finally:
cnx.close()
def lire_cfg_chambres(site: str):
"""
Retourne {sonde: {"temp_max": float, "active": bool}}
depuis Chambres_froides pour le site.
"""
dbname = os.getenv("DB_NAME", "Sondes")
sql = f"""
SELECT Sonde, Temp_Max, Etat
FROM `{dbname}`.`Chambres_froides`
WHERE Lieu=%s
"""
cnx = get_db()
cfg: dict[str, dict] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
for sonde, temp_max, etat in cur.fetchall():
cfg[str(sonde)] = {
"temp_max": float(temp_max),
"active": str(etat).upper() == "ON",
}
return cfg
except MySQLError as err:
log.exception("Erreur DB (lire_cfg_chambres): %s", err)
return cfg
finally:
cnx.close()
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)
"""
for row in last_values:
sonde = str(row["Sonde"])
meta = cfg.get(sonde)
if not meta or not meta.get("active", False):
continue
temp = float(row["Temperature"])
seuil = float(meta["temp_max"])
if temp > seuil + float(hysteresis):
return True, (sonde, temp, seuil)
return False, None
def lire_seuils_depuis_db(site: str):
sql = """
SELECT Sonde, Temp_Max
FROM Sondes.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
""", (sonde, LOOKBACK))
rows = cur.fetchall()
if not rows:
return False
last_temp, last_dt = float(rows[0][0]), rows[0][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
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
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min, continu): %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()]
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()
if not tok:
continue
if ":" in tok:
name, num = tok.split(":", 1)
out.append((name.strip(), num.strip()))
else:
out.append(("", tok))
return out
def _resolve_sms_receivers(labeled: list[tuple[str, str]]) -> list[str]:
only = os.getenv("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])
# ========= 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")
)
if self.ovh_enabled:
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"),
)
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.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"))
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()
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(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])
def send_sms(self, message: str, tag: str = f"monitor-{SITE.lower()}") -> bool:
if not self.ovh_enabled or not self.sms_labeled:
log.warning("SMS désactivé ou aucun destinataire.")
return False
receivers = _resolve_sms_receivers(self.sms_labeled)
if not receivers:
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,
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True,
"senderForResponse": False,
"validityPeriod": 2880,
"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)
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}")
if job.get("status") in ("done","error","cancelled"):
log.info("Statut job SMS: %s", job.get("status")); break
time.sleep(1.5)
except Exception as e:
log.debug("Suivi job OVH indisponible (OK): %s", e)
return True
except OVHAPIError as err:
log.exception("Erreur API OVH: %s", err); return False
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
msg = EmailMessage()
msg["From"] = self.mail_from
msg["To"] = ", ".join(self.mail_to)
msg["Subject"] = subject
msg.set_content(body)
timeout = int(os.getenv("SMTP_TIMEOUT","60"))
debug = os.getenv("SMTP_DEBUG","0") == "1"
def _send_ssl():
with smtplib.SMTP_SSL(self.smtp_host, 465, context=ssl.create_default_context(), timeout=timeout) as server:
if debug: server.set_debuglevel(1)
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
def _send_starttls():
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server:
if debug: server.set_debuglevel(1)
server.ehlo(); server.starttls(context=ssl.create_default_context()); server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
try:
if self.smtp_security == "STARTTLS":
try:
_send_starttls()
except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
log.warning("STARTTLS/587 a échoué (%s). Tentative en SSL/465...", err)
_send_ssl()
else:
_send_ssl()
log.info("Email envoyé à %s", self.mail_to)
return True
except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err:
log.exception("Erreur SMTP: %s", err); return False
except Exception as err:
log.exception("Echec envoi email: %s", err); return False
# ========= Mise en forme messages =========
from zoneinfo import ZoneInfo
PARIS = ZoneInfo("Europe/Paris")
def fmt_deg(v: float) -> str:
s = f"{float(v):.1f}".replace(".", ","); return f"{s}°C"
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()
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')}"
]
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')}"
]
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()
# 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')}"
# ========= 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
f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None
if not self.enabled:
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)
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")
# --- 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:
self.client = mqtt.Client()
# ------------------------------------------------------------
if user and pwd:
self.client.username_pw_set(user, pwd)
if tls:
self.client.tls_set()
try:
# Attacher le callback avant de s'abonner
self.client.on_message = self._on_message
self.client.connect(host, port, keepalive=30)
# 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
""
)
subs = [t.strip() for t in subs_env.split(",") if t.strip()]
if not subs:
subs = [
self.topic, # ex: Sondes/Saclay/Gyro/cmd
f"Sondes/{site}/Gyro/#",
f"{site}/Gyro/#",
"Gyro/#",
]
for t in subs:
try:
self.client.subscribe(t, qos=2)
log.info("MQTT subscribe: %s", t)
except Exception as e:
log.warning("Subscribe échoué (%s): %s", t, e)
self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic)
except Exception as e:
log.exception("MQTT connexion impossible: %s", e)
self.enabled = False
# --- Callback réception MQTT ---
def _on_message(self, client, userdata, msg):
lieu = self.site
topic = msg.topic
payload_raw = msg.payload.decode(errors="ignore").strip()
upper = payload_raw.upper()
# 1) Évènements gyrophare
if upper in ("ON", "OFF") or "gyro" in topic.lower() or "gyrophare" in topic.lower():
etat = upper if upper in ("ON", "OFF") else ("ON" if "on" in upper else "OFF")
try:
if should_insert_gyro(lieu, etat):
insert_gyro_log(
lieu=lieu,
etat=etat,
topic=topic,
payload_raw=payload_raw,
qos=getattr(msg, "qos", None),
retained=getattr(msg, "retain", None),
when=now_paris()
)
except Exception as e:
log.exception("Insert Gyro échoué: %s", e)
return # ne pas poursuivre vers un parse température ici
# 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs)
try:
float(payload_raw.replace(",", "."))
except ValueError:
log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw)
def set(self, on: bool):
if not self.enabled:
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)
try:
r.wait_for_publish(timeout=3)
except Exception:
pass
if getattr(r, 'rc', 0) != 0:
log.warning("MQTT publish rc=%s (topic=%s)", getattr(r, 'rc', None), self.topic)
else:
log.info("Gyro %s -> %s (MQTT)", self.site, payload.upper())
# Enregistrer en base l'événement gyro
try:
insert_gyro_log(
lieu=self.site,
etat=payload,
topic=self.topic,
payload_raw=payload,
qos=2,
retained=1 if getattr(r, 'is_published', lambda: False)() else None,
when=now_paris()
)
except Exception as e:
log.exception("Insert événement gyro en base a échoué: %s", e)
self.last_state = on
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 lalarme persiste, OFF quand normal confirmé.
- MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que lalarme persiste.
Ajouts :
- SMS ALERTE immédiat à lallumage
- 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"))):
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("ALERT_SMS_COOLDOWN_SEC") or 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 _env_bool("ALERT_INTERNAL_SMS_ENABLED", True):
return
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 _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
# 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 (optionnel)
if os.getenv("ALERT_INTERNAL_SMS_ENABLED", "0") == "1":
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
if os.getenv("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 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()
beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
"""
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):
"""
MAIL lorsque lalerte 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
# Optionnel: SMS "OK" côté cycle si souhaité
if os.getenv("ALERT_OK_SMS", "0") == "1":
notifier.send_sms(sms_text)
# ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE):
# 1) Lecture dernières mesures + config
last_rows = lire_sondes_depuis_db(site)
cfg = lire_cfg_chambres(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")))
if trigger:
s, t, se = trigger
log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", s, t, se)
else:
log.info("Aucun dépassement au moment du cycle")
except Exception as e:
log.exception("Erreur calcul alarme (info): %s", e)
# 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 last_rows:
nom = str(r["Sonde"])
temp = float(r["Temperature"])
if nom not in seuils:
continue # sonde non gérée dans Chambres_froides → ignorée
seuil = float(seuils[nom])
now_ = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
conn = None
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now_):
notifier_sur_depassement(site, nom, temp, seuil)
finally:
if conn:
conn.close()
else:
conn = None
try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil)
finally:
if conn:
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:
run_monitor_cycle(site)
except Exception as err:
log.exception("Erreur cycle monitoring: %s", err)
time.sleep(max(0, period_sec - (time.time() - t0)))
# ========= CLI =========
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser(description=PROGRAM_NAME)
p.add_argument("--period", type=int, default=300)
p.add_argument("--test-sms", action="store_true")
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("--once", action="store_true")
args = p.parse_args()
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, "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)

View File

@@ -1,44 +1,144 @@
# gyro_control.py #!/usr/bin/env python3
import os, time, logging, threading # -*- coding: utf-8 -*-
import json
import logging
import os
import re
import threading
import time
from typing import Any
import mysql.connector import mysql.connector
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import requests
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
_ALLOWED_SITE_RE = re.compile(r"^[A-Za-z0-9_]+$")
def safe_site(site: str) -> str:
site = (site or "").strip()
if not site or not _ALLOWED_SITE_RE.fullmatch(site):
raise ValueError(f"Nom de site invalide: {site!r}")
return site
def _env_str(name: str, default: str = "") -> str:
return (os.getenv(name, default) or "").strip()
def _env_bool(name: str, default: bool = False) -> bool:
value = _env_str(name, "1" if default else "0").lower()
return value in ("1", "true", "yes", "on")
logging.basicConfig(
level=getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("gyro") log = logging.getLogger("gyro")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
DEF_CHECK_SEC = int(os.getenv("GYRO_CHECK_SEC", "20")) DEF_CHECK_SEC = int(_env_str("GYRO_CHECK_SEC", "20"))
DEF_NORMAL_CONFIRM = int(os.getenv("GYRO_NORMAL_CONFIRM", "2")) DEF_NORMAL_CONFIRM = int(_env_str("GYRO_NORMAL_CONFIRM", "6"))
def send_synology_chat(message: str, *, site: str, username: str | None = None) -> bool:
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("[%s] Synology Chat non configuré.", site)
return False
botname = username or _env_str("SYNO_CHAT_BOTNAME", "Gestion Gyro")
timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10"))
verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True)
chat_payload: dict[str, str] = {"text": message}
if botname:
chat_payload["username"] = botname
form_data = {"payload": json.dumps(chat_payload, ensure_ascii=False)}
try:
response = requests.post(
webhook,
data=form_data,
timeout=timeout,
verify=verify_ssl,
)
response.raise_for_status()
txt = (response.text or "").strip()
log.info("[%s] Réponse Synology Chat: %s", site, txt[:300] if txt else "<vide>")
try:
data = response.json()
if isinstance(data, dict):
success = bool(data.get("success", False))
if not success:
log.warning("[%s] Synology Chat a répondu sans succès: %s", site, data)
return success
except ValueError:
pass
return txt.lower() == "ok" or not txt
except requests.RequestException as exc:
log.exception("[%s] Echec envoi Synology Chat: %s", site, exc)
return False
class MqttGyroDriver: class MqttGyroDriver:
def __init__(self, host, port, user, password, topic_cmd): def __init__(self, host: str, port: int, user: str, password: str, topic_cmd: str):
self.topic_cmd = topic_cmd self.topic_cmd = topic_cmd
try:
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
except Exception:
self.client = mqtt.Client() self.client = mqtt.Client()
if user: if user:
self.client.username_pw_set(user, password or "") self.client.username_pw_set(user, password or "")
self.client.connect(host, int(port or 1883), keepalive=30) self.client.connect(host, int(port), keepalive=30)
self.client.loop_start() self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, topic_cmd)
def set(self, on: bool): def set(self, on: bool) -> None:
payload = "ON" if on else "OFF" payload = "ON" if on else "OFF"
res = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False) result = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False)
res.wait_for_publish(timeout=5) result.wait_for_publish(timeout=5)
log.info("MQTT → %s : %s", self.topic_cmd, payload) log.info("MQTT → %s : %s", self.topic_cmd, payload)
def close(self): def close(self) -> None:
try: try:
self.client.loop_stop() self.client.loop_stop()
self.client.disconnect() self.client.disconnect()
except Exception: except Exception:
pass pass
class GyroController: class GyroController:
""" """
Gyro ON en continu tant qu'il existe au moins une alerte Etat='En cours'. Gyro ON en continu tant qu'il existe au moins une alerte Etat='En cours'.
Gyro OFF après 'normal_confirm' lectures consécutives sans alerte. Gyro OFF après 'normal_confirm' lectures consécutives sans alerte.
Notification Chat sur transition ON/OFF.
""" """
def __init__(self, *, site_name: str, db_cfg: dict, alertes_table: str,
mqtt_driver: MqttGyroDriver, check_sec: int = DEF_CHECK_SEC, def __init__(
normal_confirm: int = DEF_NORMAL_CONFIRM): self,
*,
site_name: str,
db_cfg: dict[str, Any],
alertes_table: str,
mqtt_driver: MqttGyroDriver,
check_sec: int = DEF_CHECK_SEC,
normal_confirm: int = DEF_NORMAL_CONFIRM,
):
self.site = site_name self.site = site_name
self.db_cfg = db_cfg self.db_cfg = db_cfg
self.alertes_table = alertes_table self.alertes_table = alertes_table
@@ -47,29 +147,68 @@ class GyroController:
self.normal_confirm = normal_confirm self.normal_confirm = normal_confirm
self._stop = threading.Event() self._stop = threading.Event()
self._thread = None self._thread: threading.Thread | None = None
self._current_on = None self._current_on: bool | None = None
self._normal_count = 0 self._normal_count = 0
def _set_gyro(self, on: bool): def _send_chat_on(self) -> None:
if self._current_on is not on: if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
message = (
f":rotating_light: [{self.site}] GYRO DECLENCHE\n"
f"Table alertes: {self.alertes_table}\n"
"Etat: au moins une alerte en cours"
)
send_synology_chat(message, site=self.site)
def _send_chat_off(self) -> None:
if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
message = (
f":white_check_mark: [{self.site}] GYRO RETOUR NORMALE\n"
f"Table alertes: {self.alertes_table}\n"
"Etat: plus d'alerte en cours"
)
send_synology_chat(message, site=self.site)
def _set_gyro(self, on: bool) -> None:
if self._current_on is on:
return
previous = self._current_on
self.mqtt.set(on) self.mqtt.set(on)
self._current_on = on self._current_on = on
if previous is None:
log.info("[%s] Etat initial Gyro: %s", self.site, "ON" if on else "OFF")
return
if on:
log.info("[%s] Transition Gyro OFF → ON", self.site)
self._send_chat_on()
else:
log.info("[%s] Transition Gyro ON → OFF", self.site)
self._send_chat_off()
def _has_active_alert(self, cur) -> bool: def _has_active_alert(self, cur) -> bool:
cur.execute(f"SELECT COUNT(*) FROM `{self.alertes_table}` WHERE Etat='En cours'") cur.execute(f"SELECT COUNT(*) FROM `{self.alertes_table}` WHERE Etat='En cours'")
return cur.fetchone()[0] > 0 row = cur.fetchone()
return bool(row and row[0] > 0)
def start(self): def start(self) -> None:
if self._thread and self._thread.is_alive(): if self._thread and self._thread.is_alive():
return return
self._stop.clear() self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True) self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start() self._thread.start()
log.info("[%s] GyroController démarré (check=%ss, confirm=%d)", log.info(
self.site, self.check_sec, self.normal_confirm) "[%s] GyroController démarré (check=%ss, confirm=%d)",
self.site,
self.check_sec,
self.normal_confirm,
)
def stop(self): def stop(self) -> None:
self._stop.set() self._stop.set()
def _connect_mysql(self): def _connect_mysql(self):
@@ -78,18 +217,17 @@ class GyroController:
cnx = mysql.connector.connect(autocommit=True, **self.db_cfg) cnx = mysql.connector.connect(autocommit=True, **self.db_cfg)
cur = cnx.cursor() cur = cnx.cursor()
return cnx, cur return cnx, cur
except Exception as e: except Exception as exc:
log.error("[%s] Connexion MySQL KO (%s). Retry 5s", self.site, e) log.error("[%s] Connexion MySQL KO (%s). Retry 5s...", self.site, exc)
time.sleep(5) time.sleep(5)
return None, None return None, None
def _run(self): def _run(self) -> None:
cnx, cur = self._connect_mysql() cnx, cur = self._connect_mysql()
if not cnx: if not cnx or not cur:
return return
try: try:
# au démarrage, on force OFF par sécurité (optionnel)
try: try:
self._set_gyro(False) self._set_gyro(False)
except Exception: except Exception:
@@ -98,14 +236,15 @@ class GyroController:
while not self._stop.is_set(): while not self._stop.is_set():
try: try:
active = self._has_active_alert(cur) active = self._has_active_alert(cur)
except Exception as e: except Exception as exc:
log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, e) log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, exc)
try: try:
cur.close(); cnx.close() cur.close()
cnx.close()
except Exception: except Exception:
pass pass
cnx, cur = self._connect_mysql() cnx, cur = self._connect_mysql()
if not cnx: if not cnx or not cur:
break break
active = False active = False
@@ -125,38 +264,67 @@ class GyroController:
except Exception: except Exception:
pass pass
try: try:
cur.close(); cnx.close() cur.close()
cnx.close()
except Exception: except Exception:
pass pass
log.info("[%s] GyroController stoppé", self.site) log.info("[%s] GyroController stoppé", self.site)
if __name__ == "__main__":
# ---- CONFIG À ADAPTER ----
SITE = "Meudon"
ALERTES_TABLE = "Alertes_Meudon" # adaptez au nom réel
DB_CFG = dict( def build_db_cfg() -> dict[str, Any]:
host=(os.getenv("DB_HOST") or "162.19.78.131").strip(), return {
user=(os.getenv("DB_USER") or "sondes").strip(), "host": _env_str("DB_HOST", "162.19.78.131"),
password=os.getenv("DB_PASSWORD") or "TX.)-U1!zq5Axdk4", "user": _env_str("DB_USER", "sondes"),
database=(os.getenv("DB_NAME") or "Sondes").strip(), "password": _env_str("DB_PASS"),
port=int(os.getenv("DB_PORT") or 3306), "database": _env_str("DB_NAME", "Sondes"),
"port": int(_env_str("DB_PORT", "3306")),
}
def build_topic(site: str) -> str:
return (
_env_str(f"GYRO_MQTT_TOPIC_{site}")
or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}")
or _env_str("GYRO_MQTT_TOPIC")
or f"{site}/gyrophare"
) )
MQTT_HOST = (os.getenv("MQTT_HOST") or "162.19.78.131").strip()
MQTT_PORT = int(os.getenv("MQTT_PORT") or 1883)
MQTT_USER = os.getenv("MQTT_USER") or "sondes"
MQTT_PASS = os.getenv("MQTT_PASSWORD") or "3J@bjYP0"
TOPIC_CMD = "Meudon/gyrophare/cmd" if __name__ == "__main__":
import argparse
print("MQTT_HOST =", repr(MQTT_HOST)) parser = argparse.ArgumentParser(description="Contrôle du gyrophare via table d'alertes")
print("MQTT_PORT =", repr(MQTT_PORT)) parser.add_argument("--site", default=_env_str("SITE", "Saclay"))
parser.add_argument("--test-chat", action="store_true")
args = parser.parse_args()
drv = MqttGyroDriver(MQTT_HOST, MQTT_PORT, MQTT_USER, MQTT_PASS, TOPIC_CMD) site = safe_site(args.site)
ctl = GyroController(site_name=SITE, db_cfg=DB_CFG, alertes_table=ALERTES_TABLE,
mqtt_driver=drv, check_sec=DEF_CHECK_SEC, normal_confirm=DEF_NORMAL_CONFIRM) if args.test_chat:
ctl.start() send_synology_chat(f":speech_balloon: [TEST {site}] Notification Synology Chat OK", site=site)
raise SystemExit(0)
alertes_table = _env_str("ALERTES_TABLE", f"Alertes_{site}")
db_cfg = build_db_cfg()
mqtt_host = _env_str("MQTT_HOST", "162.19.78.131")
mqtt_port = int(_env_str("MQTT_PORT", "1883"))
mqtt_user = _env_str("MQTT_USER", "sondes")
mqtt_pass = _env_str("MQTT_PASS")
topic_cmd = build_topic(site)
log.info("[%s] MQTT host=%s port=%s topic=%s", site, mqtt_host, mqtt_port, topic_cmd)
driver = MqttGyroDriver(mqtt_host, mqtt_port, mqtt_user, mqtt_pass, topic_cmd)
controller = GyroController(
site_name=site,
db_cfg=db_cfg,
alertes_table=alertes_table,
mqtt_driver=driver,
check_sec=DEF_CHECK_SEC,
normal_confirm=DEF_NORMAL_CONFIRM,
)
controller.start()
try: try:
while True: while True:
@@ -164,5 +332,5 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
ctl.stop() controller.stop()
drv.close() driver.close()

View File

@@ -1,71 +0,0 @@
import os
import ovh
from dotenv import load_dotenv
import unicodedata, re
load_dotenv()
def normaliser_sms(message: str, prefix: str = "") -> str:
REPL = {"°": "C", "": "'", "": '"', "": '"', "": "-", "": "-", "": "..."}
for k, v in REPL.items():
message = message.replace(k, v)
message = unicodedata.normalize("NFKD", message).encode("ascii", "ignore").decode("ascii")
rules = [
(r"\bTemperature\b", "T"),
(r"\bTemp[ée]rature\b", "T"),
(r"\bSeuil\b", "S"),
(r"\bHeure\b", "H"),
(r"\s{2,}", " "),
]
for pat, repl in rules:
message = re.sub(pat, repl, message, flags=re.IGNORECASE)
if prefix:
message = f"[{prefix}] {message}"
return message[:160]
def envoyer_sms(message: str, lieu: str = ""):
try:
client = ovh.Client(
endpoint=os.getenv("OVH_ENDPOINT"),
application_key=os.getenv("OVH_APP_KEY"),
application_secret=os.getenv("OVH_APP_SECRET"),
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
)
services = client.get('/sms/')
if not services:
print("❌ Aucun service SMS OVH trouvé", flush=True)
return
service_name = services[0]
numero_dest = os.getenv("SMS_RECEIVER")
sender = os.getenv("OVH_SMS_SENDER")
if numero_dest.startswith('+'):
numero_dest = '00' + numero_dest[1:]
if not numero_dest or not numero_dest.isdigit():
print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True)
return
message = normaliser_sms(message, prefix=lieu)
payload = {
"sender": sender,
"receivers": [numero_dest],
"message": message, # Pas d'encodage ni de nettoyage ici
"priority": "high",
"noStopClause": False
}
print("📤 Requête envoyée à OVH :")
print(payload)
result = client.post(f'/sms/{service_name}/jobs', **payload)
print(f"📱 SMS envoyé à {numero_dest} pour {lieu}. Job ID : {result['ids']}", flush=True)
except Exception as e:
print(f"❌ Erreur envoi SMS : {e}", flush=True)
if __name__ == "__main__":
envoyer_sms("Test SMS OVH", lieu="utils_sms")