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

View File

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

View File

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

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')
# OVH SMS
APP_KEY = os.getenv('OVH_APP_KEY')
APP_SECRET = os.getenv('OVH_APP_SECRET')
APP_KEY = os.getenv('OVH_APPLICATION_KEY')
APP_SECRET = os.getenv('OVH_APPLICATION_SECRET')
CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY')
SERVICE_NAME = os.getenv('OVH_SERVICE_NAME')
SERVICE_NAME = os.getenv('OVH_SMS_SERVICE')
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
SMS_SENDER = os.getenv('OVH_SMS_SENDER')

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")