Remise en état des fichiers

This commit is contained in:
2025-07-26 13:18:20 +02:00
parent 970fcc542a
commit 70e9ba9c64
16 changed files with 239 additions and 203 deletions

779
app/Interface.py Normal file
View File

@@ -0,0 +1,779 @@
# -*- coding: utf-8 -*-
import streamlit as st
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from fpdf import FPDF
import os
import random
from dotenv import load_dotenv
from datetime import datetime, date, time
import bcrypt
from .utils_db import connect_to_mysql
# Charger les variables d'environnement
load_dotenv()
st.set_page_config(page_title="Domo91 - Surveillance", layout="wide")
if "authenticated" not in st.session_state:
st.session_state["authenticated"] = False
st.session_state["role"] = None
st.session_state["lieu_autorise"] = None
st.title("📡 Supervision Températures")
def get_connection():
return mysql.connector.connect(**db_config)
# --- Fonction de génération PDF ---
def generer_pdf(site, date_str, periode):
st.info(f"Génération du rapport PDF pour {site} à la date {date_str} ({periode})")
try:
conn = mysql.connector.connect(**db_config)
pdf_cursor = conn.cursor(dictionary=True)
# Requête principale
pdf_cursor.execute(f"SELECT Sonde, Date, Temperature FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date", (date_str,))
rows = pdf_cursor.fetchall()
df = pd.DataFrame(rows)
df["Heure"] = pd.to_datetime(df["Date"]).dt.strftime("%H:%M")
df["Heure_obj"] = pd.to_datetime(df["Date"]).dt.time
# --- Filtrage par période ---
plages = {
"Toute la journée": (time(0, 0), time(23, 59)),
"Matin (6h-12h)": (time(6, 0), time(12, 0)),
"Après-midi (12h-18h)": (time(12, 0), time(18, 0)),
"Nuit (18h-6h)": (time(18, 0), time(6, 0)),
}
heure_debut, heure_fin = plages.get(periode, (time(0, 0), time(23, 59)))
if heure_debut < heure_fin:
df = df[(df["Heure_obj"] >= heure_debut) & (df["Heure_obj"] <= heure_fin)]
else:
df = df[(df["Heure_obj"] >= heure_debut) | (df["Heure_obj"] <= heure_fin)]
# --- Structuration des relevés ---
releves = {}
for sonde in df["Sonde"].unique():
df_sonde = df[df["Sonde"] == sonde]
releves[sonde] = list(zip(df_sonde["Heure"], df_sonde["Temperature"]))
# --- Requête alertes ---
table_alertes = f"Alertes_{site}"
pdf_cursor.execute(f"SELECT Sonde, Debut_defaut, Status FROM {table_alertes} WHERE DATE(Debut_defaut) = %s", (date_str,))
alertes = pdf_cursor.fetchall()
pdf_cursor.close()
conn.close()
# --- Classe PDF ---
class RapportPDF(FPDF):
def header(self):
self.set_font("Arial", "B", 14)
self.cell(0, 10, "Rapport de surveillance des sondes", ln=1, align="C")
self.set_font("Arial", "", 12)
self.cell(0, 10, f"Date : {date_str}", ln=1, align="C")
self.cell(0, 10, f"Periode : {getattr(self, 'periode', '')}", ln=1, align="C")
self.ln(5)
def site_info(self, site_name):
self.set_font("Arial", "B", 12)
self.cell(0, 10, f"Site : {site_name}", ln=1)
self.ln(2)
def releves_section(self, data):
self.set_font("Arial", "B", 12)
self.cell(0, 10, "Relevés de température", ln=1)
for sonde, mesures in data.items():
self.set_font("Arial", "B", 11)
self.cell(0, 8, f"Sonde : {sonde}", ln=1)
col1 = mesures[::2]
col2 = mesures[1::2]
self.set_font("Arial", "B", 10)
self.cell(40, 6, "Heure", border=1)
self.cell(30, 6, "Temp (°C)", border=1)
self.cell(20, 6, "", border=0)
self.cell(40, 6, "Heure", border=1)
self.cell(30, 6, "Temp (°C)", border=1)
self.ln()
self.set_font("Arial", "", 10)
for i in range(max(len(col1), len(col2))):
if i < len(col1):
h1, t1 = col1[i]
self.cell(40, 6, h1, border=1)
self.cell(30, 6, f"{t1:.2f}", border=1)
else:
self.cell(70, 6, "", border=0)
self.cell(20, 6, "", border=0)
if i < len(col2):
h2, t2 = col2[i]
self.cell(40, 6, h2, border=1)
self.cell(30, 6, f"{t2:.2f}", border=1)
self.ln()
self.ln(4)
def alertes_section(self, data):
self.set_font("Arial", "B", 12)
self.cell(0, 10, "Alertes enregistrées", ln=1)
self.set_font("Arial", "", 10)
for a in data:
self.cell(0, 6, f"{a['Sonde']} - {a['Debut_defaut']} - {a['Status']}", ln=1)
# --- Génération du PDF ---
pdf = RapportPDF()
pdf.periode = periode
pdf.add_page()
pdf.site_info(site)
pdf.releves_section(releves)
pdf.alertes_section(alertes)
file_name = f"rapport_{site}_{date_str}.pdf"
output_dir = "PDF"
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, file_name)
pdf.output(output_path)
with open(output_path, "rb") as f:
st.download_button(
label="📥 Télécharger le rapport PDF",
data=f,
file_name=file_name,
mime="application/pdf"
)
except Exception as err1:
st.error(f"Erreur lors de la génération du PDF : {err1}")
# --- Initialisation des variables de session ---
if "authenticated" not in st.session_state:
st.session_state["authenticated"] = False
if "role" not in st.session_state:
st.session_state["role"] = None
if "lieu_autorise" not in st.session_state:
st.session_state["lieu_autorise"] = None
# --- Connexion utilisateur dans la sidebar ---
st.sidebar.header("🔐 Connexion")
if not st.session_state.get("authenticated"):
login = st.sidebar.text_input("Nom d'utilisateur")
password = st.sidebar.text_input("Mot de passe", type="password")
if st.sidebar.button("Se connecter"):
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM Sondes.MotsDePasse WHERE utilisateur = %s", (login,))
result = cursor.fetchone()
def verifier_password(mot_de_passe_saisi, hash_en_base):
return bcrypt.checkpw(mot_de_passe_saisi.encode('utf-8'), hash_en_base.encode('utf-8'))
if result and verifier_password(password, result["mot_de_passe"]):
if result["Expiration"] and result["Expiration"] < date.today():
st.sidebar.error("⛔ Votre accès a expiré. Veuillez contacter un administrateur.")
cursor.close()
conn.close()
st.stop()
st.session_state["authenticated"] = True
st.session_state["role"] = result["role"]
st.session_state["lieu_autorise"] = result["Lieu"]
st.success(f"Connecté comme {result['role']} ({result['Lieu']})")
# Enregistrement de la connexion dans Connexion_Log
try:
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("""
INSERT INTO Connexion_Log (Utilisateur, Lieu, Date_Connexion)
VALUES (%s, %s, %s)
""", (login, result["Lieu"], now))
conn.commit()
except Exception as e:
st.warning(f"⚠️ Connexion enregistrée échouée : {e}")
st.rerun()
else:
st.sidebar.error("Identifiants invalides")
cursor.close()
conn.close()
except Exception as e:
st.sidebar.error(f"Erreur lors de la connexion à la base : {e}")
else:
st.sidebar.success(f"Connecté ({st.session_state['role']})")
if st.sidebar.button("🔓 Déconnexion", key="logout_sidebar"):
st.session_state["authenticated"] = False
st.session_state["role"] = None
st.session_state["lieu_autorise"] = None
st.rerun()
def hash_password(password):
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def ajouter_utilisateur(utilisateur, mot_de_passe, role, lieu, expiration):
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
cursor.execute("SELECT * FROM MotsDePasse WHERE utilisateur = %s", (utilisateur,))
if cursor.fetchone():
return False, "❌ Utilisateur déjà existant."
hash_mdp = hash_password(mot_de_passe)
cursor.execute("""
INSERT INTO MotsDePasse (utilisateur, mot_de_passe, role, Lieu, Expiration)
VALUES (%s, %s, %s, %s, %s)
""", (utilisateur, hash_mdp, role, lieu, expiration))
conn.commit()
cursor.close()
conn.close()
return True, "✅ Utilisateur ajouté avec succès."
except Exception as e:
return False, f"⚠️ Erreur : {e}"
def afficher_gestion_expiration(conn):
st.subheader("🔐 Gestion des expirations d'accès")
# Récupérer les utilisateurs avec leurs dates d'expiration
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT Id, utilisateur, Expiration FROM MotsDePasse")
users = cursor.fetchall()
cursor.close()
df = pd.DataFrame(users)
df['Expiration'] = pd.to_datetime(df['Expiration']).dt.date
for _, row in df.iterrows():
est_expire = row['Expiration'] < date.today()
fond = "#ffe6e6" if est_expire else "#f8f9fa"
with st.container():
st.markdown(f"<div style='background-color:{fond}; padding:10px; border-radius:8px;'>",
unsafe_allow_html=True)
col1, col2, col3 = st.columns([4, 2, 1])
with col1:
st.markdown(f"**Utilisateur :** {row['utilisateur']}")
if est_expire:
st.markdown("<span style='color:red; font-weight:bold;'>⛔ Accès expiré</span>",
unsafe_allow_html=True)
with col2:
new_date = st.date_input("Expiration", row['Expiration'], key=f"exp_{row['Id']}")
with col3:
if st.button("", key=f"save_{row['Id']}"):
try:
cursor = conn.cursor()
cursor.execute("UPDATE MotsDePasse SET Expiration = %s WHERE Id = %s",
(new_date, row['Id']))
conn.commit()
st.success(f"{row['utilisateur']} mis à jour")
cursor.close()
except Exception as e:
st.error(f"Erreur : {e}")
# 📄 Affichage bouton PDF si une date est choisie
site_pdf = (
st.session_state.get("lieu_autorise")
if st.session_state.get("role") != "superviseur"
else st.session_state.get("selected_site")
)
date_pdf = st.session_state.get("selected_date")
if site_pdf and date_pdf:
st.sidebar.markdown("---")
st.sidebar.subheader("📄 Rapport PDF")
if st.sidebar.button("📥 Télécharger létat du jour (PDF)", key="pdf_btn"):
periode = st.session_state.get("selected_periode", "Toute la journée")
generer_pdf(site_pdf, date, periode)
# --- Forcer une alerte de test dynamique (réservé aux superviseurs)
if st.session_state.get("authenticated") and st.session_state.get("role") == "superviseur":
site_actuel = (
st.session_state.get("lieu_autorise") or "Saclay"
if st.session_state.get("role") != "superviseur"
else st.session_state.get("selected_site")
)
if site_actuel:
# Initialiser la détection de changement de site
if "last_site" not in st.session_state:
st.session_state["last_site"] = None
# Si le site a changé, recharger les sondes disponibles
if site_actuel != st.session_state["last_site"]:
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
cursor.execute(f"SELECT DISTINCT Sonde FROM `{site_actuel}` ORDER BY Sonde ASC")
st.session_state["sondes_dispo"] = [row[0] for row in cursor.fetchall()]
cursor.close()
conn.close()
st.session_state["last_site"] = site_actuel
except Exception as e:
st.sidebar.warning(f"Erreur chargement des sondes : {e}")
# Affichage de la liste des sondes disponibles
sondes_dispo = st.session_state.get("sondes_dispo", [])
if sondes_dispo:
st.sidebar.markdown("---")
st.sidebar.subheader("🧪 Test alerte manuelle")
sonde_test = st.sidebar.selectbox("Choisir une sonde :", sondes_dispo)
if st.sidebar.button("🔔 Forcer une alerte de test"):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
table_alertes = f"Alertes_{site_actuel}"
cursor.execute(f"""
INSERT INTO {table_alertes} (Sonde, Debut_defaut, Status)
VALUES (%s, %s, %s)
""", (sonde_test, now, "Test"))
conn.commit()
cursor.close()
conn.close()
st.success(f"Alerte de test créée pour {sonde_test} à {now}")
except Exception as e:
st.error(f"Erreur lors de la création de l'alerte : {e}")
else:
st.success(f"Connecté ({st.session_state['role']})")
if st.button("🔓 Déconnexion", key="logout_main"):
st.session_state["authenticated"] = False
st.session_state["role"] = None
st.session_state["lieu_autorise"] = None
st.rerun()
st.markdown("---")
st.subheader("📄 Rapport PDF")
if "selected_date" in st.session_state:
if st.button("📅 Télécharger l'état du jour (PDF)"):
site = st.session_state["lieu_autorise"]
date_val = st.session_state["selected_date"].strftime("%Y-%m-%d")
generer_pdf(site, date_val, periode)
else:
st.info("Sélectionnez une date pour activer la génération PDF.")
# Récupération des sondes actives
def get_sondes_par_lieu(lieu):
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT Id, Sonde, En_entretien FROM Chambres_froides WHERE Lieu = %s", (lieu,))
sondes = cursor.fetchall()
conn.close()
return sondes
# Mise à jour statut entretien
def maj_entretien(sonde_id, statut, utilisateur):
conn = get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE Chambres_froides SET En_entretien = %s WHERE Id = %s", (statut, sonde_id))
# Insertion dans Connexion_log
action = "Mise en entretien" if statut else "Sortie d'entretien"
cursor.execute(
"INSERT INTO Connexion_Log (Utilisateur, Lieu, Date_Connexion, Action) VALUES (%s, %s, %s, %s)",
(utilisateur, lieu, datetime.now(), action)
)
conn.commit()
conn.close()
# --- CONTENU PRINCIPAL SI AUTHENTIFIÉ ---
if st.session_state["authenticated"]:
# --- AFFICHAGE GLOBAL DES ALERTES NON ACQUITTÉES ---
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
if "role" not in st.session_state:
st.session_state["role"] = None
if "lieu_autorise" not in st.session_state:
st.session_state["lieu_autorise"] = None
site_selectionne = (
st.session_state["lieu_autorise"]
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
table_alertes = f"Alertes_{site_selectionne}"
cursor.execute(
f"SELECT Sonde, Debut_defaut, Status FROM `{table_alertes}` WHERE Status != 'Acquitté' ORDER BY Debut_defaut DESC"
)
alertes = cursor.fetchall()
if alertes:
df_alertes = pd.DataFrame(alertes)
st.subheader("🚨 Alertes non acquittées")
st.dataframe(df_alertes, use_container_width=True)
else:
st.success("✅ Aucune alerte en cours.")
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur lors de la récupération des alertes : {e}")
# --- NAVIGATION --- (toujours défini, évite les erreurs)
onglet = st.session_state.get("onglet_actif", "Accueil")
if st.session_state.get("authenticated"):
if st.session_state["role"] == "superviseur":
onglets_possibles = ["Accueil", "Statistiques", "Entretien", "Traffic", "Utilisateurs"]
else:
onglets_possibles = ["Accueil", "Entretien"]
onglet = st.sidebar.radio("📁 Navigation", onglets_possibles, index=onglets_possibles.index(onglet))
st.session_state["onglet_actif"] = onglet
# --- ONGLET ACCUEIL ---
if onglet == "Accueil":
st.markdown("## Sélection du site et de la date")
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
sites_possibles = ["Saclay", "Meudon"]
if st.session_state["role"] == "superviseur":
site_selectionne = st.selectbox("📍 Choisissez un site :", sites_possibles)
st.session_state["selected_site"] = site_selectionne
else:
site_selectionne = st.session_state["lieu_autorise"]
st.info(f"Site imposé : {site_selectionne}")
selected_date = st.date_input("📅 Date du relevé", value=date.today())
st.session_state["selected_date"] = selected_date
site_selectionne = st.session_state.get("lieu_autorise") or st.session_state.get("selected_site")
if not site_selectionne:
st.warning("Aucun site sélectionné.")
st.stop()
cursor.execute(
f"SELECT * FROM `{site_selectionne}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC",
(selected_date.strftime("%Y-%m-%d"),)
)
rows = cursor.fetchall()
if rows:
df = pd.DataFrame(rows)
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde_choisie = st.selectbox("🧪 Choisissez une sonde :", sondes, key="selectbox_accueil")
df_sonde = df[df["Sonde"] == sonde_choisie]
df_sonde.loc[:, "Heure"] = df_sonde["Date"].dt.hour
tranche = st.radio("🕒 Tranche horaire :",
["Toute la journée", "Matin (6h-12h)", "Après-midi (12h-18h)", "Nuit (18h-6h)"])
st.session_state["selected_periode"] = tranche
if tranche == "Matin (6h-12h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)]
elif tranche == "Après-midi (12h-18h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 12) & (df_sonde["Heure"] < 18)]
elif tranche == "Nuit (18h-6h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)]
df_sonde = df_sonde.copy()
cursor.execute("SELECT Temp_Max FROM Chambres_froides WHERE Lieu = %s AND Sonde = %s",
(site_selectionne, sonde_choisie))
seuil = cursor.fetchone()
seuil_temp = seuil["Temp_Max"] if seuil else 10
st.subheader("📊 Tableau des relevés")
df_filtre = df_sonde.copy()
df_filtre = df_filtre.drop(columns="Id", errors="ignore")
def surlignage_temp(val):
try:
if float(val) > seuil_temp:
return "color: red; font-weight: bold"
except:
pass
return ""
styled_df = df_filtre.style.applymap(surlignage_temp, subset=["Temperature"])
st.dataframe(styled_df, use_container_width=True)
st.subheader("📈 Évolution de la température")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_filtre["Date"], df_filtre["Temperature"], marker='o', label="Température")
ax.axhline(seuil_temp, color='red', linestyle='--', label=f"Seuil {seuil_temp}°C")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.set_title(f"{sonde_choisie} - {selected_date.strftime('%d/%m/%Y')}")
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
ax.legend()
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur MySQL : {e}")
# ---- ONGLET STATISTIQUES ---
elif onglet == "Statistiques":
st.markdown("## 📈 Statistiques de température")
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
site = (
st.session_state["lieu_autorise"]
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_val = st.session_state.get("selected_date", date.today())
cursor.execute(
f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_val.strftime("%Y-%m-%d"),)
)
rows = cursor.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.info("Aucune donnée pour cette date.")
else:
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde = st.selectbox("Choisir une sonde :", sondes, key="selectbox_stats")
df_sonde = df[df["Sonde"] == sonde]
st.subheader("Évolution journalière")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker='o')
ax.set_title(f"{sonde} - {date_val.strftime('%d/%m/%Y')}")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur chargement statistiques : {e}")
# --- Fonctionnalités administrateur : ajout et gestion des chambres froides ---
if st.session_state["role"] == "superviseur":
with st.expander("+ Ajouter une nouvelle chambre froide", expanded=False):
with st.form("ajout_sonde"):
nouvelle_sonde = st.text_input("Nom de la nouvelle sonde :")
seuil_max = st.number_input("Température maximale autorisée :", min_value=-50.0, max_value=50.0,
value=6.0, step=0.1)
if st.form_submit_button(" Ajouter la nouvelle sonde"):
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
# Insérer dans Chambres_froides
cursor.execute("""
INSERT INTO Chambres_froides (Lieu, Sonde, Temp_Max, Etat)
VALUES (%s, %s, %s, %s)
""", (site_actuel, nouvelle_sonde, seuil_max, "ON"))
conn.commit()
# Insérer un premier relevé dans la table de relevés
table_releves = site_actuel
cursor.execute(f"""
INSERT INTO `{table_releves}` (Date, Sonde, Temperature)
VALUES (NOW(), %s, %s)
""", (nouvelle_sonde, 0.0))
conn.commit()
cursor.close()
conn.close()
st.success(
f"Sonde {nouvelle_sonde} ajoutée avec Temp_Max {seuil_max}°C et premier relevé enregistré.")
except Exception as e:
st.error(f"Erreur lors de l'ajout de la sonde : {e}")
# --- Affichage automatique des alertes non acquittées ---
site_selectionne = (
st.session_state.get("lieu_autorise")
if st.session_state.get("role") != "superviseur"
else st.session_state.get("selected_site")
)
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
table_alertes = f"Alertes_{site_selectionne}"
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur lors de la récupération des alertes : {e}")
if st.session_state["role"] == "superviseur":
with st.expander("🛠️ Gestion des chambres froides (administrateur)", expanded=True):
if st.button("🔄 Actualiser la liste"):
st.session_state["refresh_admin"] = random.randint(0, 9999)
try:
conn_admin = mysql.connector.connect(**db_config)
cursor_admin = conn_admin.cursor(dictionary=True)
cursor_admin.execute("SELECT * FROM Chambres_froides WHERE Lieu = %s", (site,))
chambres = cursor_admin.fetchall()
if not chambres:
st.warning("Aucune chambre froide pour ce site.")
else:
for chambre in chambres:
col1, col2, col3 = st.columns([3, 1, 2])
with col1:
st.markdown(f"**{chambre['Sonde']}**")
with col2:
etat = st.checkbox("ON", value=(chambre["Etat"] == "ON"),
key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}")
new_etat = "ON" if etat else "OFF"
with col3:
temp_max = chambre["Temp_Max"]
moins, val, plus = st.columns([1, 2, 1])
with moins:
if st.button("", key=f"moins_{chambre['Id']}"):
temp_max -= 1
with val:
st.markdown(f"<div style='text-align:center;font-size:20px'>{temp_max}°C</div>",
unsafe_allow_html=True)
with plus:
if st.button("", key=f"plus_{chambre['Id']}"):
temp_max += 1
if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]:
cursor_admin.execute(
"UPDATE Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s",
(new_etat, temp_max, chambre["Id"])
)
conn_admin.commit()
st.success(f"{chambre['Sonde']} mise à jour")
cursor_admin.close()
conn_admin.close()
except Exception as e:
st.error(f"Erreur SQL (admin) : {e}")
# --- ONGLET ENTRETIEN ---
elif onglet == "Entretien":
st.markdown("## 🧰 Gestion des sondes en entretien")
st.markdown("</div><br>", unsafe_allow_html=True)
role = st.session_state.get("role", "utilisateur")
lieu = st.session_state.get("lieu_autorise") if role != "superviseur" else st.selectbox("Choisir un lieu :",
["Saclay", "Meudon"])
if not lieu:
st.warning("Aucun site sélectionné.")
st.stop()
else:
st.info(f"Site imposé : {lieu}" if role != "superviseur" else "")
def get_sondes_par_lieu(lieu):
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT Id, Sonde, En_entretien FROM Chambres_froides WHERE Lieu = %s", (lieu,))
sondes = cursor.fetchall()
cursor.close()
conn.close()
return sondes
def maj_entretien(sonde_id, en_entretien, utilisateur, cible):
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor()
cursor.execute("UPDATE Chambres_froides SET En_entretien = %s WHERE Id = %s", (en_entretien, sonde_id))
cursor.execute(
"INSERT INTO Connexion_Log (Utilisateur, Lieu, Date_Connexion, Action, Cible) VALUES (%s, %s, %s, %s, %s)",
(utilisateur, lieu, datetime.now(), f"{'Mise' if en_entretien else 'Retrait'} en entretien",
cible))
conn.commit()
cursor.close()
conn.close()
try:
sondes = get_sondes_par_lieu(lieu)
for sonde in sondes:
checked = st.checkbox(f"{sonde['Sonde']} (ID: {sonde['Id']})", value=sonde['En_entretien'],
key=f"entretien_{sonde['Id']}")
if checked != sonde['En_entretien']:
maj_entretien(sonde['Id'], checked, st.session_state.get("utilisateur", "Inconnu"), sonde['Sonde'])
st.success(f"{sonde['Sonde']} {'mise' if checked else 'retirée'} en entretien.")
except Exception as e:
st.error(f"Erreur lors du chargement des sondes : {e}")
# --- ONGLET TRAFFIC ------
elif onglet == "Traffic":
st.markdown("## 🚦 Connexions récentes")
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT Utilisateur, Lieu, Date_Connexion FROM Connexion_Log WHERE Lieu IS NOT NULL ORDER BY Date_Connexion DESC LIMIT 200"
)
connexions = cursor.fetchall()
cursor.close()
conn.close()
if connexions:
df_connexions = pd.DataFrame(connexions)
st.dataframe(df_connexions, use_container_width=True)
else:
st.info("Aucune connexion enregistrée.")
except Exception as e:
st.error(f"Erreur chargement des connexions : {e}")
# --- ONGLET UTILISATEURS ---
elif onglet == "Utilisateurs" and st.session_state.get("role") == "superviseur":
st.title("👥 Gestion des utilisateurs")
# --- Section Ajout ---
with st.expander(" Ajouter un nouvel utilisateur"):
new_user = st.text_input("Nom d'utilisateur")
new_pass = st.text_input("Mot de passe", type="password")
new_role = st.selectbox("Rôle", ["utilisateur", "superviseur"])
new_lieu = None
if new_role == "utilisateur":
new_lieu = st.selectbox("Lieu autorisé", ["Saclay", "Meudon", "Roissy"])
expiration = st.date_input("Date d'expiration (facultative)", value=None)
if st.button("Créer l'utilisateur"):
if new_user and new_pass:
success, message = ajouter_utilisateur(
utilisateur=new_user,
mot_de_passe=new_pass,
role=new_role,
lieu=new_lieu if new_role == "utilisateur" else None,
expiration=expiration if expiration else None
)
if success:
st.success(message)
else:
st.error(message)
else:
st.warning("Tous les champs obligatoires doivent être remplis.")
# --- Section Liste ---
st.markdown("### 📋 Utilisateurs existants")
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT utilisateur, role, Lieu, Expiration FROM MotsDePasse ORDER BY utilisateur")
users = cursor.fetchall()
cursor.close()
if users:
df_users = pd.DataFrame(users)
st.dataframe(df_users)
else:
st.info("Aucun utilisateur trouvé.")
# ✅ Appel de la gestion des expirations
afficher_gestion_expiration(conn)
conn.close()
except Exception as e:
st.error(f"Erreur lors du chargement des utilisateurs : {e}")

