From bb461a2ed18b5be28a9d2f0a87ab0aca0dbb959a Mon Sep 17 00:00:00 2001 From: Michel Date: Mon, 22 Sep 2025 11:11:55 +0200 Subject: [PATCH] relancement cuisines Saclay et Meudon --- .env | 2 + app/Monitor_Meudon.py | 85 ++++++++++++++++++++++++++++++------ app/Monitor_Saclay.py | 86 +++++++++++++++++++++++++++++++------ app/logger_config.py | 19 ++++++++ app/mqtt_logger.py | 48 +++++++++++++++++++++ app/surveillance_releves.py | 6 +-- app/utils_db.py | 73 +++++++++++++++++++++++++++++++ app/utils_sms.py | 52 ++++++++++++++++++++++ requirements.txt | 2 +- scripts/backup_mysql.sh | 2 +- 10 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 app/utils_sms.py diff --git a/.env b/.env index 4152499..522265b 100644 --- a/.env +++ b/.env @@ -32,7 +32,9 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6 OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9 OVH_SMS_SERVICE=sms-jm164396-1 OVH_SMS_SENDER=DOMO91FR +SMS_RECEIVER=+33635164680 ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680 + RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248 diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index b7cbc8a..2b542dc 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -134,6 +134,48 @@ def lire_seuils_depuis_db(site: str): finally: cnx.close() +def lire_cfg_chambres(site: str): + """ + Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}} + depuis Chambres_froides pour le site. + """ + sql = """ + SELECT Sonde, Temp_Max, Etat, En_entretien + FROM Chambres_froides + WHERE Lieu=%s + """ + cnx = get_db() + cfg = {} + try: + cur = cnx.cursor() + cur.execute(sql, (site,)) + for sonde, temp_max, etat, en_entretien in cur.fetchall(): + cfg[str(sonde)] = { + "temp_max": float(temp_max), + "active": str(etat).upper() == "ON", + "entretien": bool(int(en_entretien or 0)), + } + 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["active"] or meta["entretien"]: + continue + temp = float(row["Temperature"]) + if temp > float(meta["temp_max"]) + 0.0: + return True, (sonde, temp, float(meta["temp_max"])) + return False, None + def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: table = site cnx = get_db() @@ -359,8 +401,12 @@ class MQTTPublisher: def __init__(self, site: str): self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) self.site = site - self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or - os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) + 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: @@ -416,9 +462,9 @@ class MQTTPublisher: if self.last_state is not None and self.last_state == on: return - payload = "on" if on else "off" + payload = "ON" if on else "OFF" try: - r = self.client.publish(self.topic, payload=payload, qos=1, retain=True) + r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) r.wait_for_publish(timeout=3) if r.rc != 0: log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) @@ -437,21 +483,33 @@ 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) - try: beacon.set(True) - except Exception: pass 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) - try: - if not any_alert_open(site): - beacon.set(False) - except Exception: pass # ========= Cycle & boucle ========= def run_monitor_cycle(site: str = SITE): + # 1) Lecture mesures + config sondes = lire_sondes_depuis_db(site) - seuils = lire_seuils_depuis_db(site) + cfg = lire_cfg_chambres(site) + + # 2) Gyro instantané + try: + gyro_on, trigger = compute_site_alarm(sondes, 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) + else: + log.info("Gyro %s => OFF (aucun dépassement)", site) + beacon.set(gyro_on) + except Exception as e: + log.exception("Erreur calcul/publish gyrophare: %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). + seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} + for r in sondes: nom = str(r["Sonde"]) temp = float(r["Temperature"]) @@ -460,7 +518,7 @@ def run_monitor_cycle(site: str = SITE): now = now_paris() if temp > seuil: if depassement_depuis_30min(site, nom, seuil): - # Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle + # Ouvrir si pas déjà ouverte → notifier seulement si création try: conn = get_db() if open_alert(conn, f"Alertes_{site}", nom, now): @@ -468,7 +526,7 @@ def run_monitor_cycle(site: str = SITE): finally: conn.close() else: - # Fermer si ouvert → notifier seulement si fermeture réelle + # Fermer si ouverte → notifier seulement si fermeture réelle try: conn = get_db() if close_alert(conn, f"Alertes_{site}", nom): @@ -476,7 +534,6 @@ def run_monitor_cycle(site: str = SITE): 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) while True: diff --git a/app/Monitor_Saclay.py b/app/Monitor_Saclay.py index 1016ebf..0e37cd8 100644 --- a/app/Monitor_Saclay.py +++ b/app/Monitor_Saclay.py @@ -115,6 +115,49 @@ def lire_sondes_depuis_db(site: str): finally: cnx.close() +def lire_cfg_chambres(site: str): + """ + Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}} + depuis Chambres_froides pour le site. + """ + sql = """ + SELECT Sonde, Temp_Max, Etat, En_entretien + FROM 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, en_entretien in cur.fetchall(): + cfg[str(sonde)] = { + "temp_max": float(temp_max), + "active": str(etat).upper() == "ON", + "entretien": bool(int(en_entretien or 0)), + } + 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: tuple[str,float,float] | None) + trigger = (sonde, temp, seuil) si dépassement détecté. + """ + for row in last_values: + sonde = str(row["Sonde"]) + meta = cfg.get(sonde) + if not meta or not meta["active"] or meta["entretien"]: + continue + temp = float(row["Temperature"]) + if temp > float(meta["temp_max"]) + 0.0: + return True, (sonde, temp, float(meta["temp_max"])) + return False, None + def lire_seuils_depuis_db(site: str): sql = """ SELECT Sonde, Temp_Max @@ -360,8 +403,12 @@ class MQTTPublisher: def __init__(self, site: str): self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) self.site = site - self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or - os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) + 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: @@ -417,9 +464,9 @@ class MQTTPublisher: if self.last_state is not None and self.last_state == on: return - payload = "on" if on else "off" + payload = "ON" if on else "OFF" try: - r = self.client.publish(self.topic, payload=payload, qos=1, retain=True) + r = self.client.publish(self.topic, payload=payload, qos=2, retain=True) r.wait_for_publish(timeout=3) if r.rc != 0: log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) @@ -438,22 +485,34 @@ 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) - try: beacon.set(True) - except Exception: pass 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) - try: - if not any_alert_open(site): - beacon.set(False) - except Exception: pass # ========= Cycle & boucle ========= def run_monitor_cycle(site: str = SITE): - sondes = lire_sondes_depuis_db(site) - seuils = lire_seuils_depuis_db(site) - for r in sondes: + # 1) Lecture dernières mesures + config chambres + last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}] + cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}} + + # 2) Gyro instantané : ON si >=1 sonde active & non en entretien dépasse son seuil + 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("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se) + else: + log.info("Gyro %s => OFF (aucun dépassement)", site) + beacon.set(gyro_on) + except Exception as e: + log.exception("Erreur calcul/publish gyrophare: %s", e) + + # 3) Alertes "officielles" (inchangées) avec temporisation 30 min + # On reconstitue un dict seuils pour réutiliser ta logique existante en dessous. + seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)} + + for r in last_rows: nom = str(r["Sonde"]) temp = float(r["Temperature"]) seuil = float(seuils.get(nom, 6.0)) @@ -478,6 +537,7 @@ def run_monitor_cycle(site: str = SITE): 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) while True: diff --git a/app/logger_config.py b/app/logger_config.py index e69de29..abe0245 100644 --- a/app/logger_config.py +++ b/app/logger_config.py @@ -0,0 +1,19 @@ +import os, logging + +def setup_logger(filename: str): + # si l'argument est un chemin absolu, on le respecte + if os.path.isabs(filename): + log_path = filename + else: + # ancien comportement (ex: /var/log/Cuisine_Saclay/) + base = "/var/log/Cuisine_Saclay" + os.makedirs(base, exist_ok=True) + log_path = os.path.join(base, filename) + + os.makedirs(os.path.dirname(log_path), exist_ok=True) + logging.basicConfig( + level=logging.INFO, + filename=log_path, + format="%(asctime)s %(levelname)s %(message)s" + ) + diff --git a/app/mqtt_logger.py b/app/mqtt_logger.py index e69de29..9ffb9de 100644 --- a/app/mqtt_logger.py +++ b/app/mqtt_logger.py @@ -0,0 +1,48 @@ +import argparse +import paho.mqtt.client as mqtt_client +from dotenv import load_dotenv +import logging +from logger_config import setup_logger +from utils_db import connect_to_mysql +from functools import partial + +def on_message(table_sql, _client, _userdata, msg): + try: + logging.info(f"Message reçu sur {msg.topic}: {msg.payload.decode()}") + cursor = mydb.cursor() + sonde_name = '/'.join(msg.topic.split('/')[1:]) + sql = f"INSERT INTO {table_sql} (Sonde, Temperature) VALUES (%s, %s)" + val = (sonde_name, msg.payload.decode()) + cursor.execute(sql, val) + mydb.commit() + logging.info(f"Insertion réussie : {val}") + except Exception as e: + logging.error(f"Erreur lors de l'insertion du message : {e}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--log", required=True, help="Nom du fichier de log") + parser.add_argument("--table", required=True, help="Nom complet de la table SQL") + parser.add_argument("--topic", required=True, help="Topic MQTT à écouter") + args = parser.parse_args() + + # 📋 Initialiser le logger + setup_logger(args.log) + + # 🔑 Charger les variables d'environnement + load_dotenv() + + # 🔌 Connexion MySQL + mydb = connect_to_mysql() + + # 📡 Connexion MQTT + try: + client = mqtt_client.Client() + client.username_pw_set("Bwps", "scJ5ACj2keRfI^") + client.on_message = partial(on_message, args.table) + client.connect("54.36.188.119", 1883, 60) + client.subscribe(args.topic) + logging.info(f"Connexion MQTT réussie et abonnement au topic '{args.topic}'.") + client.loop_forever() + except Exception as err: + logging.error(f"Erreur MQTT : {err}") diff --git a/app/surveillance_releves.py b/app/surveillance_releves.py index cbd9888..ad3b8aa 100644 --- a/app/surveillance_releves.py +++ b/app/surveillance_releves.py @@ -27,10 +27,10 @@ logging.basicConfig( load_dotenv('/home/debian/Gestion_sondes/.env') # OVH SMS -APP_KEY = os.getenv('OVH_APP_KEY') -APP_SECRET = os.getenv('OVH_APP_SECRET') +APP_KEY = os.getenv('OVH_APPLICATION_KEY') +APP_SECRET = os.getenv('OVH_APPLICATION_SECRET') CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY') -SERVICE_NAME = os.getenv('OVH_SERVICE_NAME') +SERVICE_NAME = os.getenv('OVH_SMS_SERVICE') SMS_RECEIVER = os.getenv('SMS_RECEIVER') SMS_SENDER = os.getenv('OVH_SMS_SENDER') diff --git a/app/utils_db.py b/app/utils_db.py index e69de29..d355df8 100644 --- a/app/utils_db.py +++ b/app/utils_db.py @@ -0,0 +1,73 @@ +import mysql.connector +from dotenv import load_dotenv +import os + +load_dotenv() + + +def connect_to_mysql(): + return mysql.connector.connect( + host=os.getenv("DB_HOST"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASS"), + database=os.getenv("DB_NAME") + ) + + +def get_latest_chaufferie(): + conn = connect_to_mysql() + cursor = conn.cursor(dictionary=True) + query = """ + SELECT Sonde, Temperature, Date, Topic + FROM Sondes.Chaufferie + WHERE Date >= NOW() - INTERVAL 5 MINUTE + ORDER BY Date DESC \ + """ + cursor.execute(query) + result = cursor.fetchall() + cursor.close() + conn.close() + return result + + +def get_history_by_sonde(sonde): + conn = connect_to_mysql() + cursor = conn.cursor(dictionary=True) + query = """ + SELECT Sonde, Temperature, Date + FROM Sondes.Chaufferie + WHERE Sonde = %s + AND Date >= NOW() - INTERVAL 1 DAY \ + + """ + cursor.execute(query, (sonde,)) + result = cursor.fetchall() + cursor.close() + conn.close() + return result + + +def lire_alertes_sondes(): + conn = connect_to_mysql() + cursor = conn.cursor(dictionary=True) + query = """ + SELECT Id, Sonde, Debut_defaut, Etat + FROM Sondes.Alertes_Chaufferie + WHERE Etat != 'Acquitté' + ORDER BY Debut_defaut DESC \ + """ + cursor.execute(query) + result = cursor.fetchall() + cursor.close() + conn.close() + return result + + +def acquitter_alerte(id_alerte): + conn = connect_to_mysql() + cursor = conn.cursor() + query = "UPDATE Sondes.Alertes_Chaufferie SET Etat = 'Acquitté' WHERE Id = %s" + cursor.execute(query, (id_alerte,)) + conn.commit() + cursor.close() + conn.close() diff --git a/app/utils_sms.py b/app/utils_sms.py new file mode 100644 index 0000000..ea3a292 --- /dev/null +++ b/app/utils_sms.py @@ -0,0 +1,52 @@ +import os +import ovh +from dotenv import load_dotenv + +load_dotenv() + +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 + + 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") diff --git a/requirements.txt b/requirements.txt index 7e7a516..6904ab7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ mysql-connector-python~=9.4.0 pandas~=2.3.1 DateTime~=5.5 -streamlit~=1.48.1 +streamlit matplotlib~=3.10.1 paho-mqtt~=2.1.0 requests~=2.32.5 diff --git a/scripts/backup_mysql.sh b/scripts/backup_mysql.sh index d84c169..4367003 100644 --- a/scripts/backup_mysql.sh +++ b/scripts/backup_mysql.sh @@ -32,7 +32,7 @@ echo "🔷 Dossier NAS : $NAS_DIR (hôte $NAS_HOST)" # 1) Pré-check SSH & droits écriture NAS echo "🔷 Test SSH NAS…" -if ! ssh "SSH_OPTS "$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then +if ! ssh $SSH_OPTS "$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then echo "❌ Impossible d écrire sur $NAS_HOST:$NAS_DIR (clé SSH ? user ? droits ? SSH NAS activé ?)" exit 20 fi