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

View File

@@ -1,44 +1,144 @@
# gyro_control.py
import os, time, logging, threading
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import os
import re
import threading
import time
from typing import Any
import mysql.connector
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")
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_NORMAL_CONFIRM = int(os.getenv("GYRO_NORMAL_CONFIRM", "2"))
DEF_CHECK_SEC = int(_env_str("GYRO_CHECK_SEC", "20"))
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:
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.client = mqtt.Client()
try:
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
except Exception:
self.client = mqtt.Client()
if user:
self.client.username_pw_set(user, password or "")
self.client.connect(host, int(port or 1883), keepalive=30)
self.client.connect(host, int(port), keepalive=30)
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"
res = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False)
res.wait_for_publish(timeout=5)
result = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False)
result.wait_for_publish(timeout=5)
log.info("MQTT → %s : %s", self.topic_cmd, payload)
def close(self):
def close(self) -> None:
try:
self.client.loop_stop()
self.client.disconnect()
except Exception:
pass
class GyroController:
"""
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.
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,
normal_confirm: int = DEF_NORMAL_CONFIRM):
def __init__(
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.db_cfg = db_cfg
self.alertes_table = alertes_table
@@ -47,29 +147,68 @@ class GyroController:
self.normal_confirm = normal_confirm
self._stop = threading.Event()
self._thread = None
self._current_on = None
self._thread: threading.Thread | None = None
self._current_on: bool | None = None
self._normal_count = 0
def _set_gyro(self, on: bool):
if self._current_on is not on:
self.mqtt.set(on)
self._current_on = on
def _send_chat_on(self) -> None:
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._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:
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():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info("[%s] GyroController démarré (check=%ss, confirm=%d)",
self.site, self.check_sec, self.normal_confirm)
log.info(
"[%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()
def _connect_mysql(self):
@@ -78,18 +217,17 @@ class GyroController:
cnx = mysql.connector.connect(autocommit=True, **self.db_cfg)
cur = cnx.cursor()
return cnx, cur
except Exception as e:
log.error("[%s] Connexion MySQL KO (%s). Retry 5s", self.site, e)
except Exception as exc:
log.error("[%s] Connexion MySQL KO (%s). Retry 5s...", self.site, exc)
time.sleep(5)
return None, None
def _run(self):
def _run(self) -> None:
cnx, cur = self._connect_mysql()
if not cnx:
if not cnx or not cur:
return
try:
# au démarrage, on force OFF par sécurité (optionnel)
try:
self._set_gyro(False)
except Exception:
@@ -98,14 +236,15 @@ class GyroController:
while not self._stop.is_set():
try:
active = self._has_active_alert(cur)
except Exception as e:
log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, e)
except Exception as exc:
log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, exc)
try:
cur.close(); cnx.close()
cur.close()
cnx.close()
except Exception:
pass
cnx, cur = self._connect_mysql()
if not cnx:
if not cnx or not cur:
break
active = False
@@ -125,38 +264,67 @@ class GyroController:
except Exception:
pass
try:
cur.close(); cnx.close()
cur.close()
cnx.close()
except Exception:
pass
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(
host=(os.getenv("DB_HOST") or "162.19.78.131").strip(),
user=(os.getenv("DB_USER") or "sondes").strip(),
password=os.getenv("DB_PASSWORD") or "TX.)-U1!zq5Axdk4",
database=(os.getenv("DB_NAME") or "Sondes").strip(),
port=int(os.getenv("DB_PORT") or 3306),
def build_db_cfg() -> dict[str, Any]:
return {
"host": _env_str("DB_HOST", "162.19.78.131"),
"user": _env_str("DB_USER", "sondes"),
"password": _env_str("DB_PASS"),
"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))
print("MQTT_PORT =", repr(MQTT_PORT))
parser = argparse.ArgumentParser(description="Contrôle du gyrophare via table d'alertes")
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)
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)
ctl.start()
site = safe_site(args.site)
if args.test_chat:
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:
while True:
@@ -164,5 +332,5 @@ if __name__ == "__main__":
except KeyboardInterrupt:
pass
finally:
ctl.stop()
drv.close()
controller.stop()
driver.close()