49
app/Logs/monitor.csv Normal file
View File

@@ -0,0 +1,49 @@
Date;Lieu;Sonde;Température;Seuil;État
2025-07-26 13:04:30;Saclay;Congelateur;-14.75;-15.0;Dépassement
2025-07-26 12:59:27;Saclay;Congelateur;-11.25;-15.0;Dépassement
2025-07-26 12:54:25;Saclay;Congelateur;-6.50;-15.0;Dépassement
2025-07-26 12:49:22;Saclay;Congelateur;-4.75;-15.0;Dépassement
2025-07-26 12:44:19;Saclay;Congelateur;-7.75;-15.0;Dépassement
2025-07-26 12:39:17;Saclay;Congelateur;-11.50;-15.0;Dépassement
2025-07-26 13:04:30;Saclay;BOF;1.75;8.0;Normal
2025-07-26 12:59:28;Saclay;BOF;2.25;8.0;Normal
2025-07-26 12:54:25;Saclay;BOF;1.00;8.0;Normal
2025-07-26 12:49:22;Saclay;BOF;2.00;8.0;Normal
2025-07-26 12:44:20;Saclay;BOF;0.75;8.0;Normal
2025-07-26 12:39:17;Saclay;BOF;2.00;8.0;Normal
2025-07-26 13:04:31;Saclay;Viandes;21.00;6.0;Dépassement
2025-07-26 12:59:28;Saclay;Viandes;21.00;6.0;Dépassement
2025-07-26 12:54:26;Saclay;Viandes;20.75;6.0;Dépassement
2025-07-26 12:49:23;Saclay;Viandes;21.00;6.0;Dépassement
2025-07-26 12:44:20;Saclay;Viandes;20.75;6.0;Dépassement
2025-07-26 12:39:18;Saclay;Viandes;21.00;6.0;Dépassement
2025-07-26 13:04:31;Saclay;Legumes;5.25;10.0;Normal
2025-07-26 12:59:29;Saclay;Legumes;4.25;10.0;Normal
2025-07-26 12:54:26;Saclay;Legumes;2.75;10.0;Normal
2025-07-26 12:49:23;Saclay;Legumes;5.50;10.0;Normal
2025-07-26 12:44:21;Saclay;Legumes;5.00;10.0;Normal
2025-07-26 12:39:18;Saclay;Legumes;3.75;10.0;Normal
2025-07-26 13:04:32;Saclay;MeP;6.25;8.0;Normal
2025-07-26 12:59:29;Saclay;MeP;7.75;8.0;Normal
2025-07-26 12:54:27;Saclay;MeP;7.00;8.0;Normal
2025-07-26 12:49:24;Saclay;MeP;5.75;8.0;Normal
2025-07-26 12:44:21;Saclay;MeP;3.25;8.0;Normal
2025-07-26 12:39:19;Saclay;MeP;4.75;8.0;Normal
2025-07-26 07:09:45;Meudon;Viandes;3.94;6.0;Normal
2025-07-25 14:37:50;Meudon;Viandes;3.94;6.0;Normal
2025-07-25 14:32:11;Meudon;Viandes;3.94;6.0;Normal
2025-07-25 14:30:20;Meudon;Viandes;3.94;6.0;Normal
2025-07-24 11:00:36;Meudon;Viandes;3.94;6.0;Normal
2025-07-24 10:41:08;Meudon;Viandes;3.94;6.0;Normal
2025-07-26 07:09:45;Meudon;Poissons;3.94;6.0;Normal
2025-07-25 14:37:50;Meudon;Poissons;3.94;6.0;Normal
2025-07-25 14:32:11;Meudon;Poissons;3.94;6.0;Normal
2025-07-25 14:30:20;Meudon;Poissons;3.94;6.0;Normal
2025-07-24 11:00:36;Meudon;Poissons;3.94;6.0;Normal
2025-07-24 10:41:08;Meudon;Poissons;3.94;6.0;Normal
2025-07-26 07:09:45;Meudon;BOF;3.00;8.0;Normal
2025-07-25 14:37:50;Meudon;BOF;3.00;8.0;Normal
2025-07-25 14:32:11;Meudon;BOF;3.00;8.0;Normal
2025-07-25 14:30:20;Meudon;BOF;3.00;8.0;Normal
2025-07-24 11:00:36;Meudon;BOF;3.00;8.0;Normal
2025-07-24 10:41:08;Meudon;BOF;3.00;8.0;Normal
1 Date Lieu Sonde Température Seuil État
2 2025-07-26 13:04:30 Saclay Congelateur -14.75 -15.0 Dépassement
3 2025-07-26 12:59:27 Saclay Congelateur -11.25 -15.0 Dépassement
4 2025-07-26 12:54:25 Saclay Congelateur -6.50 -15.0 Dépassement
5 2025-07-26 12:49:22 Saclay Congelateur -4.75 -15.0 Dépassement
6 2025-07-26 12:44:19 Saclay Congelateur -7.75 -15.0 Dépassement
7 2025-07-26 12:39:17 Saclay Congelateur -11.50 -15.0 Dépassement
8 2025-07-26 13:04:30 Saclay BOF 1.75 8.0 Normal
9 2025-07-26 12:59:28 Saclay BOF 2.25 8.0 Normal
10 2025-07-26 12:54:25 Saclay BOF 1.00 8.0 Normal
11 2025-07-26 12:49:22 Saclay BOF 2.00 8.0 Normal
12 2025-07-26 12:44:20 Saclay BOF 0.75 8.0 Normal
13 2025-07-26 12:39:17 Saclay BOF 2.00 8.0 Normal
14 2025-07-26 13:04:31 Saclay Viandes 21.00 6.0 Dépassement
15 2025-07-26 12:59:28 Saclay Viandes 21.00 6.0 Dépassement
16 2025-07-26 12:54:26 Saclay Viandes 20.75 6.0 Dépassement
17 2025-07-26 12:49:23 Saclay Viandes 21.00 6.0 Dépassement
18 2025-07-26 12:44:20 Saclay Viandes 20.75 6.0 Dépassement
19 2025-07-26 12:39:18 Saclay Viandes 21.00 6.0 Dépassement
20 2025-07-26 13:04:31 Saclay Legumes 5.25 10.0 Normal
21 2025-07-26 12:59:29 Saclay Legumes 4.25 10.0 Normal
22 2025-07-26 12:54:26 Saclay Legumes 2.75 10.0 Normal
23 2025-07-26 12:49:23 Saclay Legumes 5.50 10.0 Normal
24 2025-07-26 12:44:21 Saclay Legumes 5.00 10.0 Normal
25 2025-07-26 12:39:18 Saclay Legumes 3.75 10.0 Normal
26 2025-07-26 13:04:32 Saclay MeP 6.25 8.0 Normal
27 2025-07-26 12:59:29 Saclay MeP 7.75 8.0 Normal
28 2025-07-26 12:54:27 Saclay MeP 7.00 8.0 Normal
29 2025-07-26 12:49:24 Saclay MeP 5.75 8.0 Normal
30 2025-07-26 12:44:21 Saclay MeP 3.25 8.0 Normal
31 2025-07-26 12:39:19 Saclay MeP 4.75 8.0 Normal
32 2025-07-26 07:09:45 Meudon Viandes 3.94 6.0 Normal
33 2025-07-25 14:37:50 Meudon Viandes 3.94 6.0 Normal
34 2025-07-25 14:32:11 Meudon Viandes 3.94 6.0 Normal
35 2025-07-25 14:30:20 Meudon Viandes 3.94 6.0 Normal
36 2025-07-24 11:00:36 Meudon Viandes 3.94 6.0 Normal
37 2025-07-24 10:41:08 Meudon Viandes 3.94 6.0 Normal
38 2025-07-26 07:09:45 Meudon Poissons 3.94 6.0 Normal
39 2025-07-25 14:37:50 Meudon Poissons 3.94 6.0 Normal
40 2025-07-25 14:32:11 Meudon Poissons 3.94 6.0 Normal
41 2025-07-25 14:30:20 Meudon Poissons 3.94 6.0 Normal
42 2025-07-24 11:00:36 Meudon Poissons 3.94 6.0 Normal
43 2025-07-24 10:41:08 Meudon Poissons 3.94 6.0 Normal
44 2025-07-26 07:09:45 Meudon BOF 3.00 8.0 Normal
45 2025-07-25 14:37:50 Meudon BOF 3.00 8.0 Normal
46 2025-07-25 14:32:11 Meudon BOF 3.00 8.0 Normal
47 2025-07-25 14:30:20 Meudon BOF 3.00 8.0 Normal
48 2025-07-24 11:00:36 Meudon BOF 3.00 8.0 Normal
49 2025-07-24 10:41:08 Meudon BOF 3.00 8.0 Normal

