relancement cuisines Saclay et Meudon

This commit is contained in:
2025-09-22 11:11:55 +02:00
parent 90aab548d4
commit bb461a2ed1
10 changed files with 343 additions and 32 deletions

2
.env
View File

@@ -32,7 +32,9 @@ 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
SMS_RECEIVER=+33635164680
ALERT_SMS_TO_SACLAY==Michel:+33635164680 ALERT_SMS_TO_SACLAY==Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680 ALERT_SMS_TO_MEUDON=Michel:+33635164680
RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960 RESERVE_SACLAY=Nicolas:+33682069405,Sabrina:+33650270939,Mirceta:+33601162960
RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248 RESERVE_MEUDON=Sekou:+33625903364,Damien:+33680388259,Manon:+33631127248

View File

@@ -134,6 +134,48 @@ def lire_seuils_depuis_db(site: str):
finally: finally:
cnx.close() cnx.close()
def lire_cfg_chambres(site: str):
"""
Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
depuis Chambres_froides pour le site.
"""
sql = """
SELECT Sonde, Temp_Max, Etat, En_entretien
FROM Chambres_froides
WHERE Lieu=%s
"""
cnx = get_db()
cfg = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
for sonde, temp_max, etat, en_entretien in cur.fetchall():
cfg[str(sonde)] = {
"temp_max": float(temp_max),
"active": str(etat).upper() == "ON",
"entretien": bool(int(en_entretien or 0)),
}
return cfg
except MySQLError as err:
log.exception("Erreur DB (lire_cfg_chambres): %s", err)
return cfg
finally:
cnx.close()
def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0):
"""
Retourne (is_on: bool, trigger: (sonde, temp, seuil) | None)
"""
for row in last_values:
sonde = str(row["Sonde"])
meta = cfg.get(sonde)
if not meta or not meta["active"] or meta["entretien"]:
continue
temp = float(row["Temperature"])
if temp > float(meta["temp_max"]) + 0.0:
return True, (sonde, temp, float(meta["temp_max"]))
return False, None
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool: def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
table = site table = site
cnx = get_db() cnx = get_db()
@@ -359,8 +401,12 @@ class MQTTPublisher:
def __init__(self, site: str): def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt"))
self.site = site self.site = site
self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or self.topic = (
os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
os.getenv("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None self.last_state: bool | None = None
if not self.enabled: if not self.enabled:
@@ -416,9 +462,9 @@ class MQTTPublisher:
if self.last_state is not None and self.last_state == on: if self.last_state is not None and self.last_state == on:
return return
payload = "on" if on else "off" payload = "ON" if on else "OFF"
try: try:
r = self.client.publish(self.topic, payload=payload, qos=1, retain=True) r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
r.wait_for_publish(timeout=3) r.wait_for_publish(timeout=3)
if r.rc != 0: if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
@@ -437,21 +483,33 @@ def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil) subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
notifier.send_email(subject, email_body) notifier.send_email(subject, email_body)
try: beacon.set(True)
except Exception: pass
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, _ = build_ok_text(site, sonde, temp, seuil) subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
try:
if not any_alert_open(site):
beacon.set(False)
except Exception: pass
# ========= Cycle & boucle ========= # ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE): def run_monitor_cycle(site: str = SITE):
# 1) Lecture mesures + config
sondes = lire_sondes_depuis_db(site) sondes = lire_sondes_depuis_db(site)
seuils = lire_seuils_depuis_db(site) cfg = lire_cfg_chambres(site)
# 2) Gyro instantané
try:
gyro_on, trigger = compute_site_alarm(sondes, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
if trigger:
s, t, se = trigger
log.info("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se)
else:
log.info("Gyro %s => OFF (aucun dépassement)", site)
beacon.set(gyro_on)
except Exception as e:
log.exception("Erreur calcul/publish gyrophare: %s", e)
# 3) Alertes “officielles” (inchangées) avec temporisation 30 min
# On reconstitue un dict seuils à partir de la cfg (et on ignore les sondes OFF).
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in sondes: for r in sondes:
nom = str(r["Sonde"]) nom = str(r["Sonde"])
temp = float(r["Temperature"]) temp = float(r["Temperature"])
@@ -460,7 +518,7 @@ def run_monitor_cycle(site: str = SITE):
now = now_paris() now = now_paris()
if temp > seuil: if temp > seuil:
if depassement_depuis_30min(site, nom, seuil): if depassement_depuis_30min(site, nom, seuil):
# Ouvrir si pas déjà ouvert → notifier seulement si ouverture réelle # Ouvrir si pas déjà ouverte → notifier seulement si création
try: try:
conn = get_db() conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now): if open_alert(conn, f"Alertes_{site}", nom, now):
@@ -468,7 +526,7 @@ def run_monitor_cycle(site: str = SITE):
finally: finally:
conn.close() conn.close()
else: else:
# Fermer si ouvert → notifier seulement si fermeture réelle # Fermer si ouverte → notifier seulement si fermeture réelle
try: try:
conn = get_db() conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom): if close_alert(conn, f"Alertes_{site}", nom):
@@ -476,7 +534,6 @@ def run_monitor_cycle(site: str = SITE):
finally: finally:
conn.close() conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300): 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) log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
while True: while True:

