From a0b6d22727fe868b2eb6622c9dc58ba76fa85401 Mon Sep 17 00:00:00 2001 From: Michel Date: Sun, 14 Dec 2025 15:55:15 +0100 Subject: [PATCH] Consolidation de Domo91 --- .env | 17 +- app/Monitor_Meudon.py | 667 ++++++++++++++++++++---------------- app/Mqtt_meudon.py | 10 +- app/Mqtt_saclay.py | 6 +- app/domo91.py | 768 +++++++++++++++++++++--------------------- 5 files changed, 773 insertions(+), 695 deletions(-) diff --git a/.env b/.env index 8a1a598..a344576 100644 --- a/.env +++ b/.env @@ -6,21 +6,12 @@ DB_NAME=Sondes AUTH_USERS=[{"user":"Michel","pass":"210462"}] -# MQTT Saclay -MQTT_HOST=54.36.188.119 -MQTT_USER=Bwps -MQTT_PASS=scJ5ACj2keRfI^ - -# --- MQTT Meudon --- -MQTT_HOST_MEUDON=162.19.78.131 -MQTT_USER_MEUDON=sondes -MQTT_PASS_MEUDON=3J@bjYP0 +# MQTT +MQTT_HOST=162.19.78.131 +MQTT_USER=sondes +MQTT_PASS=3J@bjYP0 MQTT_PORT_MEUDON=1883 -# Topic gyrophare Meudon -GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare - - # Boucle rapide du gyro GYRO_MODE=mqtt GYRO_CHECK_SEC=20 diff --git a/app/Monitor_Meudon.py b/app/Monitor_Meudon.py index fe36365..47b99fb 100644 --- a/app/Monitor_Meudon.py +++ b/app/Monitor_Meudon.py @@ -1,122 +1,226 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -# ========= Site ========= -SITE = "Meudon" -PROGRAM_NAME = f"Monitor_{SITE}" +""" +Monitor températures + alertes + gyrophare MQTT + notifications (OVH SMS + SMTP). +Version corrigée pour VPS unique et robustesse DB / config. + +Principes : +- DB_HOST / MQTT_HOST / SMTP_HOST : uniques (VPS unique) +- Paramètres par site via env : MAIL_TO_{SITE}, ALERT_SMS_TO_{SITE}, etc. +- Les alertes ne concernent QUE les sondes présentes dans Chambres_froides pour le site + et avec Etat=ON et En_entretien=0. +""" import datetime as dt -# ========= Imports & .env ========= +import enum import logging import os import re import smtplib import ssl +import threading import time from datetime import datetime from email.message import EmailMessage +from zoneinfo import ZoneInfo from dotenv import load_dotenv, find_dotenv +# ========= .env (une seule fois) ========= load_dotenv(find_dotenv(usecwd=True), override=False) + +# ========= Utils projet ========= 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 +# ========= MySQL ========= import mysql.connector from mysql.connector import Error as MySQLError -from dotenv import load_dotenv -load_dotenv() -# OVH (SMS) +# ========= OVH (SMS) ========= try: import ovh from ovh.exceptions import APIError as OVHAPIError _ovh_available = True except Exception: ovh = None # type: ignore - class OVHAPIError(Exception): ... + class OVHAPIError(Exception): + ... _ovh_available = False -# MQTT +# ========= MQTT ========= try: import paho.mqtt.client as mqtt _mqtt_ok = True except Exception: _mqtt_ok = False + +# ========= Helpers env ========= +def _env_bool(name: str, default: bool) -> bool: + v = os.getenv(name, str(int(default))).strip().lower() + return v in ("1", "true", "yes", "on") + + +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]) + + +# ========= Sécurisation du site / tables ========= +_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 + + +# ========= Timezone ========= +PARIS = ZoneInfo("Europe/Paris") + +def now_paris() -> dt.datetime: + return dt.datetime.now(tz=PARIS) + + +def fmt_deg(v: float) -> str: + s = f"{float(v):.1f}".replace(".", ",") + return f"{s}°C" + + +# ========= Site (par défaut via env, sinon Meudon) ========= +SITE = safe_site(os.getenv("SITE", "Meudon")) +PROGRAM_NAME = f"Monitor_{SITE}" + # ========= 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 +# ========= DB Connexion ========= def get_db(): + """ + Connexion DB unique (VPS unique). Le site ne change pas d'hôte, + seules les tables changent (ex: `Meudon`, `Alertes_Meudon`). + """ 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, + autocommit=False, ) -# --- Journalisation Gyro en table dédiée `Gyro` --- + +# ========= Alertes DB ========= +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. + """ + cur = conn.cursor() + try: + cur.execute( + f"SELECT Id FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' ORDER BY Debut_defaut DESC LIMIT 1", + (sonde,) + ) + if cur.fetchone(): + return False + + 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() + return True + finally: + cur.close() + + +def close_alert(conn, table_alertes: str, sonde: str) -> bool: + """ + Ferme la dernière alerte 'En cours' si présente. + Retourne True si une alerte est passée à 'Acquitté'. + """ + cur = conn.cursor() + try: + cur.execute( + f"SELECT Id FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' ORDER BY Debut_defaut DESC LIMIT 1", + (sonde,) + ) + row = cur.fetchone() + if not row: + return False + + alert_id = int(row[0]) + cur.execute( + f"UPDATE `{table_alertes}` SET Etat='Acquitté' WHERE Id=%s", + (alert_id,) + ) + changed = (cur.rowcount == 1) + conn.commit() + return changed + finally: + cur.close() + + +# ========= Gyro DB (journalisation) ========= +def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool: + dbname = os.getenv("DB_NAME", "Sondes") + sql = f"SELECT Etat FROM `{dbname}`.`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() + + def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, qos: int | None, retained: int | None, when: datetime): + dbname = os.getenv("DB_NAME", "Sondes") cnx = get_db() try: cur = cnx.cursor() cur.execute( - "INSERT INTO Sondes.Gyro (Lieu, Sonde, Etat, Date, Topic, Payload, QoS, Retained) " + f"INSERT INTO `{dbname}`.`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'), + etat, + when.strftime("%Y-%m-%d %H:%M:%S"), topic, payload_raw, qos, @@ -130,20 +234,13 @@ def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str, 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) --- +# ========= Lecture températures / config ========= def lire_sondes_depuis_db(site: str): - table = site + """ + Dernière mesure par sonde, en ignorant Temperature NULL. + """ + table = safe_site(site) sql = f""" SELECT t1.Sonde, t1.Temperature, t1.Date FROM `{table}` t1 @@ -161,7 +258,7 @@ def lire_sondes_depuis_db(site: str): cur.execute(sql) rows = 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) @@ -175,16 +272,17 @@ def lire_cfg_chambres(site: str): Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}} depuis Chambres_froides pour le site. """ - sql = """ + dbname = os.getenv("DB_NAME", "Sondes") + sql = f""" SELECT Sonde, Temp_Max, Etat, En_entretien - FROM Sondes.Chambres_froides + FROM `{dbname}`.`Chambres_froides` WHERE Lieu=%s """ cnx = get_db() cfg: dict[str, dict] = {} try: cur = cnx.cursor() - cur.execute(sql, (site, )) + cur.execute(sql, (site,)) for sonde, temp_max, etat, en_entretien in cur.fetchall(): cfg[str(sonde)] = { "temp_max": float(temp_max), @@ -198,51 +296,38 @@ 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): """ Retourne (is_on: bool, trigger: (sonde,temp,seuil) | None) + Ne considère que les sondes actives et non en entretien. """ for row in last_values: sonde = str(row["Sonde"]) meta = cfg.get(sonde) - if not meta or not meta["active"] or meta["entretien"]: + if not meta: + continue + if (not meta["active"]) or meta["entretien"]: continue temp = float(row["Temperature"]) - if temp > float(meta["temp_max"]) + float(hysteresis): - return True, (sonde, temp, float(meta["temp_max"])) + 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)) + + Hypothèse : Date en DB et horloge VPS cohérentes (timezone serveur). """ 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)))) + LOOKBACK = int(os.getenv("ALERT_LOOKBACK_MINUTES", str(max(60, CONT_MIN * 3)))) - table = site + table = safe_site(site) cnx = get_db() try: cur = cnx.cursor() @@ -250,6 +335,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: SELECT Temperature, Date FROM `{table}` WHERE Sonde=%s + AND Temperature IS NOT NULL AND Date >= (NOW() - INTERVAL %s MINUTE) ORDER BY Date DESC """, (sonde, LOOKBACK)) @@ -269,73 +355,50 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: else: break - tzinfo = getattr(start_dt, "tzinfo", None) - now = dt.datetime.now(tz=tzinfo) - dur_min = (now - start_dt).total_seconds() / 60.0 + # Comparaison sur l'horloge locale (VPS) : cohérent avec NOW() DB si timezone serveur identique + now_local = dt.datetime.now() + dur_min = (now_local - 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) + sonde, start_dt, now_local, dur_min, CONT_MIN) return dur_min >= CONT_MIN except MySQLError as err: - log.exception("Erreur DB (depassement_depuis_30min, continu): %s", err) + log.exception("Erreur DB (depassement_depuis_30min): %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) ========= +# ========= Notifier (OVH SMS + SMTP) ========= class Notifier: - def __init__(self): + def __init__(self, site: str): + self.site = site + # 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"), + 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")) + 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) + # SMS CLIENTS raw_sms_client = ( - os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE}") or - os.getenv(f"ALERT_SMS_CLIENT_TO_{SITE.upper()}") or + 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(f"ALERTE_CLIENT_{site}") or os.getenv("ALERTE_CLIENT") ) self.sms_client_labeled = _parse_labeled_phones(raw_sms_client) @@ -343,18 +406,19 @@ class Notifier: # SMTP self.smtp_host = os.getenv("SMTP_HOST") - self.smtp_port = int(os.getenv("SMTP_PORT","465")) + 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_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 "") + 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.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: + def send_sms(self, message: str, tag: str | None = None) -> bool: + tag = tag or f"monitor-{self.site.lower()}" if not self.ovh_enabled or not self.sms_labeled: log.warning("SMS désactivé ou aucun destinataire.") return False @@ -363,8 +427,7 @@ class Notifier: log.warning("ALERT_SMS_ONLY filtre tous les destinataires (aucun envoi).") return False - # ✅ Normalisation GSM-7 + préfixe site - message = normaliser_sms(message, prefix=SITE) + message = normaliser_sms(message, prefix=self.site) payload = { "sender": self.ovh_sender, @@ -379,31 +442,26 @@ 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])) + 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 + log.exception("Erreur API OVH: %s", err) + return False except Exception as err: - log.exception("Echec envoi SMS OVH: %s", err); return False + 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: + def send_sms_client(self, message: str, tag: str | None = None) -> bool: + tag = tag or f"monitor-client-{self.site.lower()}" if not self.ovh_enabled: - log.warning("SMS client: OVH non configuré."); return False + 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 + log.info("SMS client: désactivé ou aucun destinataire.") + return False only = os.getenv("ALERT_SMS_CLIENT_ONLY") if only: @@ -414,9 +472,10 @@ class Notifier: receivers = [num for (_n, num) in labeled] if not receivers: - log.info("SMS client: filtre vide → aucun envoi."); return False + log.info("SMS client: filtre vide → aucun envoi.") + return False - message = normaliser_sms(message, prefix=SITE) + message = normaliser_sms(message, prefix=self.site) payload = { "sender": self.ovh_sender, @@ -436,11 +495,13 @@ class Notifier: 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 + 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 + log.warning("SMTP non configuré, email non envoyé.") + return False msg = EmailMessage() msg["From"] = self.mail_from @@ -448,48 +509,42 @@ 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(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) + with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, 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() + 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() + _send_starttls() 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 + log.exception("Erreur SMTP: %s", err) + return False except Exception as err: - log.exception("Echec envoi email: %s", err); return False + 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) +# ========= Messages ========= 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" @@ -498,11 +553,12 @@ def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt. 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.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" @@ -511,17 +567,18 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat 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.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 ========= + +# ========= MQTT Gyrophare ========= class MQTTPublisher: def __init__(self, site: str): self.enabled = bool(_mqtt_ok) @@ -537,10 +594,6 @@ class MQTTPublisher: 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")) @@ -548,14 +601,14 @@ class MQTTPublisher: pwd = os.getenv("MQTT_PASS") tls = (os.getenv("MQTT_TLS", "0") == "1") - # --- Création du client MQTT : compatible paho 1.x et 2.x --- + # Compat paho 1.x / 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) + 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() @@ -563,7 +616,6 @@ class MQTTPublisher: self.client = mqtt.Client() else: self.client = mqtt.Client() - # ------------------------------------------------------------ if user and pwd: self.client.username_pw_set(user, pwd) @@ -571,12 +623,9 @@ class MQTTPublisher: 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 @@ -586,7 +635,7 @@ class MQTTPublisher: subs = [t.strip() for t in subs_env.split(",") if t.strip()] if not subs: subs = [ - self.topic, # ex: Sondes/Saclay/Gyro/cmd + self.topic, f"Sondes/{site}/Gyro/#", f"{site}/Gyro/#", "Gyro/#", @@ -604,16 +653,14 @@ class MQTTPublisher: 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") + 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( @@ -623,13 +670,13 @@ class MQTTPublisher: payload_raw=payload_raw, qos=getattr(msg, "qos", None), retained=getattr(msg, "retain", None), - when=now_paris() + when=now_paris(), ) except Exception as e: log.exception("Insert Gyro échoué: %s", e) - return # ne pas poursuivre vers un parse température ici + return - # 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs) + # sinon ignorer try: float(payload_raw.replace(",", ".")) except ValueError: @@ -647,35 +694,37 @@ class MQTTPublisher: 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) + + if getattr(r, "rc", 0) != 0: + log.warning("MQTT publish rc=%s (topic=%s)", getattr(r, "rc", None), self.topic) + return + + log.info("Gyro %s -> %s (MQTT)", self.site, payload) + try: + insert_gyro_log( + lieu=self.site, + etat=payload, + topic=self.topic, + payload_raw=payload, + qos=2, + retained=1, + when=now_paris(), + ) + except Exception as e: + log.exception("Insert événement gyro DB é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 +# ========= Contrôleur Gyro réactif ========= class _GyroState(enum.Enum): IDLE = 0 PULSE_ON = 1 COOLDOWN = 2 + class GyroPulseController: """ Boucle rapide indépendante : @@ -683,10 +732,11 @@ class GyroPulseController: - MODE PULSE : ON (PULSE_SEC) puis OFF (COOLDOWN_SEC), tant que l’alarme persiste. Ajouts : - - SMS ALERTE immédiat à l’allumage - - SMS OK immédiat à l’extinction (activé par défaut) + - SMS ALERTE immédiat à l’allumage (option) + - SMS OK immédiat à l’extinction (option) """ - def __init__(self, site: str, beacon, notifier, *, + + def __init__(self, site: str, beacon: MQTTPublisher, notifier: 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")), @@ -704,16 +754,14 @@ 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 - # Anti-spam SMS & SMS OK activé par défaut - self._last_sms: dict[str, float] = {} # {sonde: ts dernier envoi} + self._last_sms: dict[str, float] = {} 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._send_ok = _env_bool("ALERT_OK_SMS_GYRO", True) - # Conserver le dernier déclencheur (pour SMS OK) - self._last_trigger: tuple[str, float, float] | None = None # (sonde, temp, seuil) + self._last_trigger: tuple[str, float, float] | None = None def _set_gyro(self, on: bool): if self._current is not on: @@ -751,35 +799,34 @@ class GyroPulseController: 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 + # Température courante (best-effort) 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 + curr_temp = float(r["Temperature"]) + break if curr_temp is None: - curr_temp = seuil - 0.1 # fallback léger + curr_temp = seuil - 0.1 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 + self._last_trigger = None 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) + 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() + now_ts = time.time() try: active, trigger = self._is_alarm_now() except Exception as e: @@ -789,16 +836,19 @@ class GyroPulseController: if self.state == _GyroState.IDLE: if active: self._set_gyro(True) - self._t_pulse_end = now + self.pulse_sec + self._t_pulse_end = now_ts + 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 - if os.getenv("ALERT_INTERNAL_SMS_ENABLED", "0") == "1": + s, t, se, + "CONTINU" if _env_bool("GYRO_MODE_CONTINUOUS", True) else "PULSE") + + # SMS alerte immédiat (option) + if _env_bool("ALERT_INTERNAL_SMS_ENABLED", False): self._send_alert_sms(trigger) elif self.state == _GyroState.PULSE_ON: @@ -809,15 +859,14 @@ class GyroPulseController: 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": + if _env_bool("ALERT_OK_SMS_GYRO", False): 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: + if not _env_bool("GYRO_MODE_CONTINUOUS", True): + if now_ts >= self._t_pulse_end: self._set_gyro(False) - self._t_cooldown_end = now + self.cooldown_sec + self._t_cooldown_end = now_ts + self.cooldown_sec self.state = _GyroState.COOLDOWN log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec) @@ -830,50 +879,51 @@ class GyroPulseController: log.info("Gyro: retour IDLE (plus d’alerte)") else: self._normal_count = 0 - if now >= self._t_cooldown_end: + if now_ts >= self._t_cooldown_end: self._set_gyro(True) - self._t_pulse_end = now + self.pulse_sec + self._t_pulse_end = now_ts + 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): +# ========= Notifs haut-niveau ========= +def notifier_sur_depassement(notifier: Notifier, 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.) + + SMS CLIENT couplé (si activé). + (Le SMS interne immédiat est géré 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) + notifier.send_email(subject, email_body) - # ➕ SMS client couplé au mail 30 min - if os.getenv("ALERT_SMS_CLIENT_ENABLED", "1") == "1": + if _env_bool("ALERT_SMS_CLIENT_ENABLED", True): client_msg = build_client_alert_sms(site, sonde, temp, seuil) - notifier.send_sms_client(client_msg, tag=f"client-{SITE.lower()}") + notifier.send_sms_client(client_msg, tag=f"client-{site.lower()}") -def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float): + +def notifier_acquittement(notifier: Notifier, site: str, sonde: str, temp: float, seuil: float): """ MAIL lorsque l’alerte est acquittée en base. - (Le SMS "OK" est envoyé immédiatement par la boucle gyro.) + (Le SMS OK est géré par la boucle gyro si activé.) """ 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_email(subject, email_body) + + if _env_bool("ALERT_OK_SMS", False): notifier.send_sms(sms_text) + # ========= Cycle & boucle ========= -def run_monitor_cycle(site: str = SITE): +def run_monitor_cycle(site: str, notifier: Notifier): + site = safe_site(site) + # 1) Lecture dernières mesures + config last_rows = lire_sondes_depuis_db(site) - cfg = lire_cfg_chambres(site) + cfg = lire_cfg_chambres(site) - # 2) Info: état instantané (le gyro est piloté par la boucle rapide) + # 2) Info: état instantané (gyro 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: @@ -884,54 +934,73 @@ def run_monitor_cycle(site: str = SITE): 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)} + # 3) Alertes temporisées (≥30 min) : uniquement sur sondes configurées, actives, non entretien + table_alertes = f"Alertes_{site}" for r in last_rows: nom = str(r["Sonde"]) temp = float(r["Temperature"]) - seuil = float(seuils.get(nom, 6.0)) + + meta = cfg.get(nom) + if not meta: + continue + if (not meta["active"]) or meta["entretien"]: + continue + + seuil = float(meta["temp_max"]) 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) # MAIL + SMS client + if open_alert(conn, table_alertes, nom, now_): + notifier_sur_depassement(notifier, site, nom, temp, seuil) finally: - conn.close() + 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) # MAIL acquittement + if close_alert(conn, table_alertes, nom): + notifier_acquittement(notifier, site, nom, temp, seuil) finally: - conn.close() + 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 +def run_monitor_loop(site: str, period_sec: int = 300): + site = safe_site(site) + notifier = Notifier(site) + beacon = MQTTPublisher(site) + + log.info("%s démarré (site=%s, période=%ss) ✅", f"Monitor_{site}", site, period_sec) + + # Boucle rapide gyro try: - global _gyro_controller - _gyro_controller = GyroPulseController(site, beacon, notifier) - _gyro_controller.start() + gyro_controller = GyroPulseController(site, beacon, notifier) + gyro_controller.start() except Exception as e: log.exception("Impossible de démarrer le GyroPulseController: %s", e) + # Boucle lente while True: t0 = time.time() try: - run_monitor_cycle(site) + run_monitor_cycle(site, notifier) 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("--site", default=os.getenv("SITE", SITE), help="Nom du site (ex: Meudon, Saclay, Roissy)") p.add_argument("--period", type=int, default=300) p.add_argument("--test-sms", action="store_true") p.add_argument("--test-mail", action="store_true") @@ -940,17 +1009,19 @@ if __name__ == "__main__": p.add_argument("--once", action="store_true") args = p.parse_args() + site = safe_site(args.site) + notifier = Notifier(site) + if args.test_sms: - n = Notifier() - n.send_sms("TEST DOMO91 (transactionnel)") + notifier.send_sms("TEST DOMO91 (transactionnel)") elif args.test_mail: - notifier.send_email(f"[TEST {SITE}] Mail", "OK") + notifier.send_email(f"[TEST {site}] Mail", "OK") elif args.test_alert: - notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0) + notifier_sur_depassement(notifier, site, "Congelateur", -14.5, -15.0) elif args.test_ok: - notifier_acquittement(SITE, "Congelateur", -15.2, -15.0) + notifier_acquittement(notifier, site, "Congelateur", -15.2, -15.0) else: if args.once: - run_monitor_cycle(SITE) + run_monitor_cycle(site, notifier) else: - run_monitor_loop(SITE, period_sec=args.period) + run_monitor_loop(site, period_sec=args.period) diff --git a/app/Mqtt_meudon.py b/app/Mqtt_meudon.py index e7c5e29..b12f084 100644 --- a/app/Mqtt_meudon.py +++ b/app/Mqtt_meudon.py @@ -27,11 +27,11 @@ DB_USER = os.getenv("DB_USER") DB_PASS = os.getenv("DB_PASS") DB_NAME = os.getenv("DB_NAME") -# --- MQTT Meudon --- -MQTT_HOST = os.getenv("MQTT_HOST_MEUDON") -MQTT_USER = os.getenv("MQTT_USER_MEUDON") -MQTT_PASS = os.getenv("MQTT_PASS_MEUDON") -MQTT_PORT = int(os.getenv("MQTT_PORT_MEUDON", "1883")) +# --- MQTT --- +MQTT_HOST = os.getenv("MQTT_HOST") +MQTT_USER = os.getenv("MQTT_USER") +MQTT_PASS = os.getenv("MQTT_PASS") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) # Client ID (configurable, sinon suffixé avec le hostname) MQTT_CLIENT_ID = os.getenv( diff --git a/app/Mqtt_saclay.py b/app/Mqtt_saclay.py index 0b30c5a..212888f 100644 --- a/app/Mqtt_saclay.py +++ b/app/Mqtt_saclay.py @@ -26,9 +26,9 @@ DB_USER = os.getenv("DB_USER") DB_PASS = os.getenv("DB_PASS") DB_NAME = os.getenv("DB_NAME") -MQTT_HOST = os.getenv("MQTT_HOST") -MQTT_USER = os.getenv("MQTT_USER") -MQTT_PASS = os.getenv("MQTT_PASS") +MQTT_HOST = "54.36.188.119" +MQTT_USER = "Bwps" +MQTT_PASS = "scJ5ACj2keRfI^" MQTT_PORT = int(os.getenv("MQTT_PORT", 1883)) GYRO_TOPIC_SACLAY = os.getenv("GYRO_MQTT_TOPIC_SACLAY", "Saclay/gyrophare") diff --git a/app/domo91.py b/app/domo91.py index fbc55d4..5e02d1e 100644 --- a/app/domo91.py +++ b/app/domo91.py @@ -3,19 +3,16 @@ import os import random import traceback from datetime import datetime, date, time +from contextlib import closing import bcrypt import matplotlib.dates as mdates import matplotlib.pyplot as plt import mysql.connector import pandas as pd -pd.set_option('future.no_silent_downcasting', True) +pd.set_option("future.no_silent_downcasting", True) import streamlit as st -from contextlib import closing from dotenv import find_dotenv, load_dotenv -env_file = find_dotenv(usecwd=True) -if env_file: - load_dotenv(env_file) from fpdf import FPDF # ========================================================= @@ -28,7 +25,10 @@ st.write("Bienvenue sur l’application de supervision.") # ========================================================= # ENV & DB # ========================================================= -load_dotenv() +env_file = find_dotenv(usecwd=True) +if env_file: + load_dotenv(env_file) + db_config = { "host": os.getenv("DB_HOST"), "user": os.getenv("DB_USER"), @@ -37,15 +37,51 @@ db_config = { "autocommit": False, } -SITES_AUTORISES = {"Saclay", "Meudon", "Roissy"} # anti-injection sur noms de tables +# Roissy n'existe pas actuellement => on garde Saclay / Meudon +SITES_AUTORISES = {"Saclay", "Meudon"} # anti-injection sur noms de tables +SITES_LISTE = sorted(SITES_AUTORISES) + def get_connection(): return mysql.connector.connect(**db_config) -# --- Gyro: lecture + badge (auto) --- +def assert_site_ok(site: str): + if site not in SITES_AUTORISES: + raise ValueError(f"Site invalide: {site}") + + +# ========================================================= +# Session state +# ========================================================= +for key, default in { + "authenticated": False, + "role": None, + "site_autorise": None, + "onglet_actif": "Accueil", + "selected_date": date.today(), + "selected_site": "Saclay", + "selected_periode": "Toute la journée", +}.items(): + st.session_state.setdefault(key, default) + +# ========================================================= +# Sécurité mots de passe +# ========================================================= +def hash_password(plain_password: str) -> str: + return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verifier_password(input_password: str, hash_en_base: str) -> bool: + return bcrypt.checkpw(input_password.encode("utf-8"), hash_en_base.encode("utf-8")) + + +# ========================================================= +# Gyro: lecture + badge +# ========================================================= def fetch_gyro(site: str): """Retourne (etat, ts) depuis la vue v_gyro_last pour le site donné.""" + assert_site_ok(site) q = """ SELECT Etat, `Date` FROM Sondes.v_gyro_last @@ -53,9 +89,7 @@ def fetch_gyro(site: str): ORDER BY `Date` DESC LIMIT 1 """ - cnx = get_connection() - try: - cur = cnx.cursor(dictionary=True) + with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: cur.execute(q, (site,)) row = cur.fetchone() if not row: @@ -63,36 +97,25 @@ def fetch_gyro(site: str): etat = (row.get("Etat") or "").strip().upper() ts = row.get("Date") return etat, ts - finally: - try: - cur.close() - except Exception: - pass - try: - cnx.close() - except Exception: - pass + def render_gyro_badge(site: str, stale_after_min: int = 10): """Affiche un voyant Gyro (vert/rouge/orange) + fraîcheur des données.""" etat, ts = fetch_gyro(site) - # Etat → couleur/label if etat in ("ON", "1"): - color, label = "#ef4444", "GYRO ON" # Rouge = gyro actif + color, label = "#ef4444", "GYRO ON" elif etat in ("OFF", "0"): - color, label = "#22c55e", "GYRO OFF" # Vert = gyro arrêté + color, label = "#22c55e", "GYRO OFF" elif etat in ("ALERTE", "ALARM", "ALARMED"): - color, label = "#f59e0b", "GYRO ALERTE" # Orange = alerte + color, label = "#f59e0b", "GYRO ALERTE" else: color, label = "#9E9E9E", "GYRO INCONNU" - # Fraîcheur stale = True age_txt = "—" if ts is not None: try: - # ts provient normalement de MySQL déjà en datetime from datetime import datetime as _dt now = _dt.now(ts.tzinfo) if hasattr(ts, "tzinfo") and ts.tzinfo else _dt.now() mins = int((now - ts).total_seconds() // 60) @@ -117,37 +140,10 @@ def render_gyro_badge(site: str, stale_after_min: int = 10): """, unsafe_allow_html=True) -def get_conn(): - return mysql.connector.connect(**db_config) + # ========================================================= -# Session state -# ========================================================= -for key, default in { - "authenticated": False, - "role": None, - "site_autorise": None, - "onglet_actif": "Accueil", - "selected_date": date.today(), - "selected_site": "Saclay", - "selected_periode": "Toute la journée", -}.items(): - st.session_state.setdefault(key, default) - -# ========================================================= -# Sécurité mots de passe -# ========================================================= -def hash_password(plain_password): - return bcrypt.hashpw(plain_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") - -def verifier_password(input_password, hash_en_base): - return bcrypt.checkpw(input_password.encode("utf-8"), hash_en_base.encode("utf-8")) - -# ========================================================= -# Bootstrap schéma : crée Journal_Erreurs si absent -# Gère 2 cas : -# - VERSION A : colonne générée (recommandée) -# - VERSION B (fallback) : colonne simple + triggers pour normaliser Source_Id_norm +# Bootstrap schéma : Journal_Erreurs # ========================================================= def ensure_schema(): ddl_generated = """ @@ -198,7 +194,7 @@ def ensure_schema(): ] triggers_fallback = [ """ - CREATE TRIGGER IF NOT EXISTS trg_je_bi + CREATE TRIGGER trg_je_bi BEFORE INSERT ON Sondes.Journal_Erreurs FOR EACH ROW BEGIN @@ -206,7 +202,7 @@ def ensure_schema(): END """, """ - CREATE TRIGGER IF NOT EXISTS trg_je_bu + CREATE TRIGGER trg_je_bu BEFORE UPDATE ON Sondes.Journal_Erreurs FOR EACH ROW BEGIN @@ -216,19 +212,17 @@ def ensure_schema(): ] try: - with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor()) as cur: cur.execute(ddl_generated) cnx.commit() except Exception: - # fallback si la colonne générée n'est pas supportée - with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor()) as cur: cur.execute(ddl_fallback) for q in idxs_fallback: try: cur.execute(q) except Exception: pass - # MariaDB/MySQL n'ont pas tous IF NOT EXISTS sur triggers → on tente drop/try for name in ("trg_je_bi", "trg_je_bu"): try: cur.execute(f"DROP TRIGGER IF EXISTS {name}") @@ -241,12 +235,13 @@ def ensure_schema(): pass cnx.commit() -# Exécution à l’import + try: ensure_schema() except Exception as e: st.warning(f"Init schéma Journal_Erreurs : {e}") + # ========================================================= # Connexion utilisateur # ========================================================= @@ -256,106 +251,102 @@ if not st.session_state.get("authenticated", False): if st.sidebar.button("Se connecter"): try: - conn = get_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration - FROM Acces.Utilisateurs - WHERE NomUtilisateur = %s - LIMIT 1 - """, - (login,), - ) - result = cursor.fetchone() - - if not result: - st.sidebar.error("Identifiants invalides") - elif result["DateExpiration"] and result["DateExpiration"] < date.today(): - st.sidebar.error("⛔ Accès expiré.") - elif not verifier_password(password, result["MotDePasseHash"]): - st.sidebar.error("Identifiants invalides") - else: - st.session_state.update({ - "authenticated": True, - "role": result["role"], - "site_autorise": result["Site"], - "onglet_actif": "Accueil", # 👈 reset - }) - now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor: cursor.execute( """ - INSERT INTO Sondes.Connexion_Log (Utilisateur, Lieu, Date_Connexion) - VALUES (%s, %s, %s) + SELECT NomUtilisateur, role, MotDePasseHash, Site, DateExpiration + FROM Acces.Utilisateurs + WHERE NomUtilisateur = %s + LIMIT 1 """, - (result["NomUtilisateur"], result["Site"], now_str), + (login,), ) - conn.commit() - st.rerun() + result = cursor.fetchone() + + if not result: + st.sidebar.error("Identifiants invalides") + elif result["DateExpiration"] and result["DateExpiration"] < date.today(): + st.sidebar.error("⛔ Accès expiré.") + elif not verifier_password(password, result["MotDePasseHash"]): + st.sidebar.error("Identifiants invalides") + else: + st.session_state.update({ + "authenticated": True, + "role": result["role"], + "site_autorise": result["Site"], + "onglet_actif": "Accueil", + }) + + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cursor.execute( + """ + INSERT INTO Sondes.Connexion_Log (Utilisateur, Lieu, Date_Connexion) + VALUES (%s, %s, %s) + """, + (result["NomUtilisateur"], result["Site"], now_str), + ) + conn.commit() + st.rerun() - cursor.close() - conn.close() except Exception as e: st.sidebar.error(f"Erreur connexion : {e}") + st.sidebar.text(traceback.format_exc()) else: st.sidebar.success(f"Connecté ({st.session_state.get('role')})") if st.sidebar.button("🔓 Déconnexion"): for key in ["authenticated", "role", "site_autorise"]: st.session_state[key] = False if key == "authenticated" else None - st.session_state["onglet_actif"] = "Accueil" # 👈 reset + st.session_state["onglet_actif"] = "Accueil" st.rerun() + # ========================================================= # PDF # ========================================================= -def generer_pdf(site, date_str, periode): +def generer_pdf(site: str, date_str: str, periode: str): + assert_site_ok(site) st.info(f"Génération du rapport PDF pour {site} à la date {date_str} ({periode})") + + plages = { + "Toute la journée": (time(0, 0), time(23, 59)), + "Matin (6h-12h)": (time(6, 0), time(12, 0)), + "Après-midi (12h-18h)": (time(12, 0), time(18, 0)), + "Nuit (18h-6h)": (time(18, 0), time(6, 0)), + } + try: - conn = get_connection() - pdf_cursor = conn.cursor(dictionary=True) + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cur: + cur.execute( + f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", + (date_str,), + ) + rows = cur.fetchall() - # Relevés - pdf_cursor.execute( - f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", - (date_str,), - ) - rows = pdf_cursor.fetchall() - df = pd.DataFrame(rows) - if df.empty: - st.warning("Aucune donnée ce jour.") - return + df = pd.DataFrame(rows) + if df.empty: + st.warning("Aucune donnée ce jour.") + return - df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M") - df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time + df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M") + df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time - # Périodes - plages = { - "Toute la journée": (time(0, 0), time(23, 59)), - "Matin (6h-12h)": (time(6, 0), time(12, 0)), - "Après-midi (12h-18h)": (time(12, 0), time(18, 0)), - "Nuit (18h-6h)": (time(18, 0), time(6, 0)), - } - heure_debut, heure_fin = plages.get(periode, (time(0, 0), time(23, 59))) - if heure_debut < heure_fin: - df = df[(df["Heure_obj"] >= heure_debut) & (df["Heure_obj"] <= heure_fin)] - else: - df = df[(df["Heure_obj"] >= heure_debut) | (df["Heure_obj"] <= heure_fin)] + heure_debut, heure_fin = plages.get(periode, (time(0, 0), time(23, 59))) + if heure_debut < heure_fin: + df = df[(df["Heure_obj"] >= heure_debut) & (df["Heure_obj"] <= heure_fin)] + else: + df = df[(df["Heure_obj"] >= heure_debut) | (df["Heure_obj"] <= heure_fin)] - releves = {} - for sonde in sorted(df["Sonde"].unique()): - df_sonde = df[df["Sonde"] == sonde] - releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"])) + releves = {} + for sonde in sorted(df["Sonde"].unique()): + df_sonde = df[df["Sonde"] == sonde] + releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"])) - # Alertes - table_alertes = f"Alertes_{site}" - pdf_cursor.execute( - f"SELECT Sonde, Debut_defaut, Etat FROM `{table_alertes}` WHERE DATE(Debut_defaut) = %s", - (date_str,), - ) - alertes = pdf_cursor.fetchall() - - pdf_cursor.close() - conn.close() + table_alertes = f"Alertes_{site}" + cur.execute( + f"SELECT Sonde, Debut_defaut, Etat FROM `{table_alertes}` WHERE DATE(Debut_defaut) = %s", + (date_str,), + ) + alertes = cur.fetchall() class RapportPDF(FPDF): def header(self): @@ -397,7 +388,9 @@ def generer_pdf(site, date_str, periode): self.cell(30, 6, f"{t1:.2f}", border=1) else: self.cell(70, 6, "", border=0) + self.cell(20, 6, "", border=0) + if i < len(col2): h2, t2 = col2[i] self.cell(40, 6, h2, border=1) @@ -436,11 +429,13 @@ def generer_pdf(site, date_str, periode): mime="application/pdf", ) - except Exception as err1: - st.error(f"Erreur lors de la génération du PDF : {err1}") + except Exception as err: + st.error(f"Erreur lors de la génération du PDF : {err}") + st.text(traceback.format_exc()) + # ========================================================= -# Fonctions Journal erreurs (SQL) +# Journal erreurs (SQL) # ========================================================= def _get_site_courant(): role = st.session_state.get("role") @@ -448,7 +443,9 @@ def _get_site_courant(): return st.session_state.get("site_autorise") return st.session_state.get("selected_site", "Saclay") + def load_alertes(site: str, jour: date): + assert_site_ok(site) table_alertes = f"Alertes_{site}" q = f""" SELECT @@ -463,22 +460,18 @@ def load_alertes(site: str, jour: date): WHERE DATE(a.Debut_defaut) = %s OR (a.Etat <> 'Acquitté' AND DATE(a.Debut_defaut) <= %s); """ - with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: cur.execute(q, (site, jour, jour)) rows = cur.fetchall() - cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume","Etat"] + cols = ["Source_Id", "Site", "Sonde", "DateJour", "Type", "Resume", "Etat"] return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + def load_anomalies_auto(site: str, jour: date): - """ - Détection d'anomalies "Auto" sur la date choisie : - - GAPS : vrais trous = diff entre 2 mesures consécutives >= gap_threshold_min - - JUMPS : saut de température > jump_deg entre 2 mesures consécutives - - BOUNDS : valeurs hors bornes physiques - """ + assert_site_ok(site) table_mesures = site - gap_threshold_min = 20 # seuil "trou" (ex. mesures toutes 5 min → 20 min = 4 créneaux manqués) - jump_deg = 10 # saut suspect + gap_threshold_min = 20 + jump_deg = 10 min_phys, max_phys = -60, 120 q = f""" @@ -527,37 +520,40 @@ def load_anomalies_auto(site: str, jour: date): UNION ALL SELECT NULL, %s, j.Sonde, %s, 'Auto', - CONCAT('Sauts de température suspects : ', nb_jumps, ' (max ', ROUND(max_jump,1), '°C)') + CONCAT('Sauts de température suspects : ', nb_jumps, ' (max ', ROUND(max_jump,1), '°C)') FROM jumps j UNION ALL SELECT NULL, %s, b.Sonde, %s, 'Auto', - CONCAT('Valeurs hors bornes physiques (min=', ROUND(tmin,1), '°C, max=', ROUND(tmax,1), '°C)') + CONCAT('Valeurs hors bornes physiques (min=', ROUND(tmin,1), '°C, max=', ROUND(tmax,1), '°C)') FROM bounds b ORDER BY Sonde; """ params = (jour, site, jour, site, jour, site, jour) - with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: cur.execute(q, params) rows = cur.fetchall() - cols = ["Source_Id","Site","Sonde","DateJour","Type","Resume"] + cols = ["Source_Id", "Site", "Sonde", "DateJour", "Type", "Resume"] return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) def load_journal_existants(site: str, jour: date): + assert_site_ok(site) q = """ SELECT Id, Site, Sonde, DateJour, Type, Source_Id, Resume, Statut, Priorite, Assignation, Commentaire, Tag FROM Sondes.Journal_Erreurs WHERE Site=%s AND DateJour=%s; """ - with closing(get_conn()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor(dictionary=True)) as cur: cur.execute(q, (site, jour)) rows = cur.fetchall() - cols = ["Id","Site","Sonde","DateJour","Type","Source_Id","Resume","Statut","Priorite","Assignation","Commentaire","Tag"] + cols = ["Id", "Site", "Sonde", "DateJour", "Type", "Source_Id", "Resume", + "Statut", "Priorite", "Assignation", "Commentaire", "Tag"] return pd.DataFrame(rows, columns=cols) if rows else pd.DataFrame(columns=cols) + def upsert_journal(rows: list[dict]): if not rows: return @@ -575,15 +571,17 @@ def upsert_journal(rows: list[dict]): Tag=VALUES(Tag), UpdatedAt=CURRENT_TIMESTAMP; """ - with closing(get_conn()) as cnx, closing(cnx.cursor()) as cur: + with closing(get_connection()) as cnx, closing(cnx.cursor()) as cur: cur.executemany(q_insert, rows) cnx.commit() + # ========================================================= # Page Journal erreurs # ========================================================= def page_journal_erreurs(): st.header("📝 Journal des erreurs") + site = _get_site_courant() if not site: st.warning("Veuillez d’abord choisir un site sur la page d’accueil.") @@ -595,64 +593,78 @@ def page_journal_erreurs(): jour = st.date_input("Date de vision", value=st.session_state.get("selected_date", date.today())) st.caption(f"Site : {site} — Date : {jour.strftime('%d/%m/%Y')}") - # --- Chargement des sources df_alertes = load_alertes(site, jour) - df_auto = load_anomalies_auto(site, jour) - df_saved = load_journal_existants(site, jour) + df_auto = load_anomalies_auto(site, jour) + df_saved = load_journal_existants(site, jour) base = pd.concat([df_auto, df_alertes], ignore_index=True) if base.empty and df_saved.empty: st.info("Aucune anomalie détectée ni alerte pour cette date.") return - # Colonnes attendues - for col in ["Statut","Priorite","Assignation","Commentaire","Tag","Id","Source_Id"]: - if col not in base.columns: - base[col] = pd.NA + def _source_norm(x): + return 0 if pd.isna(x) else int(x) - key_join = ["Site","Sonde","DateJour","Type","Source_Id","Resume"] - df = base.merge(df_saved, on=key_join, how="left", suffixes=("","_saved")) + # base : garantir Source_Id + Source_Id_norm + if "Source_Id" not in base.columns: + base["Source_Id"] = pd.NA + base["Source_Id_norm"] = base["Source_Id"].apply(_source_norm) - # Garantir l'existence des *_saved si df_saved est vide - for c in ["Statut_saved","Priorite_saved","Assignation_saved","Commentaire_saved", - "Tag_saved","Id_saved","Source_Id_saved"]: - if c not in df.columns: - df[c] = pd.NA + # df_saved : garantir colonnes et Key, même si vide + if df_saved is None or df_saved.empty: + df_saved = pd.DataFrame(columns=["Key", "Statut", "Priorite", "Assignation", "Commentaire", "Tag"]) + else: + if "Source_Id" not in df_saved.columns: + df_saved["Source_Id"] = pd.NA + df_saved["Source_Id_norm"] = df_saved["Source_Id"].apply(_source_norm) - # Valeurs par défaut depuis sauvegarde - df["Statut"] = df["Statut"].fillna(df["Statut_saved"]).fillna("Nouveau") - df["Priorite"] = df["Priorite"].fillna(df["Priorite_saved"]).fillna(3) - df["Assignation"] = df["Assignation"].fillna(df["Assignation_saved"]) - df["Commentaire"] = df["Commentaire"].fillna(df["Commentaire_saved"]) - df["Tag"] = df["Tag"].fillna(df["Tag_saved"]) - df["Id"] = df["Id"].fillna(df["Id_saved"]) - df["Source_Id"] = df["Source_Id"].fillna(df["Source_Id_saved"]) + df_saved["Key"] = ( + df_saved["Site"].astype(str) + "|" + + df_saved["Sonde"].astype(str) + "|" + + df_saved["DateJour"].astype(str) + "|" + + df_saved["Type"].astype(str) + "|" + + df_saved["Source_Id_norm"].astype(str) + ) + for c in ["Statut", "Priorite", "Assignation", "Commentaire", "Tag"]: + if c not in df_saved.columns: + df_saved[c] = pd.NA - # --- Types compatibles pour l'éditeur --- - text_cols = ["Sonde", "Type", "Resume", "Statut", "Assignation", "Tag", "Commentaire"] - for c in text_cols: - if c not in df.columns: - df[c] = pd.Series(dtype="string") # colonne vide typée texte - else: - df[c] = df[c].astype("string").fillna("") # tout en string, pas de NaN + # base Key (toujours) + base["Key"] = ( + base["Site"].astype(str) + "|" + + base["Sonde"].astype(str) + "|" + + base["DateJour"].astype(str) + "|" + + base["Type"].astype(str) + "|" + + base["Source_Id_norm"].astype(str) + ) - # Priorité = entier nullable (évite 3.0) + df = base.merge( + df_saved[["Key", "Statut", "Priorite", "Assignation", "Commentaire", "Tag"]], + on="Key", + how="left", + ) + + # Defaults & types + df["Statut"] = df["Statut"].fillna("Nouveau").astype("string") df["Priorite"] = pd.to_numeric(df["Priorite"], errors="coerce").fillna(3).astype("Int64") - # --- Éditeur - st.subheader("Synthèse (éditable)") - edit_cols = ["Sonde","Type","Resume","Statut","Priorite","Assignation","Tag","Commentaire"] - disabled_cols = ["Sonde","Type","Resume"] + for c in ["Assignation", "Commentaire", "Tag"]: + df[c] = df[c].astype("string").fillna("") + + for c in ["Sonde", "Type", "Resume"]: + df[c] = df[c].astype("string").fillna("") st.subheader("Synthèse (éditable)") - edit_cols = ["Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"] - disabled_cols = ["Sonde", "Type", "Resume"] + + view_cols = ["Key", "Sonde", "Type", "Resume", "Statut", "Priorite", "Assignation", "Tag", "Commentaire"] + disabled_cols = ["Key", "Sonde", "Type", "Resume"] editable = st.data_editor( - df[edit_cols].copy(), # <<< important + df[view_cols].copy(), disabled=disabled_cols, use_container_width=True, hide_index=True, column_config={ + "Key": st.column_config.TextColumn("Key", disabled=True), "Statut": st.column_config.SelectboxColumn( "Statut", options=["Nouveau", "En cours", "Planifié", "Clos"], help="État de la tâche" ), @@ -668,18 +680,19 @@ def page_journal_erreurs(): } ) - # --- Sauvegarde def _none_if_empty(x): - if x is None: return None - if isinstance(x, float) and pd.isna(x): return None - if isinstance(x, str) and x.strip() == "": return None + if x is None: + return None + if isinstance(x, float) and pd.isna(x): + return None + if isinstance(x, str) and x.strip() == "": + return None return x if st.session_state.get("role") == "superviseur": if st.button("💾 Enregistrer les modifications"): - # On rattache les clés (Site/Sonde/DateJour/Type/Source_Id/Resume) aux lignes éditées - df_keys = df[["Site","Sonde","DateJour","Type","Source_Id","Resume"]] - df_to_save = editable.merge(df_keys, on=["Sonde","Type","Resume"], how="left") + df_keys = df[["Key", "Site", "Sonde", "DateJour", "Type", "Source_Id", "Resume"]].copy() + df_to_save = editable.merge(df_keys, on="Key", how="left") payload = [] for _, r in df_to_save.iterrows(): @@ -713,60 +726,58 @@ if st.session_state.get("authenticated"): if role != "superviseur" else st.session_state.get("selected_site", "Saclay") ) - if not site_selectionne: - st.info("Connectez-vous et choisissez un site pour afficher les alertes.") - else: - if site_selectionne not in SITES_AUTORISES: - raise ValueError(f"Site invalide: {site_selectionne}") - conn = get_connection() - cursor = conn.cursor(dictionary=True) + if site_selectionne: + assert_site_ok(site_selectionne) table_alertes = f"Alertes_{site_selectionne}" - cursor.execute( - f"SELECT Sonde, Debut_defaut, Etat " - f"FROM `{table_alertes}` " - f"WHERE Etat != 'Acquitté' " - f"ORDER BY Debut_defaut DESC" - ) - alertes = cursor.fetchall() + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor: + cursor.execute( + f"SELECT Sonde, Debut_defaut, Etat " + f"FROM `{table_alertes}` " + f"WHERE Etat != 'Acquitté' " + f"ORDER BY Debut_defaut DESC" + ) + alertes = cursor.fetchall() + if alertes: - df_alertes = pd.DataFrame(alertes) st.subheader("🚨 Alertes non acquittées") - st.dataframe(df_alertes, use_container_width=True) + st.dataframe(pd.DataFrame(alertes), use_container_width=True) else: st.success("✅ Aucune alerte en cours.") - cursor.close() - conn.close() + else: + st.info("Connectez-vous et choisissez un site pour afficher les alertes.") except Exception as e: st.error(f"Erreur lors de la récupération des alertes : {e}") -else: - st.info("Connectez-vous pour voir les alertes en cours.") + st.text(traceback.format_exc()) + else: + pass # ========================================================= -# Navigation +# Navigation + Pages (CORRIGÉ : pour superviseur ET utilisateur) # ========================================================= -if st.session_state["authenticated"]: - onglets = ( - ["Accueil", "Entretien"] - if st.session_state["role"] != "superviseur" - else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"] - ) - onglets = ( - ["Accueil", "Entretien"] - if st.session_state["role"] != "superviseur" - else ["Accueil", "Statistiques", "Entretien", "Traffic", "Journal erreurs"] - ) +if st.session_state.get("authenticated"): - # 🔒 Normaliser l'onglet actif si absent de la liste (ex. compte non-admin) + # Onglets selon rôle + if st.session_state.get("role") != "superviseur": + onglets = ["Accueil", "Statistiques"] + else: + onglets = ["Accueil", "Statistiques", "Traffic", "Journal erreurs"] + + # Normaliser l'onglet actif if st.session_state.get("onglet_actif") not in onglets: st.session_state["onglet_actif"] = onglets[0] + # Menu (créé pour tous) onglet_selectionne = st.sidebar.radio( - "📁 Navigation", onglets, index=onglets.index(st.session_state["onglet_actif"]) + "📁 Navigation", + onglets, + index=onglets.index(st.session_state["onglet_actif"]), ) + st.session_state["onglet_actif"] = onglet_selectionne + # Contexte commun site_actuel = ( st.session_state.get("site_autorise") - if st.session_state["role"] != "superviseur" + if st.session_state.get("role") != "superviseur" else st.session_state.get("selected_site", "Saclay") ) date_selectionnee = st.session_state.get("selected_date", date.today()) @@ -774,72 +785,86 @@ if st.session_state["authenticated"]: # ------------------ Accueil ------------------ if onglet_selectionne == "Accueil": try: + # Site imposé ou sélection admin + if st.session_state.get("role") == "superviseur": + # sélection possible + if site_actuel not in SITES_LISTE: + site_actuel = SITES_LISTE[0] + site_actuel = st.selectbox( + "📍 Choisissez un site :", + SITES_LISTE, + index=SITES_LISTE.index(site_actuel) if site_actuel in SITES_LISTE else 0 + ) + st.session_state["selected_site"] = site_actuel + else: + st.info(f"Site imposé : {site_actuel}") - # --- Voyant Gyro pour le site courant --- + assert_site_ok(site_actuel) + + # Voyant Gyro st.subheader(f"🚨 Statut Gyro — {site_actuel}") try: st.autorefresh(interval=30000, key="gyro_autorefresh") except Exception: pass render_gyro_badge(site_actuel) - # ---------------------------------------- - - conn = get_connection() - cursor = conn.cursor(dictionary=True) - - if st.session_state["role"] == "superviseur": - site_actuel = st.selectbox("📍 Choisissez un site :", ["Saclay", "Meudon"], index=0) - st.session_state["selected_site"] = site_actuel - else: - st.info(f"Site imposé : {site_actuel}") + # Date date_selectionnee = st.date_input("📅 Date du relevé", value=date_selectionnee) st.session_state["selected_date"] = date_selectionnee - cursor.execute( - f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC", - (date_selectionnee.strftime("%Y-%m-%d"),), - ) - rows = cursor.fetchall() + rows = [] + df_sonde = pd.DataFrame() + seuil_temp = 10.0 + sonde_choisie = None - if rows: - df = pd.DataFrame(rows) - df["Date"] = pd.to_datetime(df["Date"]) - sondes = sorted(df["Sonde"].unique()) - sonde_choisie = st.selectbox("🧪 Choisissez une sonde :", sondes) - df_sonde = df[df["Sonde"] == sonde_choisie].copy() - df_sonde["Heure"] = df_sonde["Date"].dt.hour - - tranche = st.radio( - "🕒 Tranche horaire :", - ["Toute la journée", "Matin (6h-12h)", "Après-midi (12h-18h)", "Nuit (18h-6h)"], - ) - st.session_state["selected_periode"] = tranche - - # Génération PDF - if st.button("🧾 Générer le PDF du jour"): - generer_pdf( - site_actuel, - date_selectionnee.strftime("%Y-%m-%d"), - st.session_state.get("selected_periode", "Toute la journée"), - ) - - if tranche == "Matin (6h-12h)": - df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)] - elif tranche == "Après-midi (12h-18h)": - df_sonde = df_sonde[(df_sonde["Heure"] >= 12) & (df_sonde["Heure"] < 18)] - elif tranche == "Nuit (18h-6h)": - df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)] - - seuil_temp = 10 + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor: cursor.execute( - "SELECT Temp_Max FROM Sondes.Chambres_froides WHERE Lieu = %s AND Sonde = %s", - (site_actuel, sonde_choisie), + f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC", + (date_selectionnee.strftime("%Y-%m-%d"),), ) - seuil = cursor.fetchone() - if seuil: - seuil_temp = seuil["Temp_Max"] + rows = cursor.fetchall() + if rows: + df = pd.DataFrame(rows) + df["Date"] = pd.to_datetime(df["Date"]) + sondes = sorted(df["Sonde"].unique()) + sonde_choisie = st.selectbox("🧪 Choisissez une sonde :", sondes) + + df_sonde = df[df["Sonde"] == sonde_choisie].copy() + df_sonde["Heure"] = df_sonde["Date"].dt.hour + + tranche = st.radio( + "🕒 Tranche horaire :", + ["Toute la journée", "Matin (6h-12h)", "Après-midi (12h-18h)", "Nuit (18h-6h)"], + ) + st.session_state["selected_periode"] = tranche + + if st.button("🧾 Générer le PDF du jour"): + generer_pdf( + site_actuel, + date_selectionnee.strftime("%Y-%m-%d"), + st.session_state.get("selected_periode", "Toute la journée"), + ) + + # Filtre tranche + if tranche == "Matin (6h-12h)": + df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)] + elif tranche == "Après-midi (12h-18h)": + df_sonde = df_sonde[(df_sonde["Heure"] >= 12) & (df_sonde["Heure"] < 18)] + elif tranche == "Nuit (18h-6h)": + df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)] + + # Seuil + cursor.execute( + "SELECT Temp_Max FROM Sondes.Chambres_froides WHERE Lieu = %s AND Sonde = %s", + (site_actuel, sonde_choisie), + ) + seuil = cursor.fetchone() + if seuil and seuil.get("Temp_Max") is not None: + seuil_temp = float(seuil["Temp_Max"]) + + if rows and not df_sonde.empty: st.subheader("📊 Tableau des relevés") def surlignage_temp(val): @@ -852,6 +877,7 @@ if st.session_state["authenticated"]: styled_df = df_sonde.style.map(surlignage_temp, subset=["Temperature"]) st.dataframe(styled_df, use_container_width=True) + st.subheader("📈 Évolution de la température") fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o") @@ -862,9 +888,9 @@ if st.session_state["authenticated"]: ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) ax.legend() st.pyplot(fig) + elif not rows: + st.info("Aucun relevé pour cette date.") - cursor.close() - conn.close() except Exception as e: st.error(f"Erreur : {e}") st.text(traceback.format_exc()) @@ -872,21 +898,24 @@ if st.session_state["authenticated"]: # ------------------ Statistiques ------------------ elif onglet_selectionne == "Statistiques": st.markdown("## 📈 Statistiques de température") + site = ( - st.session_state["site_autorise"] - if st.session_state["role"] != "superviseur" + st.session_state.get("site_autorise") + if st.session_state.get("role") != "superviseur" else st.session_state.get("selected_site", "Saclay") ) + assert_site_ok(site) + date_val = st.session_state.get("selected_date", date.today()) try: - conn = get_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute( - f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", - (date_val.strftime("%Y-%m-%d"),), - ) - rows = cursor.fetchall() + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor: + cursor.execute( + f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", + (date_val.strftime("%Y-%m-%d"),), + ) + rows = cursor.fetchall() + df = pd.DataFrame(rows) if df.empty: st.info("Aucune donnée pour cette date.") @@ -895,6 +924,7 @@ if st.session_state["authenticated"]: sondes = sorted(df["Sonde"].unique()) sonde = st.selectbox("Choisir une sonde :", sondes, key="selectbox_stats") df_sonde = df[df["Sonde"] == sonde] + st.subheader("Évolution journalière") fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker="o") @@ -903,104 +933,90 @@ if st.session_state["authenticated"]: ax.set_ylabel("Température (°C)") ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) st.pyplot(fig) - cursor.close() - conn.close() + except Exception as e: st.error(f"Erreur chargement statistiques : {e}") + st.text(traceback.format_exc()) # Admin Chambres froides - if st.session_state["role"] == "superviseur": + if st.session_state.get("role") == "superviseur": with st.expander("🛠️ Gestion des chambres froides (administrateur)", expanded=True): if st.button("🔄 Actualiser la liste"): st.session_state["refresh_admin"] = random.randint(0, 9999) + try: - conn_admin = get_connection() - cursor_admin = conn_admin.cursor(dictionary=True) - cursor_admin.execute("SELECT * FROM Sondes.Chambres_froides WHERE Lieu = %s", (site,)) - chambres = cursor_admin.fetchall() - if not chambres: - st.warning("Aucune chambre froide pour ce site.") - else: - for chambre in chambres: - col1, col2, col3 = st.columns([3, 1, 2]) - with col1: - st.markdown(f"**{chambre['Sonde']}**") - with col2: - etat = st.checkbox( - "ON", - value=(chambre["Etat"] == "ON"), - key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}", - ) - new_etat = "ON" if etat else "OFF" - with col3: - temp_max = chambre["Temp_Max"] - moins, temp_display, plus = st.columns([1, 2, 1]) - with moins: - if st.button("▼", key=f"moins_{chambre['Id']}"): - temp_max -= 1 - with temp_display: - st.markdown( - f"
{temp_max}°C
", - unsafe_allow_html=True, + with closing(get_connection()) as conn_admin, closing(conn_admin.cursor(dictionary=True)) as cursor_admin: + cursor_admin.execute("SELECT * FROM Sondes.Chambres_froides WHERE Lieu = %s", (site,)) + chambres = cursor_admin.fetchall() + + if not chambres: + st.warning("Aucune chambre froide pour ce site.") + else: + for chambre in chambres: + col1, col2, col3 = st.columns([3, 1, 2]) + with col1: + st.markdown(f"**{chambre['Sonde']}**") + + with col2: + etat = st.checkbox( + "ON", + value=(chambre["Etat"] == "ON"), + key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}", ) - with plus: - if st.button("▲", key=f"plus_{chambre['Id']}"): - temp_max += 1 - if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]: - cursor_admin.execute( - "UPDATE Sondes.Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s", - (new_etat, temp_max, chambre["Id"]), - ) - conn_admin.commit() - st.success(f"{chambre['Sonde']} mise à jour") - cursor_admin.close() - conn_admin.close() + new_etat = "ON" if etat else "OFF" + + with col3: + temp_max = int(chambre["Temp_Max"]) + moins, temp_display, plus = st.columns([1, 2, 1]) + with moins: + if st.button("▼", key=f"moins_{chambre['Id']}"): + temp_max -= 1 + with temp_display: + st.markdown( + f"
{temp_max}°C
", + unsafe_allow_html=True, + ) + with plus: + if st.button("▲", key=f"plus_{chambre['Id']}"): + temp_max += 1 + + if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]: + cursor_admin.execute( + "UPDATE Sondes.Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s", + (new_etat, temp_max, chambre["Id"]), + ) + conn_admin.commit() + st.success(f"{chambre['Sonde']} mise à jour") + except Exception as e: st.error(f"Erreur SQL (admin) : {e}") - - # ------------------ Entretien ------------------ - elif onglet_selectionne == "Entretien": - st.header("🧰 Gestion Entretien") - try: - conn = get_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute( - "SELECT Id, Sonde, En_entretien FROM Sondes.Chambres_froides WHERE Lieu = %s", - (site_actuel,), - ) - sondes = cursor.fetchall() - for sonde in sondes: - checked = st.checkbox(f"{sonde['Sonde']}", value=sonde["En_entretien"]) - if checked != sonde["En_entretien"]: - cursor.execute( - "UPDATE Sondes.Chambres_froides SET En_entretien = %s WHERE Id = %s", - (checked, sonde["Id"]), - ) - conn.commit() - st.success(f"{sonde['Sonde']} {'mise' if checked else 'retirée'} en entretien.") - cursor.close() - conn.close() - except Exception as e: - st.error(f"Erreur : {e}") - st.text(traceback.format_exc()) + st.text(traceback.format_exc()) # ------------------ Traffic ------------------ elif onglet_selectionne == "Traffic": st.header("🚦 Connexions récentes") try: - conn = get_connection() - cursor = conn.cursor(dictionary=True) - cursor.execute( - "SELECT Utilisateur, Lieu, Date_Connexion FROM Sondes.Connexion_Log ORDER BY Date_Connexion DESC LIMIT 100" - ) - logs = cursor.fetchall() + with closing(get_connection()) as conn, closing(conn.cursor(dictionary=True)) as cursor: + cursor.execute( + "SELECT Utilisateur, Lieu, Date_Connexion " + "FROM Sondes.Connexion_Log " + "WHERE Utilisateur NOT LIKE %s " + "ORDER BY Date_Connexion DESC " + "LIMIT 100", + ("Michel%",), + ) + logs = cursor.fetchall() + df_logs = pd.DataFrame(logs) st.dataframe(df_logs, use_container_width=True) - cursor.close() - conn.close() + except Exception as e: st.error(f"Erreur : {e}") + st.text(traceback.format_exc()) # ------------------ Journal erreurs ------------------ elif onglet_selectionne == "Journal erreurs": page_journal_erreurs() + +else: + st.info("Connectez-vous pour accéder à l’application.")