Refonte des alarmes

This commit is contained in:
2025-09-19 12:02:32 +02:00
parent 5b6c31392f
commit 72be72a8aa
5 changed files with 438 additions and 22 deletions

32
.env
View File

@@ -1,3 +1,4 @@
# OVH_SMS_SENDER=DOMO91FR
#connexion mysql #connexion mysql
DB_HOST=162.19.78.131 DB_HOST=162.19.78.131
DB_USER=sondes DB_USER=sondes
@@ -9,24 +10,23 @@ MQTT_HOST=162.19.78.131
MQTT_PORT=1883 MQTT_PORT=1883
MQTT_USER=sondes MQTT_USER=sondes
MQTT_PASS=3J@bjYP0 MQTT_PASS=3J@bjYP0
GYRO_PUBLISH_GLOBAL=1
# paramètres mail # paramètres mail
SMTP_HOST=smtp.mail.ovh.net SMTP_HOST=ssl0.ovh.net
SMTP_PORT=465 SMTP_PORT=587
EMAIL_FROM=services@domo91.fr SMTP_SECURITY=STARTTLS
EMAIL_PASSWORD=6ZiCsVtSf9@nEHv@$^0 SMTP_USER=services@domo91.fr
EMAIL_DESTINATAIRES=services@domo91.fr SMTP_PASS=6ZiCsVtSf9@nEHv@$^0
MAIL_FROM=services@domo91.fr
MAIL_TO=michel@mj91.fr,nicolas.thibaut@bw-paris-saclay.com,
# connexion OVH pour les SMS # --- Paramètres SMS ----
OVH_APP_KEY=f725d07b2f98a195
OVH_APP_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_ENDPOINT=ovh-eu OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=f725d07b2f98a195
OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR OVH_SMS_SENDER=DOMO91FR
OVH_SERVICE_NAME=sms-jm164396-1 TEST_RECEIVER=+33635164680
SMS_RECEIVER=+33635164680 ALERT_SMS_TO=+33635164680,+33682069405,+33650270939,+33601162960
OVH_PASSWORD=w*j&A2j*QT^HL6
ENVOI_SMS=1
PHONE_SACLAY=+33682069405,+33650270939
PHONE_MEUDON=+33666271128
PHONE_ADMIN=+33635164680

View File

@@ -27,9 +27,9 @@ load_dotenv()
ENVOI_SMS = os.getenv("ENVOI_SMS") == "1" ENVOI_SMS = os.getenv("ENVOI_SMS") == "1"
# === AJOUT GYRO (MQTT) === # === AJOUT GYRO (MQTT) ===
MQTT_HOST = os.getenv("MQTT_HOST", "127.0.0.1") MQTT_HOST = os.getenv("MQTT_HOST")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) MQTT_PORT = int(os.getenv("MQTT_PORT"))
MQTT_USER = os.getenv("MQTT_USER", "") MQTT_USER = os.getenv("MQTT_USER")
MQTT_PASS = os.getenv("MQTT_PASS", "") MQTT_PASS = os.getenv("MQTT_PASS", "")
MQTT_QOS = 1 MQTT_QOS = 1
GYRO_PUBLISH_GLOBAL = os.getenv("GYRO_PUBLISH_GLOBAL", "0") == "1" GYRO_PUBLISH_GLOBAL = os.getenv("GYRO_PUBLISH_GLOBAL", "0") == "1"

414
app/Monitor_Saclay.py Normal file
View File

