Remise en état des userforms inventaire

This commit is contained in:
2025-10-03 13:52:13 +02:00
parent 807f2d318b
commit 4bd43a3611
4 changed files with 257 additions and 23 deletions

2
.env
View File

@@ -47,7 +47,7 @@ OVH_APPLICATION_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_SMS_SERVICE=sms-jm164396-1
OVH_SMS_SENDER=DOMO91FR
SMS_RECEIVER=+33635164680
SMS_RECEIVER=+33759600180
ALERT_SMS_TO_SACLAY=Michel:+33635164680
ALERT_SMS_TO_MEUDON=Michel:+33635164680

View File

@@ -27,7 +27,7 @@ try:
_ovh_available = True
except Exception:
ovh = None # type: ignore
class OVHAPIError(Exception): pass
class OVHAPIError(Exception): ...
_ovh_available = False
# MQTT
@@ -44,7 +44,7 @@ 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:
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).
@@ -59,7 +59,7 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
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'))
(sonde, dt_.strftime('%Y-%m-%d %H:%M:%S'))
)
conn.commit()
cur.close()
@@ -92,6 +92,45 @@ def get_db():
autocommit=True,
)
# --- Journalisation Gyro en table dédiée `Gyro` ---
def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str,
qos: int | None, retained: int | None, when: datetime):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
"INSERT INTO `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'),
topic,
payload_raw,
qos,
retained
)
)
cnx.commit()
log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic)
except MySQLError as err:
log.exception("Erreur DB insert_gyro_log: %s", err)
finally:
cnx.close()
def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool:
sql = "SELECT Etat FROM `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) ---
def lire_sondes_depuis_db(site: str):
table = site
sql = f"""
@@ -100,8 +139,10 @@ def lire_sondes_depuis_db(site: str):
JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM `{table}`
WHERE Temperature IS NOT NULL
GROUP BY Sonde
) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate
WHERE t1.Temperature IS NOT NULL
"""
cnx = get_db()
try:
@@ -109,7 +150,7 @@ def lire_sondes_depuis_db(site: str):
cur.execute(sql)
rows = cur.fetchall()
for r in rows:
r["Temperature"] = float(r["Temperature"])
r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
@@ -117,6 +158,7 @@ def lire_sondes_depuis_db(site: str):
finally:
cnx.close()
def lire_cfg_chambres(site: str):
"""
Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
@@ -236,7 +278,7 @@ def _split_list(raw: str | None) -> list[str]:
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().strip('"').strip("'")
tok = tok.strip()
if not tok:
continue
if ":" in tok:
@@ -465,6 +507,7 @@ def build_ok_text(site: str, sonde: str, temp: float, seuil: float, when: dt.dat
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 =========
@@ -494,6 +537,7 @@ 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 ---
cbver = getattr(mqtt, "CallbackAPIVersion", None)
if cbver is not None:
api_v = (
@@ -508,6 +552,7 @@ class MQTTPublisher:
self.client = mqtt.Client()
else:
self.client = mqtt.Client()
# ------------------------------------------------------------
if user and pwd:
self.client.username_pw_set(user, pwd)
@@ -515,13 +560,70 @@ 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
os.getenv("GYRO_MQTT_SUB") or
""
)
subs = [t.strip() for t in subs_env.split(",") if t.strip()]
if not subs:
subs = [
self.topic, # ex: Sondes/Saclay/Gyro/cmd
f"Sondes/{site}/Gyro/#",
f"{site}/Gyro/#",
"Gyro/#",
]
for t in subs:
try:
self.client.subscribe(t, qos=2)
log.info("MQTT subscribe: %s", t)
except Exception as e:
log.warning("Subscribe échoué (%s): %s", t, e)
self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic)
except Exception as e:
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")
try:
if should_insert_gyro(lieu, etat):
insert_gyro_log(
lieu=lieu,
etat=etat,
topic=topic,
payload_raw=payload_raw,
qos=getattr(msg, "qos", None),
retained=getattr(msg, "retain", None),
when=now_paris()
)
except Exception as e:
log.exception("Insert Gyro échoué: %s", e)
return # ne pas poursuivre vers un parse température ici
# 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs)
try:
float(payload_raw.replace(",", "."))
except ValueError:
log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw)
def set(self, on: bool):
if not self.enabled:
return
@@ -530,11 +632,27 @@ class MQTTPublisher:
payload = "ON" if on else "OFF"
try:
r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
try:
r.wait_for_publish(timeout=3)
if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
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)
self.last_state = on
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
@@ -728,6 +846,7 @@ def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float):
"""
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_sms(sms_text)
@@ -755,13 +874,13 @@ def run_monitor_cycle(site: str = SITE):
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0))
now = now_paris()
now_ = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
if open_alert(conn, f"Alertes_{site}", nom, now_):
notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client
finally:
conn.close()
@@ -810,9 +929,9 @@ if __name__ == "__main__":
elif args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(SITE, "Chambre_N1", -14.5, -15.0)
notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(SITE, "Chambre_N1", -15.2, -15.0)
notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
else:
if args.once:
run_monitor_cycle(SITE)