146
app/Monitor.py Normal file
View File

@@ -0,0 +1,146 @@
import os
import time
from datetime import datetime, timedelta
from pathlib import Path
from app.utils_db import connect_to_mysql
from dotenv import load_dotenv
from app.utils_sms import envoyer_sms
if os.name != 'nt':
log_dir = Path('/home/debian/Gestion_sondes/Logs')
else:
log_dir = Path.cwd() / 'Logs'
log_dir.mkdir(parents=True, exist_ok=True)
load_dotenv()
ENVOI_SMS = os.getenv("ENVOI_SMS") == "1"
print("▶️ Lancement Monitor.py")
# --- Suivi des alertes actives pour rappels ---
alertes_actives = {}
# --- Fonction de surveillance ---
def surveiller():
global alertes_actives
log_entries = []
try:
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT DISTINCT Lieu FROM `Chambres_froides`")
lieux = [row['Lieu'] for row in cursor.fetchall()]
for lieu in lieux:
table_temp = lieu
table_alertes = f"Alertes_{lieu}"
cursor.execute("""
SELECT Sonde, Temp_Max
FROM Sondes.Chambres_froides
WHERE Lieu = %s
AND Etat = 'ON'
AND En_entretien = 0
""", (lieu,))
sondes = cursor.fetchall()
for sonde in sondes:
nom_sonde = sonde['Sonde']
seuil = sonde['Temp_Max']
cursor.execute(f"""
SELECT Date, Temperature FROM {table_temp}
WHERE Sonde = %s
ORDER BY Date DESC LIMIT 6
""", (nom_sonde,))
releves = cursor.fetchall()
for r in releves:
log_entries.append({
"Date": r['Date'],
"Lieu": lieu,
"Sonde": nom_sonde,
"Température": r['Temperature'],
"Seuil": seuil,
"État": "Dépassement" if r['Temperature'] > seuil else "Normal"
})
if len(releves) == 6:
toutes_hors_seuil = all(r['Temperature'] > seuil for r in releves)
plus_ancien = releves[-1]['Date']
maintenant = datetime.now()
if toutes_hors_seuil and (maintenant - plus_ancien >= timedelta(minutes=30)):
cursor.execute(f"""
SELECT COUNT(*) as total FROM {table_alertes}
WHERE Sonde=%s AND Status='En cours'
""", (nom_sonde,))
en_cours = cursor.fetchone()
if en_cours['total'] == 0:
cursor.execute(
f"INSERT INTO {table_alertes} (Sonde, Debut_defaut, Status) VALUES (%s, NOW(), 'En cours')",
(nom_sonde,)
)
print(f"🚨 Alerte déclenchée pour {nom_sonde} ({lieu})", flush=True)
message = (
f"La sonde '{nom_sonde}' du site '{lieu}' a dépassé le seuil de {seuil}°C "
f"depuis plus de 30 minutes.\nHeure : {maintenant.strftime('%Y-%m-%d %H:%M:%S')}"
)
if ENVOI_SMS:
envoyer_sms(message, lieu)
alertes_actives[nom_sonde] = maintenant
else:
dernier_envoi = alertes_actives.get(nom_sonde)
if dernier_envoi and (maintenant - dernier_envoi >= timedelta(hours=1)):
message = (
f"La sonde '{nom_sonde}' du site '{lieu}' est TOUJOURS en dépassement de seuil (>{seuil}°C).\n"
f"Heure : {maintenant.strftime('%Y-%m-%d %H:%M:%S')}"
)
if ENVOI_SMS:
envoyer_sms(message, lieu)
alertes_actives[nom_sonde] = maintenant
# Vérifier retour à la normale (Acquittement)
cursor.execute(f"""
SELECT Temperature FROM {table_temp}
WHERE Sonde = %s
ORDER BY Date DESC LIMIT 1
""", (nom_sonde,))
derniere = cursor.fetchone()
if derniere and derniere['Temperature'] <= seuil:
cursor.execute(f"""
UPDATE {table_alertes}
SET Status = 'Acquitté'
WHERE Sonde = %s AND Status IN ('En cours', 'Test')
""", (nom_sonde,))
if nom_sonde in alertes_actives:
del alertes_actives[nom_sonde]
conn.commit()
cursor.close()
conn.close()
if log_entries:
import pandas as pd
df_logs = pd.DataFrame(log_entries)
df_logs.to_csv(log_dir / "monitor.csv", sep=";", index=False)
if os.name != 'nt':
df_logs.to_csv("/var/log/monitor.csv", sep=";", index=False)
except Exception as e:
print(f"Erreur : {e}", flush=True)
# --- Boucle principale ---
while True:
print(f"📡 Vérification à {datetime.now()}", flush=True)
surveiller()
time.sleep(300) # 5 minutes