View File

@@ -115,6 +115,49 @@ def lire_sondes_depuis_db(site: str):
finally: finally:
cnx.close() cnx.close()
def lire_cfg_chambres(site: str):
"""
Retourne un dict {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
depuis Chambres_froides pour le site.
"""
sql = """
SELECT Sonde, Temp_Max, Etat, En_entretien
FROM Chambres_froides
WHERE Lieu=%s
"""
cnx = get_db()
cfg: dict[str, dict] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
for sonde, temp_max, etat, en_entretien in cur.fetchall():
cfg[str(sonde)] = {
"temp_max": float(temp_max),
"active": str(etat).upper() == "ON",
"entretien": bool(int(en_entretien or 0)),
}
return cfg
except MySQLError as err:
log.exception("Erreur DB (lire_cfg_chambres): %s", err)
return cfg
finally:
cnx.close()
def compute_site_alarm(last_values: list[dict], cfg: dict[str, dict], hysteresis: float = 0.0):
"""
Retourne (is_on: bool, trigger: tuple[str,float,float] | None)
trigger = (sonde, temp, seuil) si dépassement détecté.
"""
for row in last_values:
sonde = str(row["Sonde"])
meta = cfg.get(sonde)
if not meta or not meta["active"] or meta["entretien"]:
continue
temp = float(row["Temperature"])
if temp > float(meta["temp_max"]) + 0.0:
return True, (sonde, temp, float(meta["temp_max"]))
return False, None
def lire_seuils_depuis_db(site: str): def lire_seuils_depuis_db(site: str):
sql = """ sql = """
SELECT Sonde, Temp_Max SELECT Sonde, Temp_Max
@@ -360,8 +403,12 @@ class MQTTPublisher:
def __init__(self, site: str): def __init__(self, site: str):
self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt")) self.enabled = (_mqtt_ok and (os.getenv("GYRO_MODE", "").lower() == "mqtt"))
self.site = site self.site = site
self.topic = (os.getenv(f"GYRO_MQTT_TOPIC_{site}") or self.topic = (
os.getenv(f"GYRO_MQTT_TOPIC_{site.capitalize()}")) os.getenv(f"GYRO_MQTT_TOPIC_{site}") or
os.getenv(f"GYRO_MQTT_TOPIC_{site.upper()}") or
os.getenv("GYRO_MQTT_TOPIC") or
f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None self.last_state: bool | None = None
if not self.enabled: if not self.enabled:
@@ -417,9 +464,9 @@ class MQTTPublisher:
if self.last_state is not None and self.last_state == on: if self.last_state is not None and self.last_state == on:
return return
payload = "on" if on else "off" payload = "ON" if on else "OFF"
try: try:
r = self.client.publish(self.topic, payload=payload, qos=1, retain=True) r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
r.wait_for_publish(timeout=3) r.wait_for_publish(timeout=3)
if r.rc != 0: if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic) log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
@@ -438,22 +485,34 @@ def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float):
subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil) subject, sms_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
notifier.send_email(subject, email_body) notifier.send_email(subject, email_body)
try: beacon.set(True)
except Exception: pass
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, _ = build_ok_text(site, sonde, temp, seuil) subject, sms_text, _ = build_ok_text(site, sonde, temp, seuil)
notifier.send_sms(sms_text) notifier.send_sms(sms_text)
try:
if not any_alert_open(site):
beacon.set(False)
except Exception: pass
# ========= Cycle & boucle ========= # ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE): def run_monitor_cycle(site: str = SITE):
sondes = lire_sondes_depuis_db(site) # 1) Lecture dernières mesures + config chambres
seuils = lire_seuils_depuis_db(site) last_rows = lire_sondes_depuis_db(site) # [{'Sonde','Temperature','Date'}]
for r in sondes: cfg = lire_cfg_chambres(site) # {sonde: {temp_max, active, entretien}}
# 2) Gyro instantané : ON si >=1 sonde active & non en entretien dépasse son seuil
try:
gyro_on, trigger = compute_site_alarm(last_rows, cfg, hysteresis=float(os.getenv("GYRO_HYSTERESIS", "0.0")))
if trigger:
s, t, se = trigger
log.info("Gyro %s => ON (déclenché par %s: %.2f > %.2f)", site, s, t, se)
else:
log.info("Gyro %s => OFF (aucun dépassement)", site)
beacon.set(gyro_on)
except Exception as e:
log.exception("Erreur calcul/publish gyrophare: %s", e)
# 3) Alertes "officielles" (inchangées) avec temporisation 30 min
# On reconstitue un dict seuils pour réutiliser ta logique existante en dessous.
seuils = {s: meta["temp_max"] for s, meta in cfg.items() if meta.get("active", False)}
for r in last_rows:
nom = str(r["Sonde"]) nom = str(r["Sonde"])
temp = float(r["Temperature"]) temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0)) seuil = float(seuils.get(nom, 6.0))
@@ -478,6 +537,7 @@ def run_monitor_cycle(site: str = SITE):
conn.close() conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300): 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) log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
while True: while True:

View File

@@ -0,0 +1,19 @@
import os, logging
def setup_logger(filename: str):
# si l'argument est un chemin absolu, on le respecte
if os.path.isabs(filename):
log_path = filename
else:
# ancien comportement (ex: /var/log/Cuisine_Saclay/<filename>)
base = "/var/log/Cuisine_Saclay"
os.makedirs(base, exist_ok=True)
log_path = os.path.join(base, filename)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
filename=log_path,
format="%(asctime)s %(levelname)s %(message)s"
)

View File

@@ -0,0 +1,48 @@
import argparse
import paho.mqtt.client as mqtt_client
from dotenv import load_dotenv
import logging
from logger_config import setup_logger
from utils_db import connect_to_mysql
from functools import partial
def on_message(table_sql, _client, _userdata, msg):
try:
logging.info(f"Message reçu sur {msg.topic}: {msg.payload.decode()}")
cursor = mydb.cursor()
sonde_name = '/'.join(msg.topic.split('/')[1:])
sql = f"INSERT INTO {table_sql} (Sonde, Temperature) VALUES (%s, %s)"
val = (sonde_name, msg.payload.decode())
cursor.execute(sql, val)
mydb.commit()
logging.info(f"Insertion réussie : {val}")
except Exception as e:
logging.error(f"Erreur lors de l'insertion du message : {e}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--log", required=True, help="Nom du fichier de log")
parser.add_argument("--table", required=True, help="Nom complet de la table SQL")
parser.add_argument("--topic", required=True, help="Topic MQTT à écouter")
args = parser.parse_args()
# 📋 Initialiser le logger
setup_logger(args.log)
# 🔑 Charger les variables d'environnement
load_dotenv()
# 🔌 Connexion MySQL
mydb = connect_to_mysql()
# 📡 Connexion MQTT
try:
client = mqtt_client.Client()
client.username_pw_set("Bwps", "scJ5ACj2keRfI^")
client.on_message = partial(on_message, args.table)
client.connect("54.36.188.119", 1883, 60)
client.subscribe(args.topic)
logging.info(f"Connexion MQTT réussie et abonnement au topic '{args.topic}'.")
client.loop_forever()
except Exception as err:
logging.error(f"Erreur MQTT : {err}")

View File

@@ -27,10 +27,10 @@ logging.basicConfig(
load_dotenv('/home/debian/Gestion_sondes/.env') load_dotenv('/home/debian/Gestion_sondes/.env')
# OVH SMS # OVH SMS
APP_KEY = os.getenv('OVH_APP_KEY') APP_KEY = os.getenv('OVH_APPLICATION_KEY')
APP_SECRET = os.getenv('OVH_APP_SECRET') APP_SECRET = os.getenv('OVH_APPLICATION_SECRET')
CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY') CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY')
SERVICE_NAME = os.getenv('OVH_SERVICE_NAME') SERVICE_NAME = os.getenv('OVH_SMS_SERVICE')
SMS_RECEIVER = os.getenv('SMS_RECEIVER') SMS_RECEIVER = os.getenv('SMS_RECEIVER')
SMS_SENDER = os.getenv('OVH_SMS_SENDER') SMS_SENDER = os.getenv('OVH_SMS_SENDER')