View File

@@ -27,7 +27,7 @@ try:
_ovh_available = True
except Exception:
ovh = None # type: ignore
class OVHAPIError(Exception): pass
class OVHAPIError(Exception): ...
_ovh_available = False
# MQTT
@@ -44,7 +44,7 @@ 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:
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).
@@ -59,7 +59,7 @@ def open_alert(conn, table_alertes: str, sonde: str, dt: datetime) -> bool:
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'))
(sonde, dt_.strftime('%Y-%m-%d %H:%M:%S'))
)
conn.commit()
cur.close()
@@ -92,6 +92,45 @@ def get_db():
autocommit=True,
)
# --- Journalisation Gyro en table dédiée `Gyro` ---
def insert_gyro_log(lieu: str, etat: str, topic: str, payload_raw: str,
qos: int | None, retained: int | None, when: datetime):
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
"INSERT INTO Sondes.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'),
topic,
payload_raw,
qos,
retained
)
)
cnx.commit()
log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic)
except MySQLError as err:
log.exception("Erreur DB insert_gyro_log: %s", err)
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) ---
def lire_sondes_depuis_db(site: str):
table = site
sql = f"""
@@ -100,8 +139,10 @@ def lire_sondes_depuis_db(site: str):
JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM `{table}`
WHERE Temperature IS NOT NULL
GROUP BY Sonde
) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate
WHERE t1.Temperature IS NOT NULL
"""
cnx = get_db()
try:
@@ -109,7 +150,7 @@ def lire_sondes_depuis_db(site: str):
cur.execute(sql)
rows = cur.fetchall()
for r in rows:
r["Temperature"] = float(r["Temperature"])
r["Temperature"] = float(r["Temperature"]) # garanti NOT NULL
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
@@ -117,6 +158,7 @@ def lire_sondes_depuis_db(site: str):
finally:
cnx.close()
def lire_cfg_chambres(site: str):
"""
Retourne {sonde: {"temp_max": float, "active": bool, "entretien": bool}}
@@ -518,13 +560,70 @@ 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
os.getenv("GYRO_MQTT_SUB") or
""
)
subs = [t.strip() for t in subs_env.split(",") if t.strip()]
if not subs:
subs = [
self.topic, # ex: Sondes/Saclay/Gyro/cmd
f"Sondes/{site}/Gyro/#",
f"{site}/Gyro/#",
"Gyro/#",
]
for t in subs:
try:
self.client.subscribe(t, qos=2)
log.info("MQTT subscribe: %s", t)
except Exception as e:
log.warning("Subscribe échoué (%s): %s", t, e)
self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic)
except Exception as e:
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")
try:
if should_insert_gyro(lieu, etat):
insert_gyro_log(
lieu=lieu,
etat=etat,
topic=topic,
payload_raw=payload_raw,
qos=getattr(msg, "qos", None),
retained=getattr(msg, "retain", None),
when=now_paris()
)
except Exception as e:
log.exception("Insert Gyro échoué: %s", e)
return # ne pas poursuivre vers un parse température ici
# 2) Pas du gyro → ignorer ici (la collecte T° est gérée ailleurs)
try:
float(payload_raw.replace(",", "."))
except ValueError:
log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw)
def set(self, on: bool):
if not self.enabled:
return
@@ -533,11 +632,27 @@ class MQTTPublisher:
payload = "ON" if on else "OFF"
try:
r = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
try:
r.wait_for_publish(timeout=3)
if r.rc != 0:
log.warning("MQTT publish rc=%s (topic=%s)", r.rc, self.topic)
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)
self.last_state = on
except Exception as e:
log.exception("MQTT publish erreur: %s", e)
@@ -759,13 +874,13 @@ def run_monitor_cycle(site: str = SITE):
nom = str(r["Sonde"])
temp = float(r["Temperature"])
seuil = float(seuils.get(nom, 6.0))
now = now_paris()
now_ = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now):
if open_alert(conn, f"Alertes_{site}", nom, now_):
notifier_sur_depassement(site, nom, temp, seuil) # MAIL + SMS client
finally:
conn.close()
@@ -822,4 +937,3 @@ if __name__ == "__main__":
run_monitor_cycle(SITE)
else:
run_monitor_loop(SITE, period_sec=args.period)

View File

@@ -9,6 +9,7 @@ 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)
import streamlit as st
from contextlib import closing
from dotenv import load_dotenv