163
app/Technique.py Normal file
View File

@@ -0,0 +1,163 @@
import streamlit as st
import pandas as pd
import altair as alt
from dotenv import load_dotenv
from .utils_db import connect_to_mysql
st.set_page_config(page_title="Tech Chaufferie", layout="wide")
load_dotenv() # charger .env à la racine du projet
def login_commun():
login = st.text_input("Identifiant", type="default")
password = st.text_input("Mot de passe", type="password")
if st.button("Se connecter"):
user = verifier_utilisateur_commun(login, password)
if user:
st.session_state["authenticated"] = True
st.session_state["utilisateur"] = user["NomUtilisateur"]
st.success("✅ Connexion réussie")
st.rerun()
else:
st.error("❌ Identifiants incorrects ou expirés")
# Authentification
if "authenticated" not in st.session_state:
st.session_state["authenticated"] = False
if not st.session_state["authenticated"]:
st.title("🔧 Suivi des sondes de la chaufferie")
st.subheader("🔐 Connexion technicien chaufferie")
login_commun()
st.stop()
with st.sidebar:
st.markdown(f"👤 Connecté en tant que **{st.session_state['utilisateur']}**")
if st.button("🔓 Se déconnecter"):
st.session_state.clear()
st.rerun()
# --- Affichage principal ---
st.title("🔧 Suivi des sondes de la chaufferie")
data = get_latest_chaufferie()
df = pd.DataFrame(data)
# Ajout d'un statut couleur
def statut(temp):
if temp > 75:
return "❌ Très chaud"
elif temp > 60:
return "⚠️ Surveiller"
else:
return "✅ OK"
df["Statut"] = df["Temperature"].apply(statut)
# Séparer par topic
for topic in df["Topic"].unique():
st.subheader(f"Zone : {topic}")
st.dataframe(df[df["Topic"] == topic][["Sonde", "Temperature", "Date", "Statut"]], use_container_width=True)
# Graphique historique
st.markdown("---")
st.header("📈 Historique par sonde (24h)")
# Ce bloc doit exister pour que sonde_selection soit défini
sonde_selection = st.selectbox("Choisir une sonde à afficher", df["Sonde"].unique())
if sonde_selection:
historique = get_history_by_sonde(sonde_selection)
if historique:
df_hist = pd.DataFrame(historique)
df_hist["Date"] = pd.to_datetime(df_hist["Date"])
df_hist["Temperature"] = pd.to_numeric(df_hist["Temperature"], errors="coerce")
# ✅ Message si -127°C détecté
if (df_hist["Temperature"] <= -126).any():
st.error("🚨 Sonde défaillante détectée (-127°C). Vérification urgente requise.")
# ✅ Ligne limite (modifiable ici)
limite = 80
ligne_limite = alt.Chart(pd.DataFrame({
"y": [limite]
})).mark_rule(color="red").encode(y="y")
# ✅ Points rouge si erreur (-127°C)
alerte_sonde = alt.Chart(df_hist[df_hist["Temperature"] <= -126]).mark_point(
shape="cross", color="red", size=100
).encode(
x="Date:T",
y="Temperature:Q",
tooltip=["Date:T", "Temperature:Q"]
)
# Insérer une alerte dans Mysql en cas de sonde défectueuse
def inserer_alerte_defaut_sonde(sonde):
conn = None
cursor = None
try:
cursor = conn.cursor()
# Vérifie s'il existe déjà une alerte en cours
query_check = """
SELECT COUNT(*) FROM Alertes_Chaufferie
WHERE Sonde = %s AND Etat = 'En cours'
"""
cursor.execute(query_check, (sonde,))
count = cursor.fetchone()[0]
if count == 0:
# Date du premier -127°C détecté
date_defaut = df_hist[df_hist["Temperature"] <= -126]["Date"].min()
query_insert = """
INSERT INTO Alertes_Chaufferie (Sonde, Debut_defaut, Etat)
VALUES (%s, %s, 'En cours')
"""
cursor.execute(query_insert, (sonde, date_defaut))
conn.commit()
except Exception as e:
st.warning(f"⚠️ Erreur enregistrement alerte : {e}")
finally:
if cursor:
cursor.close()
if conn and conn.is_connected():
conn.close()
# 🔁 Appel de la fonction si -127 détecté
if (df_hist["Temperature"] <= -126).any():
inserer_alerte_defaut_sonde(sonde_selection, df_hist["Date"].min())
# ✅ Graphique principal
chart = alt.Chart(df_hist).mark_line().encode(
x=alt.X("Date:T", title="Date"),
y=alt.Y("Temperature:Q", title="Température (°C)", scale=alt.Scale(domain=[-130, 100])),
tooltip=["Date:T", "Temperature:Q"]
).properties(
width=900,
height=400,
title=f"Évolution sur 24h de {sonde_selection}"
)
st.altair_chart(chart + ligne_limite + alerte_sonde, use_container_width=True)
else:
st.info("Aucune donnée disponible pour cette sonde dans les dernières 24h.")
st.markdown("---")
st.header("🧯 Alertes sondes défaillantes")
alertes = lire_alertes_sondes()
if alertes:
for alerte in alertes:
col1, col2, col3, col4 = st.columns([3, 3, 2, 2])
col1.markdown(f"**Sonde :** {alerte['Sonde']}")
col2.markdown(f"**Défaut depuis :** {alerte['Debut_defaut'].strftime('%Y-%m-%d %H:%M')}")
col3.markdown(f"**État :** :orange[{alerte['Etat']}]")
if alerte['Etat'] != 'Acquitté':
if col4.button("✅ Acquitter", key=f"acquitter_{alerte['Id']}"):
acquitter_alerte(alerte['Id'])
st.rerun()
else:
st.success("Aucune alerte active.")

