Mise en place des services d'alerte Meudon
This commit is contained in:
10
.env
10
.env
@@ -7,7 +7,6 @@ DB_NAME=Sondes
|
|||||||
|
|
||||||
# MQTT
|
# MQTT
|
||||||
MQTT_HOST=162.19.78.131
|
MQTT_HOST=162.19.78.131
|
||||||
MQTT_PORT=1883
|
|
||||||
MQTT_USER=sondes
|
MQTT_USER=sondes
|
||||||
MQTT_PASS=3J@bjYP0
|
MQTT_PASS=3J@bjYP0
|
||||||
GYRO_PUBLISH_GLOBAL=1
|
GYRO_PUBLISH_GLOBAL=1
|
||||||
@@ -19,7 +18,10 @@ SMTP_SECURITY=STARTTLS
|
|||||||
SMTP_USER=services@domo91.fr
|
SMTP_USER=services@domo91.fr
|
||||||
SMTP_PASS=6ZiCsVtSf9@nEHv@$^0
|
SMTP_PASS=6ZiCsVtSf9@nEHv@$^0
|
||||||
MAIL_FROM=services@domo91.fr
|
MAIL_FROM=services@domo91.fr
|
||||||
MAIL_TO=michel@mj91.fr,nicolas.thibaut@bw-paris-saclay.com,
|
MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com,
|
||||||
|
MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>"
|
||||||
|
MAIL_TO_MEUDON=robots@domo91.fr
|
||||||
|
MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>"
|
||||||
|
|
||||||
# --- Paramètres SMS ----
|
# --- Paramètres SMS ----
|
||||||
OVH_ENDPOINT=ovh-eu
|
OVH_ENDPOINT=ovh-eu
|
||||||
@@ -28,5 +30,5 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
|
|||||||
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
|
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
|
||||||
OVH_SMS_SERVICE=sms-jm164396-1
|
OVH_SMS_SERVICE=sms-jm164396-1
|
||||||
OVH_SMS_SENDER=DOMO91FR
|
OVH_SMS_SENDER=DOMO91FR
|
||||||
TEST_RECEIVER=+33635164680
|
ALERT_SMS_TO_SACLAY==Michel:+33635164680,Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
|
||||||
ALERT_SMS_TO=+33635164680,+33682069405,+33650270939,+33601162960
|
ALERT_SMS_TO_MEUDON=Michel:+33635164680
|
||||||
|
|||||||
487
app/Monitor_Meudon.py
Normal file
487
app/Monitor_Meudon.py
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# ========= Imports & chargement .env =========
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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)
|
||||||
|
SITE = "Meudon"
|
||||||
|
PROGRAM_NAME = f"Monitor_{SITE}"
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
import mysql.connector
|
||||||
|
from mysql.connector import Error as MySQLError
|
||||||
|
|
||||||
|
# OVH (robuste même si la lib n'est pas installée)
|
||||||
|
try:
|
||||||
|
import ovh
|
||||||
|
from ovh.exceptions import APIError as OVHAPIError
|
||||||
|
_ovh_available = True
|
||||||
|
except Exception:
|
||||||
|
ovh = None # type: ignore
|
||||||
|
class OVHAPIError(Exception): # fallback pour les except
|
||||||
|
pass
|
||||||
|
_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=getattr(first_over, "tzinfo", None))
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ========= Helpers destinataires =========
|
||||||
|
def _split_list(raw: str | None) -> list[str]:
|
||||||
|
"""Pour les emails (MAIL_TO) — accepte virgule ou point-virgule."""
|
||||||
|
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]]:
|
||||||
|
"""
|
||||||
|
Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')]
|
||||||
|
Si pas de nom fourni -> ('', '+336...')
|
||||||
|
"""
|
||||||
|
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]:
|
||||||
|
"""
|
||||||
|
Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs.
|
||||||
|
Si ALERT_SMS_ONLY absent -> tous les numéros.
|
||||||
|
"""
|
||||||
|
only = os.getenv("ALERT_SMS_ONLY")
|
||||||
|
if not only:
|
||||||
|
return [num for (_name, 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:
|
||||||
|
"""Michel(+336...), Christian(+336...) pour les logs."""
|
||||||
|
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
|
||||||
|
|
||||||
|
# ========= Notifications (OVH + SMTP) =========
|
||||||
|
class Notifier:
|
||||||
|
def __init__(self):
|
||||||
|
# ...
|
||||||
|
self.ovh_enabled = _ovh_available and all(
|
||||||
|
os.getenv(k) for k in (
|
||||||
|
"OVH_APPLICATION_KEY", "OVH_APPLICATION_SECRET", "OVH_CONSUMER_KEY",
|
||||||
|
"OVH_SMS_SERVICE", "OVH_SMS_SENDER"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if self.ovh_enabled:
|
||||||
|
self.ovh_client = ovh.Client(
|
||||||
|
endpoint=os.getenv("OVH_ENDPOINT", "ovh-eu"),
|
||||||
|
application_key=os.getenv("OVH_APPLICATION_KEY"),
|
||||||
|
application_secret=os.getenv("OVH_APPLICATION_SECRET"),
|
||||||
|
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
|
||||||
|
)
|
||||||
|
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
|
||||||
|
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
|
||||||
|
|
||||||
|
raw_sms = (os.getenv("ALERT_SMS_TO_Meudon")
|
||||||
|
or os.getenv("ALERT_SMS_TO_MEUDON")
|
||||||
|
or os.getenv("ALERT_SMS_TO"))
|
||||||
|
self.sms_labeled = _parse_labeled_phones(raw_sms)
|
||||||
|
else:
|
||||||
|
self.sms_labeled = []
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
site_key = SITE # "Saclay" ou "Meudon" selon le fichier
|
||||||
|
raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}")
|
||||||
|
or os.getenv(f"MAIL_TO_{site_key.upper()}")
|
||||||
|
or os.getenv("MAIL_TO")
|
||||||
|
or "")
|
||||||
|
self.mail_to = _split_list(raw_mail_to)
|
||||||
|
|
||||||
|
self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}")
|
||||||
|
or os.getenv(f"MAIL_FROM_{site_key.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 = "monitor-saclay") -> bool:
|
||||||
|
if not self.ovh_enabled or not self.sms_labeled:
|
||||||
|
log.warning("SMS désactivé ou aucun destinataire.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros
|
||||||
|
if not receivers:
|
||||||
|
log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).")
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"sender": self.ovh_sender,
|
||||||
|
"receivers": receivers,
|
||||||
|
"message": message[:1600],
|
||||||
|
"priority": "high",
|
||||||
|
"coding": "7bit",
|
||||||
|
"class": "phoneDisplay",
|
||||||
|
"noStopClause": True, # transactionnel (H24) si habilité chez OVH
|
||||||
|
"senderForResponse": False,
|
||||||
|
"validityPeriod": 2880,
|
||||||
|
"tag": tag,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
log.info("Envoi SMS vers: %s", _human_labeled_list([(n, p) for (n, p) in self.sms_labeled if p in receivers]))
|
||||||
|
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
|
||||||
|
ids = resp.get("ids") or []
|
||||||
|
log.info("SMS OVH envoyé (job ids=%s)", ids)
|
||||||
|
# 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 OVHAPIError as err:
|
||||||
|
log.exception("Erreur API OVH: %s", err)
|
||||||
|
return False
|
||||||
|
except Exception as err:
|
||||||
|
log.exception("Echec envoi SMS OVH: %s", err)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_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:
|
||||||
|
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, _ = build_ok_text(site, sonde, temp, seuil)
|
||||||
|
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul
|
||||||
|
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()
|
||||||
|
# Faire en sorte que --site pilote aussi la liste SMS
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["SITE_NAME"] = args.site
|
||||||
|
|
||||||
|
# Créer le notifier maintenant (après avoir fixé SITE_NAME)
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
notifier = Notifier() # type: ignore[assignment]
|
||||||
|
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)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
# ========= Imports & chargement .env =========
|
# ========= Imports & chargement .env =========
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import ssl
|
import ssl
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -13,16 +14,22 @@ from typing import List
|
|||||||
|
|
||||||
from dotenv import load_dotenv, find_dotenv
|
from dotenv import load_dotenv, find_dotenv
|
||||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||||
|
SITE = "Saclay"
|
||||||
|
PROGRAM_NAME = f"Monitor_{SITE}"
|
||||||
|
|
||||||
# MySQL
|
# MySQL
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from mysql.connector import Error as MySQLError
|
from mysql.connector import Error as MySQLError
|
||||||
|
|
||||||
# OVH (optionnel : si non installé, SMS désactivé proprement)
|
# OVH (robuste même si la lib n'est pas installée)
|
||||||
try:
|
try:
|
||||||
import ovh
|
import ovh
|
||||||
|
from ovh.exceptions import APIError as OVHAPIError
|
||||||
_ovh_available = True
|
_ovh_available = True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
ovh = None # type: ignore
|
||||||
|
class OVHAPIError(Exception): # fallback pour les except
|
||||||
|
pass
|
||||||
_ovh_available = False
|
_ovh_available = False
|
||||||
|
|
||||||
# ========= Logger =========
|
# ========= Logger =========
|
||||||
@@ -133,7 +140,7 @@ def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
|
|||||||
if not first_over:
|
if not first_over:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
now = dt.datetime.now(tz=first_over.tzinfo) if hasattr(first_over, "tzinfo") else dt.datetime.now()
|
now = dt.datetime.now(tz=getattr(first_over, "tzinfo", None))
|
||||||
return (now - first_over) >= dt.timedelta(minutes=30)
|
return (now - first_over) >= dt.timedelta(minutes=30)
|
||||||
except MySQLError as err:
|
except MySQLError as err:
|
||||||
log.exception("Erreur DB (depassement_depuis_30min): %s", err)
|
log.exception("Erreur DB (depassement_depuis_30min): %s", err)
|
||||||
@@ -187,10 +194,44 @@ def acquitter_alerte(site: str, sonde: str):
|
|||||||
finally:
|
finally:
|
||||||
cnx.close()
|
cnx.close()
|
||||||
|
|
||||||
# ========= Notifications (OVH + SMTP) =========
|
# ========= Helpers destinataires =========
|
||||||
def _split_list(raw: str | None) -> List[str]:
|
def _split_list(raw: str | None) -> list[str]:
|
||||||
return [x.strip() for x in (raw or "").split(",") if x and x.strip()]
|
"""Pour les emails (MAIL_TO) — accepte virgule ou point-virgule."""
|
||||||
|
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]]:
|
||||||
|
"""
|
||||||
|
Transforme 'Nom:+336..., Autre:+336...' en [('Nom','+336...'), ('Autre','+336...')]
|
||||||
|
Si pas de nom fourni -> ('', '+336...')
|
||||||
|
"""
|
||||||
|
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]:
|
||||||
|
"""
|
||||||
|
Applique éventuellement ALERT_SMS_ONLY=Nom1,Nom2 ou numéros directs.
|
||||||
|
Si ALERT_SMS_ONLY absent -> tous les numéros.
|
||||||
|
"""
|
||||||
|
only = os.getenv("ALERT_SMS_ONLY")
|
||||||
|
if not only:
|
||||||
|
return [num for (_name, 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:
|
||||||
|
"""Michel(+336...), Christian(+336...) pour les logs."""
|
||||||
|
return ", ".join([f"{n}({p})" if n else p for n, p in labeled])
|
||||||
|
|
||||||
|
# ========= Notifications (OVH + SMTP) =========
|
||||||
class Notifier:
|
class Notifier:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# OVH
|
# OVH
|
||||||
@@ -208,10 +249,15 @@ class Notifier:
|
|||||||
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
|
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
|
||||||
)
|
)
|
||||||
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
|
self.ovh_service = os.getenv("OVH_SMS_SERVICE")
|
||||||
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
|
self.ovh_sender = os.getenv("OVH_SMS_SENDER")
|
||||||
self.sms_to = _split_list(os.getenv("ALERT_SMS_TO"))
|
|
||||||
|
# <<< LIGNE CLÉ POUR SACLAY >>>
|
||||||
|
raw_sms = (os.getenv("ALERT_SMS_TO_Saclay")
|
||||||
|
or os.getenv("ALERT_SMS_TO_SACLAY")
|
||||||
|
or os.getenv("ALERT_SMS_TO")) # fallback facultatif
|
||||||
|
self.sms_labeled = _parse_labeled_phones(raw_sms)
|
||||||
else:
|
else:
|
||||||
self.sms_to = []
|
self.sms_labeled = []
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
self.smtp_host = os.getenv("SMTP_HOST")
|
self.smtp_host = os.getenv("SMTP_HOST")
|
||||||
@@ -221,15 +267,34 @@ class Notifier:
|
|||||||
self.mail_from = os.getenv("MAIL_FROM") or self.smtp_user
|
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.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_security = (os.getenv("SMTP_SECURITY", "SSL") or "SSL").upper()
|
||||||
|
# >>> NOUVEAU : destinataires/expéditeur par site, sinon valeur générique
|
||||||
|
site_key = SITE # "Saclay" ou "Meudon" selon le fichier
|
||||||
|
raw_mail_to = (os.getenv(f"MAIL_TO_{site_key}")
|
||||||
|
or os.getenv(f"MAIL_TO_{site_key.upper()}")
|
||||||
|
or os.getenv("MAIL_TO")
|
||||||
|
or "")
|
||||||
|
self.mail_to = _split_list(raw_mail_to)
|
||||||
|
|
||||||
|
self.mail_from = (os.getenv(f"MAIL_FROM_{site_key}")
|
||||||
|
or os.getenv(f"MAIL_FROM_{site_key.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])
|
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:
|
def send_sms(self, message: str, tag: str = "monitor-saclay") -> bool:
|
||||||
if not self.ovh_enabled or not self.sms_to:
|
if not self.ovh_enabled or not self.sms_labeled:
|
||||||
log.warning("SMS désactivé ou aucun destinataire.")
|
log.warning("SMS désactivé ou aucun destinataire.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
receivers = _resolve_sms_receivers(self.sms_labeled) # liste de numéros
|
||||||
|
if not receivers:
|
||||||
|
log.warning("ALERT_SMS_ONLY a filtré tous les destinataires (aucun envoi).")
|
||||||
|
return False
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"sender": self.ovh_sender,
|
"sender": self.ovh_sender,
|
||||||
"receivers": self.sms_to,
|
"receivers": receivers,
|
||||||
"message": message[:1600],
|
"message": message[:1600],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"coding": "7bit",
|
"coding": "7bit",
|
||||||
@@ -239,7 +304,9 @@ class Notifier:
|
|||||||
"validityPeriod": 2880,
|
"validityPeriod": 2880,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
log.info("Envoi SMS vers: %s", _human_labeled_list([(n, p) for (n, p) in self.sms_labeled if p in receivers]))
|
||||||
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
|
resp = self.ovh_client.post(f"/sms/{self.ovh_service}/jobs", **payload)
|
||||||
ids = resp.get("ids") or []
|
ids = resp.get("ids") or []
|
||||||
log.info("SMS OVH envoyé (job ids=%s)", ids)
|
log.info("SMS OVH envoyé (job ids=%s)", ids)
|
||||||
@@ -256,10 +323,11 @@ class Notifier:
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
log.debug("Suivi job OVH indisponible (OK): %s", err)
|
log.debug("Suivi job OVH indisponible (OK): %s", err)
|
||||||
return True
|
return True
|
||||||
except ovh.exceptions.APIError as err:
|
|
||||||
|
except OVHAPIError as err:
|
||||||
log.exception("Erreur API OVH: %s", err)
|
log.exception("Erreur API OVH: %s", err)
|
||||||
return False
|
return False
|
||||||
except Exception as err: # volontaire: ne jamais faire planter le service sur notif
|
except Exception as err:
|
||||||
log.exception("Echec envoi SMS OVH: %s", err)
|
log.exception("Echec envoi SMS OVH: %s", err)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -291,7 +359,7 @@ class Notifier:
|
|||||||
except (smtplib.SMTPException, ssl.SSLError) as err:
|
except (smtplib.SMTPException, ssl.SSLError) as err:
|
||||||
log.exception("Erreur SMTP: %s", err)
|
log.exception("Erreur SMTP: %s", err)
|
||||||
return False
|
return False
|
||||||
except Exception as err: # pare-chocs
|
except Exception as err:
|
||||||
log.exception("Echec envoi email: %s", err)
|
log.exception("Echec envoi email: %s", err)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -349,8 +417,8 @@ def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
|
|||||||
log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde)
|
log.error("Alerte: SMS et mail KO pour %s/%s", site, sonde)
|
||||||
|
|
||||||
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
|
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
|
||||||
subject, sms_text, email_body = build_ok_text(site, sonde, temp, seuil)
|
subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
|
||||||
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul (comme convenu)
|
sms_ok = notifier.send_sms(sms_text) # retour à la normale: SMS seul
|
||||||
if sms_ok:
|
if sms_ok:
|
||||||
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde)
|
log.info("Acquittement envoyé (SMS) pour %s/%s", site, sonde)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user