View File

@@ -0,0 +1,73 @@
import mysql.connector
from dotenv import load_dotenv
import os
load_dotenv()
def connect_to_mysql():
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME")
)
def get_latest_chaufferie():
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Sonde, Temperature, Date, Topic
FROM Sondes.Chaufferie
WHERE Date >= NOW() - INTERVAL 5 MINUTE
ORDER BY Date DESC \
"""
cursor.execute(query)
result = cursor.fetchall()
cursor.close()
conn.close()
return result
def get_history_by_sonde(sonde):
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Sonde, Temperature, Date
FROM Sondes.Chaufferie
WHERE Sonde = %s
AND Date >= NOW() - INTERVAL 1 DAY \
"""
cursor.execute(query, (sonde,))
result = cursor.fetchall()
cursor.close()
conn.close()
return result
def lire_alertes_sondes():
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Id, Sonde, Debut_defaut, Etat
FROM Sondes.Alertes_Chaufferie
WHERE Etat != 'Acquitté'
ORDER BY Debut_defaut DESC \
"""
cursor.execute(query)
result = cursor.fetchall()
cursor.close()
conn.close()
return result
def acquitter_alerte(id_alerte):
conn = connect_to_mysql()
cursor = conn.cursor()
query = "UPDATE Sondes.Alertes_Chaufferie SET Etat = 'Acquitté' WHERE Id = %s"
cursor.execute(query, (id_alerte,))
conn.commit()
cursor.close()
conn.close()

52
app/utils_sms.py Normal file
View File

@@ -0,0 +1,52 @@
import os
import ovh
from dotenv import load_dotenv
load_dotenv()
def envoyer_sms(message: str, lieu: str = ""):
try:
client = ovh.Client(
endpoint=os.getenv("OVH_ENDPOINT"),
application_key=os.getenv("OVH_APP_KEY"),
application_secret=os.getenv("OVH_APP_SECRET"),
consumer_key=os.getenv("OVH_CONSUMER_KEY"),
)
services = client.get('/sms/')
if not services:
print("❌ Aucun service SMS OVH trouvé", flush=True)
return
service_name = services[0]
numero_dest = os.getenv("SMS_RECEIVER")
sender = os.getenv("OVH_SMS_SENDER")
if numero_dest.startswith('+'):
numero_dest = '00' + numero_dest[1:]
if not numero_dest or not numero_dest.isdigit():
print(f"❌ Numéro de téléphone invalide ou manquant : '{numero_dest}'", flush=True)
return
payload = {
"sender": sender,
"receivers": [numero_dest],
"message": message, # Pas d'encodage ni de nettoyage ici
"priority": "high",
"noStopClause": False
}
print("📤 Requête envoyée à OVH :")
print(payload)
result = client.post(f'/sms/{service_name}/jobs', **payload)
print(f"📱 SMS envoyé à {numero_dest} pour {lieu}. Job ID : {result['ids']}", flush=True)
except Exception as e:
print(f"❌ Erreur envoi SMS : {e}", flush=True)
if __name__ == "__main__":
envoyer_sms("Test SMS OVH", lieu="utils_sms")

View File

@@ -1,7 +1,7 @@
mysql-connector-python~=9.4.0 mysql-connector-python~=9.4.0
pandas~=2.3.1 pandas~=2.3.1
DateTime~=5.5 DateTime~=5.5
streamlit~=1.48.1 streamlit
matplotlib~=3.10.1 matplotlib~=3.10.1
paho-mqtt~=2.1.0 paho-mqtt~=2.1.0
requests~=2.32.5 requests~=2.32.5

View File

@@ -32,7 +32,7 @@ echo "🔷 Dossier NAS : $NAS_DIR (hôte $NAS_HOST)"
# 1) Pré-check SSH & droits écriture NAS # 1) Pré-check SSH & droits écriture NAS
echo "🔷 Test SSH NAS…" echo "🔷 Test SSH NAS…"
if ! ssh "SSH_OPTS "$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then if ! ssh $SSH_OPTS "$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then
echo "❌ Impossible d écrire sur $NAS_HOST:$NAS_DIR (clé SSH ? user ? droits ? SSH NAS activé ?)" echo "❌ Impossible d écrire sur $NAS_HOST:$NAS_DIR (clé SSH ? user ? droits ? SSH NAS activé ?)"
exit 20 exit 20
fi fi