0
app/_init_.py Normal file
View File

82
app/db.py Normal file
View File

@@ -0,0 +1,82 @@
# utils/db.py
from datetime import datetime
from app.utils_db import connect_to_mysql # ✅ Import centralisé
def get_latest_chaufferie():
"""Renvoie les dernières valeurs par sonde dans la table 'Chaufferie'."""
db = connect_to_mysql()
cursor = db.cursor(dictionary=True)
query = """
SELECT c1.*
FROM Sondes.Chaufferie c1
INNER JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM Sondes.Chaufferie
GROUP BY Sonde
) c2 ON c1.Sonde = c2.Sonde AND c1.Date = c2.MaxDate
ORDER BY c1.Sonde;
"""
cursor.execute(query)
result = cursor.fetchall()
cursor.close()
db.close()
return result
def get_history_by_sonde(sonde: str, start: datetime, end: datetime):
"""Retourne lhistorique des températures dune sonde entre deux dates."""
db = connect_to_mysql()
cursor = db.cursor(dictionary=True)
query = """
SELECT * FROM Sondes.Chaufferie
WHERE Sonde = %s AND Date BETWEEN %s AND %s
ORDER BY Date;
"""
cursor.execute(query, (sonde, start, end))
result = cursor.fetchall()
cursor.close()
db.close()
return result
def verifier_utilisateur_commun(utilisateur: str, motdepasse: str):
"""Vérifie si un utilisateur (non superviseur) existe dans la table MotsDePasse."""
db = connect_to_mysql()
cursor = db.cursor(dictionary=True)
query = """
SELECT * FROM Sondes.MotsDePasse
WHERE utilisateur = %s AND mot_de_passe = %s AND role = 'utilisateur'
"""
cursor.execute(query, (utilisateur, motdepasse))
result = cursor.fetchone()
cursor.close()
db.close()
return result
def lire_alertes_sondes():
"""Renvoie la liste des alertes non acquittées dans la table Alertes_Chaufferie."""
db = connect_to_mysql()
cursor = db.cursor(dictionary=True)
query = """
SELECT * FROM Sondes.Alertes_Chaufferie
WHERE Etat != 'Acquitté'
ORDER BY Debut_defaut DESC
"""
cursor.execute(query)
result = cursor.fetchall()
cursor.close()
db.close()
return result
def acquitter_alerte(id_alerte: int):
"""Met à jour une alerte comme acquittée dans la base."""
db = connect_to_mysql()
cursor = db.cursor()
query = "UPDATE Sondes.Alertes_Chaufferie SET Etat = 'Acquitté' WHERE Id = %s"
cursor.execute(query, (id_alerte,))
db.commit()
cursor.close()
db.close()