@@ -0,0 +1,414 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ========= Imports & chargement .env =========
import os
import time
import ssl
import smtplib
import logging
import datetime as dt
from email.message import EmailMessage
from typing import List
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
# MySQL
import mysql.connector
from mysql.connector import Error as MySQLError
# OVH (optionnel : si non installé, SMS désactivé proprement)
try:
import ovh
_ovh_available = True
except Exception:
_ovh_available = False
# ========= Logger =========
log = logging.getLogger("monitor_saclay")
if not log.handlers:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ========= Utilitaires DB =========
def get_db():
cnx = mysql.connector.connect(
host=os.getenv("DB_HOST", "localhost"),
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,
)
return cnx
def lire_sondes_depuis_db(site: str):
"""
Relevés les + récents par sonde pour le site (table = nom du site, ex. 'Saclay').
Retour: [{"Sonde": str, "Temperature": float, "Date": datetime}]
"""
table = site
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
FROM `{table}` t1
JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM `{table}`
GROUP BY Sonde
) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate
"""
cnx = get_db()
try:
cur = cnx.cursor(dictionary=True)
cur.execute(sql)
rows = cur.fetchall()
for r in rows:
r["Temperature"] = float(r["Temperature"])
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
return []
finally:
cnx.close()
def lire_seuils_depuis_db(site: str):
"""
Lit 'Chambres_froides' pour le site (Etat='ON').
Retour: dict {sonde: seuil_float}
"""
sql = """
SELECT Sonde, Temp_Max
FROM 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()
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
Vrai si dépassement continu >= 30 minutes.
Approche : cherche la première mesure > seuil dans les 120 dernières minutes,
vérifie que la dernière mesure est toujours > seuil et que l'écart >= 30 min.
"""
table = site
cnx = get_db()
try:
cur = cnx.cursor()
# Dernière valeur
cur.execute(f"""
SELECT Temperature, Date
FROM `{table}`
WHERE Sonde=%s
ORDER BY Date DESC
LIMIT 1
""", (sonde,))
last = cur.fetchone()
if not last:
return False
last_temp, last_date = float(last[0]), last[1]
if last_temp <= float(seuil):
return False
# Première mesure > seuil (fenêtre 120 min)
cur.execute(f"""
SELECT MIN(Date)
FROM `{table}`
WHERE Sonde=%s
AND Temperature > %s
AND Date >= (NOW() - INTERVAL 120 MINUTE)
""", (sonde, float(seuil)))
first_over = cur.fetchone()[0]
if not first_over:
return False
now = dt.datetime.now(tz=first_over.tzinfo) if hasattr(first_over, "tzinfo") else dt.datetime.now()
return (now - first_over) >= dt.timedelta(minutes=30)
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min): %s", err)
return False
finally:
cnx.close()
def alerte_en_cours(site: str, sonde: str) -> bool:
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"SELECT 1 FROM `{table}` WHERE `Sonde`=%s AND `Etat`='En cours' LIMIT 1",
(sonde,)
)
return cur.fetchone() is not None
except MySQLError as err:
log.exception("Erreur DB (alerte_en_cours): %s", err)
return False
finally:
cnx.close()
def creer_alerte(site: str, sonde: str):
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"INSERT INTO `{table}` (`Sonde`, `Debut_defaut`, `Etat`) VALUES (%s, NOW(), 'En cours')",
(sonde,)
)
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (creer_alerte): %s", err)
finally:
cnx.close()
def acquitter_alerte(site: str, sonde: str):
table = f"Alertes_{site}"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"UPDATE `{table}` SET `Etat`='Acquitté' WHERE `Sonde`=%s AND `Etat`='En cours'",
(sonde,)
)
cnx.commit()
except MySQLError as err:
log.exception("Erreur DB (acquitter_alerte): %s", err)
finally:
cnx.close()
# ========= Notifications (OVH + SMTP) =========
def _split_list(raw: str | None) -> List[str]:
return [x.strip() for x in (raw or "").split(",") if x and x.strip()]
class Notifier:
def __init__(self):
# OVH
self.ovh_enabled = _ovh_available and all(
os.getenv(k) for k in (
"OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
"OVH_SMS_SERVICE", "OVH_SMS_SENDER"
)
)
if self.ovh_enabled:
self.ovh_client = ovh.Client(
endpoint=os.getenv("OVH_ENDPOINT", "ovh-eu"),
application_key=os.getenv("OVH_APPLICATION_KEY"),
application_secret=os.getenv("OVH_APPLICATION_SECRET"),
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
)
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
self.sms_to = _split_list(os.getenv("ALERT_SMS_TO"))
else:
self.sms_to = []
# SMTP
self.smtp_host = os.getenv("SMTP_HOST")
self.smtp_port = int(os.getenv("SMTP_PORT", "465"))
self.smtp_user = os.getenv("SMTP_USER")
self.smtp_pass = os.getenv("SMTP_PASS")
self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user
self.mail_to = _split_list(os.getenv("MAIL_TO") or os.getenv("EMAIL_DESTINATAIRES"))
self.smtp_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper()
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 = "monitor-saclay") -> bool:
if not self.ovh_enabled or not self.sms_to:
log.warning("SMS désactivé ou aucun destinataire.")
return False
payload = {
"sender": self.ovh_sender,
"receivers": self.sms_to,
"message": message[:1600],
"priority": "high",
"coding": "7bit",
"class": "phoneDisplay",
"noStopClause": True, # transactionnel (H24) si habilité chez OVH
"senderForResponse": False,
"validityPeriod": 2880,
"tag": tag,
}
try:
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)
# Suivi non bloquant : OVH peut supprimer le job très vite → ignorer 404
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 err:
log.debug("Suivi job OVH indisponible (OK): %s", err)
return True
except ovh.exceptions.APIError as err:
log.exception("Erreur API OVH: %s", err)
return False
except Exception as err: # volontaire: ne jamais faire planter le service sur notif
log.exception("Echec envoi SMS OVH: %s", err)
return False
def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled:
log.warning("SMTP non configuré, email non envoyé.")
return False
msg = EmailMessage()
msg["From"] = self.mail_from
msg["To"] = ", ".join(self.mail_to)
msg["Subject"] = subject
msg.set_content(body)
try:
if self.smtp_security == "STARTTLS":
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server:
server.ehlo()
server.starttls(context=ssl.create_default_context())
server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
else:
with smtplib.SMTP_SSL(self.smtp_host, self.smtp_port, context=ssl.create_default_context(), timeout=20) as server:
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
log.info("Email envoyé à %s", self.mail_to)
return True
except (smtplib.SMTPException, ssl.SSLError) as err:
log.exception("Erreur SMTP: %s", err)
return False
except Exception as err: # pare-chocs
log.exception("Echec envoi email: %s", err)
return False
# ========= Helpers de mise en forme des messages =========
from zoneinfo import ZoneInfo
PARIS = ZoneInfo("Europe/Paris")
def fmt_deg(v: float) -> str:
s = f"{float(v):.1f}".replace(".", ",")
return f"{s}°C"
def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS)
def build_alert_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris()
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}",
]
txt = "\n".join(lines)
return subject, txt, txt
def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.datetime | None = None):
when = when or now_paris()
subject = f"[OK {site}] {sonde} revenue normale"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}",
f"Site: {site}",
f"Heure: {when.strftime('%Y-%m-%d %H:%M:%S')}",
]
txt = "\n".join(lines)
return subject, txt, txt
# ========= Fonctions de notification haut niveau =========
notifier = Notifier()
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text)
mail_ok = notifier.send_email(subject, email_body)
if sms_ok and mail_ok:
log.info("Alerte envoyée (SMS+mail) pour %s/%s", site, sonde)
elif sms_ok:
log.warning("Alerte: SMS OK, mail KO pour %s/%s", site, sonde)
elif mail_ok:
log.warning("Alerte: mail OK, SMS KO pour %s/%s", site, sonde)
else:
log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde)
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul (comme convenu)
if sms_ok:
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde)
else:
log.warning("Acquittement: SMS KO pour %s/%s", site, sonde)
# ========= Cycle & boucle de monitoring =========
def run_monitor_cycle(site: str):
sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site)
for r in sondes:
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) # défaut 6°C si manquant
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil) and not alerte_en_cours(site, nom):
creer_alerte(site, nom)
notifier_sur_depassement(site, nom, temp, seuil)
else:
if alerte_en_cours(site, nom):
acquitter_alerte(site, nom)
notifier_acquittement(site, nom, temp, seuil)
def run_monitor_loop(site: str, period_sec: int = 300):
log.info("Monitor_Saclay démarré (site=%s, période=%ss) ✅", site, period_sec)
while True:
t0 = time.time()
try:
run_monitor_cycle(site)
except Exception as err: # volontaire : ne jamais tuer le service
log.exception("Erreur cycle monitoring: %s", err)
time.sleep(max(0, period_sec - (time.time() - t0)))
# ========= Entrée CLI =========
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Monitor_Saclay")
parser.add_argument("--site", default=os.getenv("SITE_NAME", "Saclay"))
parser.add_argument("--period", type=int, default=300, help="période en secondes (défaut 300)")
# tests
parser.add_argument("--test-sms", action="store_true")
parser.add_argument("--test-mail", action="store_true")
parser.add_argument("--test-alert", action="store_true")
parser.add_argument("--test-ok", action="store_true")
parser.add_argument("--once", action="store_true", help="exécuter un seul cycle puis quitter")
args = parser.parse_args()
if args.test_sms:
notifier.send_sms("TEST DOMO91 (transactionnel)")
elif args.test_mail:
notifier.send_email("[TEST DOMO91] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(args.site, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(args.site, "Congelateur", -15.2, -15.0)
else:
if args.once:
run_monitor_cycle(args.site)
else:
run_monitor_loop(args.site, period_sec=args.period)

View File

@@ -143,7 +143,7 @@ def generer_pdf(site, date_str, periode):
# --- Requête alertes --- # --- Requête alertes ---
table_alertes = f"Alertes_{site}" table_alertes = f"Alertes_{site}"
pdf_cursor.execute(f"SELECT Sonde, Debut_defaut, Status FROM {table_alertes} WHERE DATE(Debut_defaut) = %s", (date_str,)) pdf_cursor.execute(f"SELECT Sonde, Debut_defaut, Etat FROM {table_alertes} WHERE DATE(Debut_defaut) = %s", (date_str,))
alertes = pdf_cursor.fetchall() alertes = pdf_cursor.fetchall()
pdf_cursor.close() pdf_cursor.close()
@@ -258,9 +258,9 @@ if st.session_state.get("authenticated"):
table_alertes = f"Alertes_{site_selectionne}" table_alertes = f"Alertes_{site_selectionne}"
cursor.execute( cursor.execute(
f"SELECT Sonde, Debut_defaut, Status " f"SELECT Sonde, Debut_defaut, Etat "
f"FROM `{table_alertes}` " f"FROM `{table_alertes}` "
f"WHERE Status != 'Acquitté' " f"WHERE Etat != 'Acquitté' "
f"ORDER BY Debut_defaut DESC" f"ORDER BY Debut_defaut DESC"
) )
alertes = cursor.fetchall() alertes = cursor.fetchall()

View File

@@ -12,3 +12,5 @@ bcrypt
ovh ovh
fpdf==1.7.2 fpdf==1.7.2
altair altair
dotenv