358
app/domo91.py Normal file
View File

@@ -0,0 +1,358 @@
# -*- coding: utf-8 -*-
import streamlit as st
import mysql.connector
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import os
from dotenv import load_dotenv
from datetime import datetime, date
import bcrypt
import traceback
import random
# Charger les variables d'environnement
load_dotenv()
st.set_page_config(page_title="Domo91 - Surveillance", layout="wide")
st.title("📊 Domo91 - Surveillance des sondes")
st.write("Bienvenue sur lapplication de supervision.")
# Initialisation session state avec valeurs sûres
for key, default in {
"authenticated": False,
"role": None,
"lieu_autorise": None,
"onglet_actif": "Accueil",
"selected_date": date.today(),
"selected_site": "Saclay",
"selected_periode": "Toute la journée",
}.items():
st.session_state.setdefault(key, default)
# Configuration MySQL
db_config = {
"host": os.getenv("DB_HOST"),
"user": os.getenv("DB_USER"),
"password": os.getenv("DB_PASSWORD"),
"database": os.getenv("DB_NAME")
}
def get_connection():
return mysql.connector.connect(**db_config)
def hash_password(plain_password):
return bcrypt.hashpw(plain_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verifier_password(input_password, hash_en_base):
return bcrypt.checkpw(input_password.encode('utf-8'), hash_en_base.encode('utf-8'))
# --- Connexion utilisateur ---
if not st.session_state["authenticated"]:
login = st.sidebar.text_input("Nom d'utilisateur")
password = st.sidebar.text_input("Mot de passe", type="password")
if st.sidebar.button("Se connecter"):
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM MotsDePasse WHERE utilisateur = %s", (login,))
result = cursor.fetchone()
if result and verifier_password(password, result["mot_de_passe"]):
if result["Expiration"] and result["Expiration"] < date.today():
st.sidebar.error("⛔ Accès expiré.")
cursor.close()
conn.close()
st.stop()
st.session_state.update({
"authenticated": True,
"role": result["role"],
"lieu_autorise": result["Lieu"]
})
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
cursor.execute("INSERT INTO Connexion_Log (Utilisateur, Lieu, Date_Connexion) VALUES (%s, %s, %s)",
(login, result["Lieu"], now_str))
conn.commit()
st.rerun()
else:
st.sidebar.error("Identifiants invalides")
cursor.close()
conn.close()
except Exception as e:
st.sidebar.error(f"Erreur connexion : {e}")
else:
st.sidebar.success(f"Connecté ({st.session_state['role']})")
if st.sidebar.button("🔓 Déconnexion"):
for key in ["authenticated", "role", "lieu_autorise"]:
st.session_state[key] = False if key == "authenticated" else None
st.rerun()
# --- Navigation ---
if st.session_state["authenticated"]:
onglets = ["Accueil", "Entretien"] if st.session_state["role"] != "superviseur" else ["Accueil", "Statistiques",
"Entretien", "Traffic",
"Utilisateurs"]
onglet_selectionne = st.sidebar.radio("📁 Navigation", onglets,
index=onglets.index(st.session_state["onglet_actif"]))
st.session_state["onglet_actif"] = onglet_selectionne
site_actuel = st.session_state.get("lieu_autorise") if st.session_state[
"role"] != "superviseur" else st.session_state.get(
"selected_site", "Saclay")
date_selectionnee = st.session_state.get("selected_date", date.today())
periode_selectionnee = st.session_state.get("selected_periode", "Toute la journée")
# --- Onglet Accueil ---
if onglet_selectionne == "Accueil":
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
if st.session_state["role"] == "superviseur":
site_actuel = st.selectbox("📍 Choisissez un site :", ["Saclay", "Meudon"], index=0)
st.session_state["selected_site"] = site_actuel
else:
st.info(f"Site imposé : {site_actuel}")
date_selectionnee = st.date_input("📅 Date du relevé", value=date_selectionnee)
st.session_state["selected_date"] = date_selectionnee
cursor.execute(f"SELECT * FROM `{site_actuel}` WHERE DATE(Date) = %s ORDER BY Sonde, Date DESC",
(date_selectionnee.strftime("%Y-%m-%d"),))
rows = cursor.fetchall()
if rows:
df = pd.DataFrame(rows)
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde_choisie = st.selectbox("🧪 Choisissez une sonde :", sondes)
df_sonde = df[df["Sonde"] == sonde_choisie].copy()
df_sonde["Heure"] = df_sonde["Date"].dt.hour
tranche = st.radio("🕒 Tranche horaire :",
["Toute la journée", "Matin (6h-12h)", "Après-midi (12h-18h)", "Nuit (18h-6h)"])
st.session_state["selected_periode"] = tranche
if tranche == "Matin (6h-12h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 6) & (df_sonde["Heure"] < 12)]
elif tranche == "Après-midi (12h-18h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 12) & (df_sonde["Heure"] < 18)]
elif tranche == "Nuit (18h-6h)":
df_sonde = df_sonde[(df_sonde["Heure"] >= 18) | (df_sonde["Heure"] < 6)]
seuil_temp = 10
cursor.execute("SELECT Temp_Max FROM Chambres_froides WHERE Lieu = %s AND Sonde = %s",
(site_actuel, sonde_choisie))
seuil = cursor.fetchone()
if seuil:
seuil_temp = seuil["Temp_Max"]
st.subheader("📊 Tableau des relevés")
def surlignage_temp(val):
try:
if float(val) > seuil_temp:
return "color: red; font-weight: bold"
except:
pass
return ""
styled_df = df_sonde.style.applymap(surlignage_temp, subset=["Temperature"])
st.dataframe(styled_df, use_container_width=True)
st.subheader("📈 Évolution de la température")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker='o')
ax.axhline(seuil_temp, color='red', linestyle='--', label=f"Seuil {seuil_temp}°C")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.set_title(f"{sonde_choisie} - {date_selectionnee.strftime('%d/%m/%Y')}")
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
ax.legend()
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
# --- Onglet Statistiques ---
elif onglet_selectionne == "Statistiques":
st.markdown("## 📈 Statistiques de température")
site = (
st.session_state["lieu_autorise"]
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_val = st.session_state.get("selected_date", date.today())
try:
conn = mysql.connector.connect(**db_config)
cursor = conn.cursor(dictionary=True)
site = (
st.session_state["lieu_autorise"]
if st.session_state["role"] != "superviseur"
else st.session_state.get("selected_site", "Saclay")
)
date_val = st.session_state.get("selected_date", date.today())
cursor.execute(
f"SELECT * FROM `{site}` WHERE DATE(Date) = %s ORDER BY Sonde, Date",
(date_val.strftime("%Y-%m-%d"),)
)
rows = cursor.fetchall()
df = pd.DataFrame(rows)
if df.empty:
st.info("Aucune donnée pour cette date.")
else:
df["Date"] = pd.to_datetime(df["Date"])
sondes = sorted(df["Sonde"].unique())
sonde = st.selectbox("Choisir une sonde :", sondes, key="selectbox_stats")
df_sonde = df[df["Sonde"] == sonde]
st.subheader("Évolution journalière")
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(df_sonde["Date"], df_sonde["Temperature"], marker='o')
ax.set_title(f"{sonde} - {date_val.strftime('%d/%m/%Y')}")
ax.set_xlabel("Heure")
ax.set_ylabel("Température (°C)")
ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
st.pyplot(fig)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur chargement statistiques : {e}")
# Tableau consignes chambres froides
if st.session_state["role"] == "superviseur":
with st.expander("🛠️ Gestion des chambres froides (administrateur)", expanded=True):
if st.button("🔄 Actualiser la liste"):
st.session_state["refresh_admin"] = random.randint(0, 9999)
try:
conn_admin = mysql.connector.connect(**db_config)
cursor_admin = conn_admin.cursor(dictionary=True)
cursor_admin.execute("SELECT * FROM Chambres_froides WHERE Lieu = %s", (site,))
chambres = cursor_admin.fetchall()
if not chambres:
st.warning("Aucune chambre froide pour ce site.")
else:
for chambre in chambres:
col1, col2, col3 = st.columns([3, 1, 2])
with col1:
st.markdown(f"**{chambre['Sonde']}**")
with col2:
etat = st.checkbox("ON", value=(chambre["Etat"] == "ON"),
key=f"etat_{chambre['Id']}_{st.session_state.get('refresh_admin', 0)}")
new_etat = "ON" if etat else "OFF"
with col3:
temp_max = chambre["Temp_Max"]
moins, temp_display, plus = st.columns([1, 2, 1])
with moins:
if st.button("", key=f"moins_{chambre['Id']}"):
temp_max -= 1
with temp_display:
st.markdown(f"<div style='text-align:center;font-size:20px'>{temp_max}°C</div>",
unsafe_allow_html=True)
with plus:
if st.button("", key=f"plus_{chambre['Id']}"):
temp_max += 1
if new_etat != chambre["Etat"] or temp_max != chambre["Temp_Max"]:
cursor_admin.execute(
"UPDATE Chambres_froides SET Etat = %s, Temp_Max = %s WHERE Id = %s",
(new_etat, temp_max, chambre["Id"])
)
conn_admin.commit()
st.success(f"{chambre['Sonde']} mise à jour")
cursor_admin.close()
conn_admin.close()
except Exception as e:
st.error(f"Erreur SQL (admin) : {e}")
# --- Onglet Entretien ---
elif onglet_selectionne == "Entretien":
st.header("🧰 Gestion Entretien")
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT Id, Sonde, En_entretien FROM Chambres_froides WHERE Lieu = %s", (site_actuel,))
sondes = cursor.fetchall()
for sonde in sondes:
checked = st.checkbox(f"{sonde['Sonde']}", value=sonde['En_entretien'])
if checked != sonde['En_entretien']:
cursor.execute("UPDATE Chambres_froides SET En_entretien = %s WHERE Id = %s",
(checked, sonde['Id']))
conn.commit()
st.success(f"{sonde['Sonde']} {'mise' if checked else 'retirée'} en entretien.")
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
# --- Onglet Traffic ---
elif onglet_selectionne == "Traffic":
st.header("🚦 Connexions récentes")
try:
conn = get_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute(
"SELECT Utilisateur, Lieu, Date_Connexion FROM Connexion_Log ORDER BY Date_Connexion DESC LIMIT 100")
logs = cursor.fetchall()
df_logs = pd.DataFrame(logs)
st.dataframe(df_logs)
cursor.close()
conn.close()
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())
# --- Onglet Utilisateurs ---
elif onglet_selectionne == "Utilisateurs":
st.header("👥 Gestion des utilisateurs")
with st.form("ajouter_utilisateur"):
new_user = st.text_input("Nom d'utilisateur")
new_pass = st.text_input("Mot de passe", type="password")
new_role = st.selectbox("Rôle", ["utilisateur", "superviseur"])
new_lieu = st.selectbox("Lieu", ["Saclay", "Meudon", "Roissy"])
if st.form_submit_button("Ajouter"):
try:
conn = get_connection()
cursor = conn.cursor()
hash_mdp = hash_password(new_pass)
cursor.execute(
"INSERT INTO MotsDePasse (utilisateur, mot_de_passe, role, Lieu) VALUES (%s, %s, %s, %s)",
(new_user, hash_mdp, new_role, new_lieu))
conn.commit()
cursor.close()
conn.close()
st.success("Utilisateur ajouté.")
except Exception as e:
st.error(f"Erreur : {e}")
st.text(traceback.format_exc())

35
app/logger_config.py Normal file
View File

@@ -0,0 +1,35 @@
import os
import logging
def setup_logger(log_filename: str, dossier_logs: str = "/var/log/Cuisine_Saclay") -> None:
"""
Configure le logger pour écrire à la fois dans un fichier et sur la console.
:param log_filename: Nom du fichier de log (exemple : 'Cuisine_Saclay.log')
:param dossier_logs: Dossier où enregistrer les logs (par défaut : /var/log/Cuisine_Saclay)
"""
# 📁 Créer le dossier s'il n'existe pas
os.makedirs(dossier_logs, exist_ok=True)
# 📄 Chemin complet du fichier de log
logfile = os.path.join(dossier_logs, log_filename)
# 📝 Configuration de base du logger (fichier)
logging.basicConfig(
filename=logfile,
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
filemode="a" # ajouter au fichier existant
)
# 🔔 Ajout de la sortie console
console = logging.StreamHandler()
console.setLevel(logging.INFO)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
console.setFormatter(formatter)
# 👇 Ajouter le handler console au logger racine
logging.getLogger('').addHandler(console)
logging.info(f"Logger initialisé. Fichier de log : {logfile}")

48
app/mqtt_logger.py Normal file
View File

@@ -0,0 +1,48 @@
import argparse
import paho.mqtt.client as mqtt_client
from dotenv import load_dotenv
import logging
from app.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

@@ -0,0 +1,45 @@
#!/home/debian/Gestion_sondes/myenv/bin/python
import subprocess
import smtplib
from email.mime.text import MIMEText
from datetime import datetime
heure_actuelle = datetime.now().strftime("%H:%M")
etat_services = []
anomalies = []
try:
output = subprocess.check_output("sudo /usr/bin/supervisorctl status", shell=True, text=True)
for line in output.splitlines():
parts = line.split()
if len(parts) >= 2:
nom, statut = parts[0], parts[1]
etat_services.append(f"{nom}{statut}")
if statut != "RUNNING":
anomalies.append(f"{nom}{statut}")
except Exception as e:
etat_services.append("❌ Impossible d'exécuter supervisorctl")
anomalies.append(f"Erreur : {e}")
# Déclenchement mail si anomalie ou à 07:00
envoyer_mail = bool(anomalies) or heure_actuelle == "07:00"
if envoyer_mail:
sujet = "⚠️ Alerte Supervisor" if anomalies else "✅ Rapport quotidien Supervisor"
intro = "🛑 Les services suivants ne sont pas en RUNNING :" if anomalies else "✅ Tous les services supervisés sont en RUNNING."
contenu = f"{intro}\n\n" + "\n".join(etat_services)
msg = MIMEText(contenu)
msg["Subject"] = sujet
msg["From"] = "supervisor@domo91.fr"
msg["To"] = "services@domo91.fr"
try:
with smtplib.SMTP_SSL("smtp.mail.ovh.net", 465) as server:
server.login("services@domo91.fr", "6ZiCsVtSf9@nEHv@$^0")
server.sendmail(msg["From"], [msg["To"]], msg.as_string())
print("📧 Mail envoyé.")
except Exception as e:
print(f"Erreur envoi mail : {e}")
else:
print("🕖 Aucun mail envoyé (tout est OK et ce nest pas lheure du rapport).")

101
app/surveillance_releves.py Normal file
View File

@@ -0,0 +1,101 @@
#!/home/debian/Gestion_sondes/myenv/bin/python3
from datetime import datetime, timedelta
from dotenv import load_dotenv
import os
import utils_db
import logging
from utils_sms import envoyer_sms
# Dossier Logs
LOG_DIR = '/home/debian/Gestion_sondes/Logs'
os.makedirs(LOG_DIR, exist_ok=True)
# Fichier de log (nommé par date)
log_filename = os.path.join(LOG_DIR, datetime.now().strftime("surveillance_%Y-%m-%d.log"))
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_filename),
logging.StreamHandler() # Affiche aussi les logs dans la console
]
)
# Charger .env
load_dotenv('/home/debian/Gestion_sondes/.env')
# OVH SMS
APP_KEY = os.getenv('OVH_APP_KEY')
APP_SECRET = os.getenv('OVH_APP_SECRET')
CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY')
SERVICE_NAME = os.getenv('OVH_SERVICE_NAME')
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
SMS_SENDER = os.getenv('OVH_SMS_SENDER')
tables = ['Saclay', 'Meudon', 'Chaufferie']
DELAI_MINUTES = 15
RAPPEL_HEURES = 6
STATE_DIR = '/tmp/surveillance_states'
os.makedirs(STATE_DIR, exist_ok=True)
def should_send_alert(site):
state_file = os.path.join(STATE_DIR, f'{site}.state')
now = datetime.now()
if not os.path.exists(state_file):
with open(state_file, 'w') as f:
f.write(now.isoformat())
return True
with open(state_file, 'r') as f:
last_alert = datetime.fromisoformat(f.read().strip())
if now - last_alert >= timedelta(hours=RAPPEL_HEURES):
with open(state_file, 'w') as f:
f.write(now.isoformat())
return True
return False
def clear_state(site):
state_file = os.path.join(STATE_DIR, f'{site}.state')
if os.path.exists(state_file):
os.remove(state_file)
def main():
cnx = utils_db.connect_to_mysql() # ← appel via db_utils
cursor = cnx.cursor()
now = datetime.now()
limite = now - timedelta(minutes=DELAI_MINUTES)
problemes = []
for table in tables:
cursor.execute(f"SELECT MAX(Date) FROM {table}")
result = cursor.fetchone()
last_update = result[0]
if not last_update or last_update < limite:
if should_send_alert(table):
problemes.append(f"{table} (dernier relevé : {last_update})")
else:
logging.info(f"⏳ Problème déjà signalé pour {table}, attente du délai de rappel.")
else:
if os.path.exists(os.path.join(STATE_DIR, f'{table}.state')):
message = f"{table} : relevés à nouveau reçus. Situation normale."
envoyer_sms(message)
clear_state(table)
logging.info(f"📩 SMS de retour à la normale envoyé pour {table}.")
else:
logging.info(f"{table} OK (dernier relevé : {last_update})")
cursor.close()
cnx.close()
if problemes:
message = "⚠️ ALERTE : pas de relevés depuis >15min :\n" + "\n".join(problemes)
envoyer_sms(message)
else:
logging.info("👍 Tout est OK, aucun SMS envoyé.")
if __name__ == "__main__":
main()

23
app/utils_db.py Normal file
View File

@@ -0,0 +1,23 @@
import mysql.connector
import os
import sys
import logging
def connect_to_mysql(db_name: str = None):
"""
Se connecte à MySQL. Par défaut, utilise la base spécifiée dans DB_NAME,
mais peut être redirigé vers une autre base en passant db_name.
"""
try:
sys.path.insert(0, "/myenv/lib/python3.11.2/site-packages") # à ajuster si inutile
mydb = mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=db_name if db_name else os.getenv("DB_NAME")
)
logging.info(f"Connexion MySQL à la base {mydb.database} réussie.")
return mydb
except mysql.connector.Error as err:
logging.error(f"Erreur de connexion MySQL : {err}")
sys.exit(1)

34
app/utils_sms.py Normal file
View File

@@ -0,0 +1,34 @@
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("NUMERO_DESTINATAIRE")
result = client.post(f'/sms/{service_name}/jobs',
sender='Monitor',
message=message,
receivers=[numero_dest],
noStopClause=True
)
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)