Remise en état des relevés temp
This commit is contained in:
30
.env
30
.env
@@ -1,14 +1,25 @@
|
||||
# OVH_SMS_SENDER=DOMO91FR
|
||||
#connexion mysql
|
||||
DB_HOST=162.19.78.131
|
||||
DB_USER=sondes
|
||||
DB_PASS='TX.)-U1!zq5Axdk4'
|
||||
DB_PASS=TX.)-U1!zq5Axdk4
|
||||
DB_NAME=Sondes
|
||||
AUTH_USERS=[{"user":"Michel","pass":"210462"}]
|
||||
|
||||
# MQTT
|
||||
|
||||
# MQTT Saclay
|
||||
MQTT_HOST=54.36.188.119
|
||||
MQTT_USER=Bwps
|
||||
MQTT_PASS='scJ5ACj2keRfI^'
|
||||
MQTT_PASS=scJ5ACj2keRfI^
|
||||
|
||||
# --- MQTT Meudon ---
|
||||
MQTT_HOST_MEUDON=162.19.78.131
|
||||
MQTT_USER_MEUDON=sondes
|
||||
MQTT_PASS_MEUDON=3J@bjYP0
|
||||
MQTT_PORT_MEUDON=1883
|
||||
|
||||
# Topic gyrophare Meudon
|
||||
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
|
||||
|
||||
|
||||
# Boucle rapide du gyro
|
||||
GYRO_MODE=mqtt
|
||||
@@ -28,6 +39,17 @@ ALERT_LOOKBACK_MINUTES=120
|
||||
# Logs
|
||||
LOGLEVEL=INFO
|
||||
|
||||
# === Connexion SSH pour visualiseur_logs.py ===
|
||||
SSH_HOST=162.19.78.131
|
||||
SSH_PORT=22
|
||||
SSH_USER=debian
|
||||
SSH_KEY_PATH=/home/debian/.ssh/id_ed25519
|
||||
SSH_KEY_PASSPHRASE='gaby'
|
||||
SSH_LOG_DIR=/home/debian/Gestion_sondes/Logs
|
||||
VPS_HOST=162.19.78.131
|
||||
|
||||
|
||||
|
||||
# paramètres mail
|
||||
SMTP_HOST=ssl0.ovh.net
|
||||
SMTP_PORT=587
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import os
|
||||
import datetime as dt
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from dotenv import load_dotenv
|
||||
import mysql.connector as mc
|
||||
|
||||
# ----------------------
|
||||
# Config de la page
|
||||
# ----------------------
|
||||
st.set_page_config(page_title="Injection de données de test", page_icon="🧪", layout="centered")
|
||||
st.title("🧪 Injecteur de relevés de test (Sondes)")
|
||||
st.caption("Crée ~10 lignes au-dessus d'un seuil pour tester les alertes")
|
||||
|
||||
# ----------------------
|
||||
# Connexion MySQL depuis .env
|
||||
# ----------------------
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_connection():
|
||||
load_dotenv()
|
||||
try:
|
||||
cnx = mc.connect(
|
||||
host=os.getenv("DB_HOST"),
|
||||
user=os.getenv("DB_USER"),
|
||||
password=os.getenv("DB_PASS"),
|
||||
database=os.getenv("DB_NAME"),
|
||||
autocommit=True,
|
||||
)
|
||||
return cnx
|
||||
except Exception as e:
|
||||
st.error(f"Échec de connexion MySQL : {e}")
|
||||
raise
|
||||
|
||||
# ----------------------
|
||||
# Helpers : liste des sondes actives et hors entretien
|
||||
# ----------------------
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def list_sondes(site: str) -> list:
|
||||
"""Retourne la liste des sondes actives (Etat=ON) et non en entretien pour le site.
|
||||
Essaie d'abord monitor_{site}, puis Chambres_froides, sinon fallback via la table de mesures.
|
||||
"""
|
||||
cnx = get_connection()
|
||||
cur = cnx.cursor()
|
||||
# 1) monitor_{site}
|
||||
try:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT Sonde
|
||||
FROM `monitor_{site}`
|
||||
WHERE (Etat='ON' OR Etat=1)
|
||||
AND ( (Maintenance='OFF') OR (Maintenance=0) OR (Maintenance IS NULL) )
|
||||
ORDER BY Sonde
|
||||
"""
|
||||
)
|
||||
rows = [r[0] for r in cur.fetchall()]
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Chambres_froides
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT Sonde
|
||||
FROM `Chambres_froides`
|
||||
WHERE Lieu=%s AND (Etat='ON' OR Etat=1)
|
||||
AND ( (Maintenance='OFF') OR (Maintenance=0) OR (Maintenance IS NULL) )
|
||||
ORDER BY Sonde
|
||||
""",
|
||||
(site,)
|
||||
)
|
||||
rows = [r[0] for r in cur.fetchall()]
|
||||
if rows:
|
||||
return rows
|
||||
except Exception:
|
||||
pass
|
||||
# 3) Fallback : dernier état via table de mesures (distinct)
|
||||
try:
|
||||
cur.execute(
|
||||
f"SELECT DISTINCT Sonde FROM `{site}` ORDER BY Sonde LIMIT 200"
|
||||
)
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ----------------------
|
||||
# Helper : récupérer Temp_Max pour une sonde donnée
|
||||
# ----------------------
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def get_temp_max(site: str, sonde: str):
|
||||
"""Retourne Temp_Max pour (site, sonde) en cherchant d'abord dans monitor_{site}, puis Chambres_froides.
|
||||
Renvoie None si non trouvé."""
|
||||
try:
|
||||
cnx = get_connection()
|
||||
cur = cnx.cursor()
|
||||
# 1) monitor_{site}
|
||||
try:
|
||||
cur.execute(
|
||||
f"SELECT Temp_Max FROM `monitor_{site}` WHERE Sonde=%s LIMIT 1",
|
||||
(sonde,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] is not None:
|
||||
return float(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
# 2) Chambres_froides
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT Temp_Max
|
||||
FROM `Chambres_froides`
|
||||
WHERE Lieu=%s AND Sonde=%s
|
||||
LIMIT 1
|
||||
""",
|
||||
(site, sonde)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row and row[0] is not None:
|
||||
return float(row[0])
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ----------------------
|
||||
# UI paramètres
|
||||
# ----------------------
|
||||
with st.sidebar:
|
||||
st.header("Paramètres")
|
||||
site = st.selectbox("Site (table)", ["Saclay", "Meudon"], index=0)
|
||||
|
||||
# Sélecteur de sonde depuis la liste active / hors entretien
|
||||
options_sondes = list_sondes(site)
|
||||
if not options_sondes:
|
||||
st.warning("Aucune sonde active trouvée (ou table monitor introuvable). Vous pouvez saisir un nom manuel.")
|
||||
sonde = st.text_input("Nom de la sonde", value="TEST_Chambre1")
|
||||
else:
|
||||
sonde = st.selectbox("Sonde (actives, hors entretien)", options_sondes)
|
||||
|
||||
st.subheader("Température")
|
||||
# Auto-remplissage du seuil depuis la base et verrouillage par défaut
|
||||
_temp_db = get_temp_max(site, sonde)
|
||||
if _temp_db is None:
|
||||
st.warning("Temp_Max introuvable en base ; valeur par défaut 6.0°C.")
|
||||
_temp_db = 6.0
|
||||
allow_edit = st.checkbox("Autoriser la modification du seuil", value=False)
|
||||
temp_max = st.number_input("Seuil (Temp_Max)", value=float(_temp_db), step=0.1, disabled=not allow_edit)
|
||||
|
||||
delta = st.number_input("Delta au-dessus du seuil", value=1.0, step=0.1)
|
||||
absolute_override = st.checkbox("Définir une température absolue à la place")
|
||||
absolute_temp = st.number_input(
|
||||
"Température absolue (si coché)", value=12.5, step=0.1, disabled=not absolute_override
|
||||
)
|
||||
|
||||
st.subheader("Série temporelle")
|
||||
rows = st.number_input("Nombre de points", min_value=1, max_value=200, value=10, step=1)
|
||||
step_min = st.number_input("Pas (minutes)", min_value=1, max_value=120, value=5, step=1)
|
||||
start_offset = st.number_input("Début : il y a (minutes)", min_value=0, max_value=1440, value=45, step=5)
|
||||
|
||||
st.markdown("---")
|
||||
st.caption("Nettoyage rapide")
|
||||
cleanup_scope = st.selectbox("Supprimer", ["Cette sonde", "Toutes les TEST_ des dernières 24h"])
|
||||
do_cleanup = st.button("🧹 Supprimer les données de test")
|
||||
col1, col2 = st.columns(2)
|
||||
# ----------------------
|
||||
# Actions
|
||||
# ----------------------
|
||||
if col1.button("🚀 Injecter les données"):
|
||||
try:
|
||||
cnx = get_connection()
|
||||
cur = cnx.cursor()
|
||||
|
||||
# Calcul des timestamps et de la valeur
|
||||
now = dt.datetime.now()
|
||||
t0 = now - dt.timedelta(minutes=int(start_offset))
|
||||
if absolute_override:
|
||||
value = float(absolute_temp)
|
||||
else:
|
||||
value = float(temp_max) + float(delta)
|
||||
|
||||
# Préparation batch INSERT
|
||||
sql = f"INSERT INTO `{site}` (Sonde, Temperature, Date) VALUES (%s,%s,%s)"
|
||||
batch = []
|
||||
for i in range(int(rows)):
|
||||
ts = t0 + dt.timedelta(minutes=i * int(step_min))
|
||||
batch.append((sonde, value, ts.strftime("%Y-%m-%d %H:%M:%S")))
|
||||
|
||||
cur.executemany(sql, batch)
|
||||
st.success(f"{len(batch)} lignes insérées dans `{site}` pour **{sonde}** à **{value}°C**.")
|
||||
|
||||
# Aperçu des données insérées
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT Id, Sonde, Temperature, Date
|
||||
FROM `{site}`
|
||||
WHERE Sonde = %s AND Date >= %s AND Date <= %s
|
||||
ORDER BY Date DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
(
|
||||
sonde,
|
||||
(t0 - dt.timedelta(minutes=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
(t0 + dt.timedelta(minutes=int(rows)*int(step_min) + 1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
),
|
||||
)
|
||||
rows_preview = cur.fetchall()
|
||||
if rows_preview:
|
||||
df = pd.DataFrame(rows_preview, columns=["Id", "Sonde", "Temperature", "Date"])
|
||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.info("Aucune ligne trouvée pour l'aperçu (vérifiez les filtres/horaires).")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de l'injection : {e}")
|
||||
|
||||
# Nettoyage
|
||||
def cleanup():
|
||||
cnx = get_connection()
|
||||
cur = cnx.cursor()
|
||||
if cleanup_scope == "Cette sonde":
|
||||
cur.execute(f"DELETE FROM `{site}` WHERE Sonde = %s", (sonde,))
|
||||
st.success(f"Données supprimées pour la sonde **{sonde}** dans `{site}`.")
|
||||
else:
|
||||
cur.execute(
|
||||
f"DELETE FROM `{site}` WHERE Sonde LIKE 'TEST\_%' ESCAPE '\\' AND Date >= NOW() - INTERVAL 1 DAY"
|
||||
)
|
||||
st.success(f"Toutes les sondes **TEST_** des dernières 24h supprimées dans `{site}`.")
|
||||
|
||||
if do_cleanup:
|
||||
try:
|
||||
cleanup()
|
||||
except Exception as e:
|
||||
st.error(f"Erreur de nettoyage : {e}")
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
# visualiseur_logs.py
|
||||
# Dépendances: streamlit, paramiko
|
||||
# pip install streamlit paramiko
|
||||
|
||||
import html
|
||||
import time
|
||||
from datetime import datetime
|
||||
import streamlit as st
|
||||
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError:
|
||||
paramiko = None
|
||||
|
||||
|
||||
# =========================
|
||||
# CONFIG — À RENSEIGNER
|
||||
# =========================
|
||||
VPS_HOST = "162.19.78.131" # ← mets ton IP/DNS
|
||||
VPS_PORT = 22 # ← port SSH
|
||||
VPS_USER = "debian" # ← utilisateur
|
||||
VPS_PASSWORD = "lpZwixbBUFtGY" # ← mot de passe
|
||||
VPS_LOG_DIR = "/home/debian/Gestion_sondes/Logs" # ← dossier des logs sur le VPS
|
||||
|
||||
|
||||
# =========================
|
||||
# UI
|
||||
# =========================
|
||||
st.set_page_config(page_title="Visualiseur de Logs (VPS, password)", layout="wide")
|
||||
st.title("🧾 Visualiseur de fichiers logs (VPS)")
|
||||
st.caption(f"Cible : {VPS_USER}@{VPS_HOST}:{VPS_PORT} • Dossier logs : {VPS_LOG_DIR}")
|
||||
|
||||
if paramiko is None:
|
||||
st.error("Paramiko n’est pas installé. Exécute : pip install paramiko")
|
||||
st.stop()
|
||||
|
||||
# Barre latérale : options d’affichage & refresh
|
||||
with st.sidebar:
|
||||
st.header("⚙️ Options")
|
||||
auto_refresh = st.toggle("🔄 Rafraîchissement auto", value=False, key="auto_refresh")
|
||||
refresh_interval = st.slider("Intervalle (secondes)", 2, 60, 5, key="refresh_interval")
|
||||
if st.button("Rafraîchir maintenant"):
|
||||
st.rerun()
|
||||
|
||||
|
||||
# =========================
|
||||
# FONCTIONS SSH
|
||||
# =========================
|
||||
def ssh_connect_password(host, port, user, password):
|
||||
"""Retourne un client SSH connecté (password)."""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(hostname=host, port=int(port), username=user, password=password, timeout=10)
|
||||
return ssh
|
||||
|
||||
def list_logs_over_ssh(ssh, log_dir):
|
||||
"""
|
||||
Liste les *.log du dossier (sans sous-dossiers), triés par mtime desc.
|
||||
Retourne [{'name': 'f.log', 'mtime': 1234567890.0, 'size': 1024}, ...]
|
||||
"""
|
||||
cmd = (
|
||||
f"bash -lc \"find '{log_dir}' -maxdepth 1 -type f -name '*.log' "
|
||||
f"-printf '%T@ %s %f\\n' 2>/dev/null | sort -nr\""
|
||||
)
|
||||
_, stdout, stderr = ssh.exec_command(cmd)
|
||||
_ = stderr.read().decode(errors="ignore") # on ignore les warnings find
|
||||
out = stdout.read().decode(errors="ignore")
|
||||
|
||||
items = []
|
||||
for line in out.splitlines():
|
||||
parts = line.strip().split(" ", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
mtime = float(parts[0]); size = int(parts[1]); name = parts[2]
|
||||
items.append({"name": name, "mtime": mtime, "size": size})
|
||||
except Exception:
|
||||
continue
|
||||
return items
|
||||
|
||||
def read_tail_over_ssh(ssh, remote_path, n_lines):
|
||||
"""Lit les N dernières lignes via 'tail -n' (rapide sur gros logs)."""
|
||||
n = max(1, int(n_lines))
|
||||
cmd = f"bash -lc \"tail -n {n} '{remote_path}'\""
|
||||
_, stdout, stderr = ssh.exec_command(cmd)
|
||||
err = stderr.read().decode(errors="ignore")
|
||||
if "No such file" in err:
|
||||
raise FileNotFoundError(err)
|
||||
return stdout.read().decode(errors="ignore")
|
||||
|
||||
def backup_and_truncate_remote(ssh, remote_path):
|
||||
"""Crée une sauvegarde horodatée .bak et tronque le fichier à 0 octet. Retourne le chemin .bak."""
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
bak = f"{remote_path}.{ts}.bak"
|
||||
cmd = (
|
||||
f"bash -lc \"cp '{remote_path}' '{bak}' 2>/dev/null || true; "
|
||||
f"truncate -s 0 '{remote_path}' 2>/dev/null || : > '{remote_path}'\""
|
||||
)
|
||||
_, out, err = ssh.exec_command(cmd)
|
||||
_ = out.read()
|
||||
e = err.read().decode(errors="ignore").strip()
|
||||
if "No such file" in e:
|
||||
raise FileNotFoundError(e)
|
||||
return bak
|
||||
|
||||
|
||||
# =========================
|
||||
# CONNEXION & LISTE
|
||||
# =========================
|
||||
if not all([VPS_HOST, VPS_USER, VPS_PASSWORD, VPS_LOG_DIR]):
|
||||
st.error("Complète les constantes en haut du fichier (hôte/utilisateur/mot de passe/dossier logs).")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
ssh = ssh_connect_password(VPS_HOST, VPS_PORT, VPS_USER, VPS_PASSWORD)
|
||||
except Exception as e:
|
||||
st.error(f"❌ Connexion SSH échouée : {e}")
|
||||
st.stop()
|
||||
|
||||
try:
|
||||
logs = list_logs_over_ssh(ssh, VPS_LOG_DIR)
|
||||
except Exception as e:
|
||||
ssh.close()
|
||||
st.error(f"❌ Impossible de lister les logs : {e}")
|
||||
st.stop()
|
||||
|
||||
if not logs:
|
||||
ssh.close()
|
||||
st.warning("Aucun fichier *.log trouvé dans ce dossier sur le VPS.")
|
||||
st.stop()
|
||||
|
||||
fichiers = [x["name"] for x in logs]
|
||||
choix = st.selectbox("📂 Sélectionnez un fichier log :", fichiers, index=0)
|
||||
log_info = next((x for x in logs if x["name"] == choix), logs[0])
|
||||
log_path = f"{VPS_LOG_DIR.rstrip('/')}/{choix}"
|
||||
|
||||
mtime_dt = datetime.fromtimestamp(log_info["mtime"])
|
||||
st.caption(f"`{choix}` — Taille : {log_info['size']:,} o — Modifié : {mtime_dt:%Y-%m-%d %H:%M:%S}")
|
||||
|
||||
# =========================
|
||||
# OPTIONS & LECTURE
|
||||
# =========================
|
||||
col1, col2, col3 = st.columns([1, 1, 1.2])
|
||||
with col1:
|
||||
filtre_erreurs = st.checkbox(
|
||||
"🔍 Afficher uniquement les erreurs",
|
||||
value=False,
|
||||
help="Filtre sur ERROR, ❌, Traceback, failed, exception, critical, fatal"
|
||||
)
|
||||
with col2:
|
||||
nb_lignes = st.slider("📏 Lignes à afficher", 10, 5000, 30)
|
||||
with col3:
|
||||
highlight = st.checkbox(
|
||||
"🖍️ Surligner erreurs/avertissements",
|
||||
value=True,
|
||||
help="Met en évidence ERROR/CRITICAL/EXCEPTION (rouge) et WARN (jaune)"
|
||||
)
|
||||
|
||||
# Lecture (on prend une marge quand filtre actif)
|
||||
try:
|
||||
marge = 300 if filtre_erreurs else 0
|
||||
content = read_tail_over_ssh(ssh, log_path, nb_lignes + marge)
|
||||
lignes = content.splitlines(keepends=True)
|
||||
except Exception as e:
|
||||
ssh.close()
|
||||
st.error(f"Impossible de lire le fichier : {e}")
|
||||
st.stop()
|
||||
|
||||
# On peut fermer maintenant (les actions rouvriront une session propre)
|
||||
ssh.close()
|
||||
|
||||
# Filtrage
|
||||
if filtre_erreurs:
|
||||
err_keys = ["error", "traceback", "failed", "exception", "critical", "fatal"]
|
||||
lignes = [l for l in lignes if any(k in l.lower() for k in err_keys)]
|
||||
|
||||
dernieres = lignes[-nb_lignes:]
|
||||
|
||||
# Surlignage
|
||||
def colorize(lines):
|
||||
out = []
|
||||
for l in lines:
|
||||
low = l.lower()
|
||||
style = (
|
||||
"font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"
|
||||
"white-space: pre-wrap; margin: 0; padding: 2px 6px; border-radius: 4px;"
|
||||
)
|
||||
bg = None
|
||||
if any(k in low for k in ["error", "traceback", "failed", "exception", "critical", "fatal", "❌"]):
|
||||
bg = "#ffe6e6" # rouge clair
|
||||
elif "warn" in low:
|
||||
bg = "#fff8e1" # jaune clair
|
||||
if bg:
|
||||
style += f"background:{bg};"
|
||||
out.append(f"<div style='{style}'>{html.escape(l)}</div>")
|
||||
return "\n".join(out)
|
||||
|
||||
if highlight:
|
||||
st.markdown(colorize(dernieres), unsafe_allow_html=True)
|
||||
else:
|
||||
st.text_area("📄 Contenu du fichier log :", "".join(dernieres), height=600)
|
||||
|
||||
st.divider()
|
||||
|
||||
# =========================
|
||||
# ACTIONS (Backup + Vidage)
|
||||
# =========================
|
||||
st.subheader("🧰 Actions sur ce fichier (VPS)")
|
||||
colA, colB, colC = st.columns([1, 1, 2])
|
||||
with colA:
|
||||
faire_backup = st.checkbox("Créer une copie .bak avant vidage", value=True,
|
||||
help="Copie horodatée à côté du fichier.")
|
||||
with colB:
|
||||
confirmation = st.checkbox("Je confirme vouloir vider", value=False)
|
||||
with colC:
|
||||
vider = st.button("🧹 Vider ce log", use_container_width=True)
|
||||
|
||||
if vider:
|
||||
if not confirmation:
|
||||
st.warning("❗ Coche d’abord « Je confirme vouloir vider » pour éviter les erreurs.")
|
||||
else:
|
||||
try:
|
||||
ssh2 = ssh_connect_password(VPS_HOST, VPS_PORT, VPS_USER, VPS_PASSWORD)
|
||||
with ssh2:
|
||||
if faire_backup:
|
||||
bak_path = backup_and_truncate_remote(ssh2, log_path)
|
||||
st.info(f"📦 Copie de sauvegarde créée : `{bak_path}`")
|
||||
else:
|
||||
cmd = f"bash -lc \"truncate -s 0 '{log_path}' 2>/dev/null || : > '{log_path}'\""
|
||||
_, out, err = ssh2.exec_command(cmd)
|
||||
_ = out.read(); _ = err.read()
|
||||
|
||||
st.success("✅ Fichier vidé avec succès.")
|
||||
st.rerun()
|
||||
except PermissionError:
|
||||
st.error("⛔ Permission refusée. Vérifie les droits sur le fichier/dossier.")
|
||||
except FileNotFoundError as e:
|
||||
st.error(f"📁 Fichier/dossier introuvable : {e}")
|
||||
except Exception as e:
|
||||
st.error(f"❌ Échec du vidage : {e}")
|
||||
|
||||
# =========================
|
||||
# PURGE DE PLUSIEURS LOGS
|
||||
# =========================
|
||||
st.subheader("🗑️ Purge de plusieurs logs (VPS)")
|
||||
|
||||
# On reliste tous les logs disponibles
|
||||
try:
|
||||
ssh3 = ssh_connect_password(VPS_HOST, VPS_PORT, VPS_USER, VPS_PASSWORD)
|
||||
all_logs = list_logs_over_ssh(ssh3, VPS_LOG_DIR)
|
||||
ssh3.close()
|
||||
except Exception as e:
|
||||
all_logs = []
|
||||
st.error(f"Impossible de relister les logs : {e}")
|
||||
|
||||
if all_logs:
|
||||
age_jours = st.number_input("Supprimer les fichiers plus vieux que (jours)", 1, 365, 7)
|
||||
now = time.time()
|
||||
candidats = [x for x in all_logs if (now - x["mtime"]) / 86400 >= age_jours]
|
||||
|
||||
if not candidats:
|
||||
st.info("Aucun fichier ne correspond au filtre d'âge.")
|
||||
else:
|
||||
st.write(f"📂 {len(candidats)} fichier(s) plus vieux que {age_jours} jours trouvé(s).")
|
||||
|
||||
selection = []
|
||||
for x in candidats:
|
||||
label = f'{x["name"]} — {x["size"]/1024:.1f} Ko — {datetime.fromtimestamp(x["mtime"]).strftime("%Y-%m-%d %H:%M")}'
|
||||
if st.checkbox(label, key=f"purge_{x['name']}"):
|
||||
selection.append(x["name"])
|
||||
|
||||
if selection:
|
||||
st.warning(f"{len(selection)} fichier(s) sélectionné(s) pour suppression définitive.")
|
||||
confirm = st.checkbox("Je confirme la suppression définitive", key="confirm_purge")
|
||||
if st.button("❌ Supprimer les fichiers sélectionnés", type="primary", disabled=not confirm):
|
||||
try:
|
||||
ssh4 = ssh_connect_password(VPS_HOST, VPS_PORT, VPS_USER, VPS_PASSWORD)
|
||||
for fname in selection:
|
||||
remote_path = f"{VPS_LOG_DIR.rstrip('/')}/{fname}"
|
||||
cmd = f"bash -lc \"rm -f '{remote_path}'\""
|
||||
_, out, err = ssh4.exec_command(cmd)
|
||||
_ = out.read(); _ = err.read()
|
||||
ssh4.close()
|
||||
st.success(f"✅ {len(selection)} fichier(s) supprimé(s).")
|
||||
st.rerun()
|
||||
except Exception as e:
|
||||
st.error(f"Erreur lors de la suppression : {e}")
|
||||
else:
|
||||
st.info("Aucun log trouvé pour la purge.")
|
||||
|
||||
# =========================
|
||||
# AUTO-REFRESH (fin de script)
|
||||
# =========================
|
||||
if auto_refresh:
|
||||
# Affiche une petite info et relance la page après X secondes
|
||||
st.caption(f"🔄 Rafraîchissement auto activé — Prochaine mise à jour dans {refresh_interval} s…")
|
||||
time.sleep(refresh_interval)
|
||||
st.rerun()
|
||||
@@ -5,12 +5,19 @@
|
||||
SITE = "Meudon"
|
||||
PROGRAM_NAME = f"Monitor_{SITE}"
|
||||
|
||||
# ========= Imports & .env =========
|
||||
import os, re, time, ssl, smtplib, logging
|
||||
import datetime as dt
|
||||
from email.message import EmailMessage
|
||||
# ========= Imports & .env =========
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
import time
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
|
||||
load_dotenv(find_dotenv(usecwd=True), override=False)
|
||||
from utils_sms import normaliser_sms
|
||||
|
||||
|
||||
188
app/Mqtt_meudon.py
Normal file
188
app/Mqtt_meudon.py
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mqtt_meudon.py
|
||||
Récupère les mesures MQTT du site Meudon et les insère dans la table Sondes.Meudon.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.client import CallbackAPIVersion
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# =========================
|
||||
# Chargement du .env
|
||||
# =========================
|
||||
load_dotenv()
|
||||
|
||||
# --- MySQL (commun) ---
|
||||
DB_HOST = os.getenv("DB_HOST")
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASS = os.getenv("DB_PASS")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
|
||||
# --- MQTT Meudon ---
|
||||
MQTT_HOST = os.getenv("MQTT_HOST_MEUDON", os.getenv("MQTT_HOST"))
|
||||
MQTT_USER = os.getenv("MQTT_USER_MEUDON", os.getenv("MQTT_USER"))
|
||||
MQTT_PASS = os.getenv("MQTT_PASS_MEUDON", os.getenv("MQTT_PASS"))
|
||||
MQTT_PORT = int(os.getenv("MQTT_PORT_MEUDON", os.getenv("MQTT_PORT", 1883)))
|
||||
|
||||
GYRO_TOPIC_MEUDON = os.getenv("GYRO_MQTT_TOPIC_MEUDON", "Meudon/gyrophare")
|
||||
|
||||
# Nom de la table de destination
|
||||
TABLE_NAME = "Meudon"
|
||||
|
||||
# =========================
|
||||
# Logging
|
||||
# =========================
|
||||
def setup_logging():
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Console
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
logger.addHandler(console)
|
||||
|
||||
# Logs fichier (même logique que Saclay)
|
||||
log_dir = os.getenv("LOG_DIR", "./Logs")
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, "Mqtt_meudon.log"),
|
||||
maxBytes=1_000_000,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
logging.warning("Impossible de créer le fichier de log : %s", e)
|
||||
|
||||
|
||||
# =========================
|
||||
# Accès MySQL
|
||||
# =========================
|
||||
def insert_temperature(sonde: str, temperature: float) -> None:
|
||||
"""
|
||||
Insère une mesure dans la table Sondes.Meudon.
|
||||
La colonne Date utilise CURRENT_TIMESTAMP par défaut dans MySQL.
|
||||
"""
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
database=DB_NAME,
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
sql = f"INSERT INTO {TABLE_NAME} (Sonde, Temperature) VALUES (%s, %s)"
|
||||
cursor.execute(sql, (sonde, temperature))
|
||||
conn.commit()
|
||||
|
||||
logging.info("Insertion OK (Meudon) -> %s = %.2f", sonde, temperature)
|
||||
|
||||
except Error as e:
|
||||
logging.exception("Erreur MySQL (Meudon) pour la sonde %s : %s", sonde, e)
|
||||
|
||||
finally:
|
||||
try:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
if conn and conn.is_connected():
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =========================
|
||||
# Callbacks MQTT (API v2)
|
||||
# =========================
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code == 0:
|
||||
logging.info("Connecté au broker MQTT Meudon (%s)", MQTT_HOST)
|
||||
client.subscribe("Meudon/mod02/#")
|
||||
logging.info("Abonné au topic : Meudon/#")
|
||||
else:
|
||||
logging.error("Échec de connexion MQTT (Meudon), code retour = %s", reason_code)
|
||||
|
||||
|
||||
def on_message(client, userdata, msg: mqtt.MQTTMessage):
|
||||
topic = msg.topic
|
||||
payload_raw = msg.payload.decode("utf-8", errors="ignore").strip()
|
||||
|
||||
logging.debug("Msg reçu (Meudon) : topic=%s payload=%s", topic, payload_raw)
|
||||
|
||||
# On ignore le gyrophare
|
||||
if topic == GYRO_TOPIC_MEUDON:
|
||||
logging.debug("Topic gyrophare Meudon ignoré : %s", topic)
|
||||
return
|
||||
|
||||
# Nom de la sonde = dernier segment du topic
|
||||
sonde = topic.split("/")[-1] if "/" in topic else topic
|
||||
|
||||
# Conversion du payload en float
|
||||
try:
|
||||
value = float(payload_raw.replace(",", "."))
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
"Payload non numérique (Meudon), mesure ignorée (topic=%s, payload=%s)",
|
||||
topic,
|
||||
payload_raw,
|
||||
)
|
||||
return
|
||||
|
||||
insert_temperature(sonde, value)
|
||||
|
||||
|
||||
# =========================
|
||||
# Programme principal
|
||||
# =========================
|
||||
def main():
|
||||
setup_logging()
|
||||
logging.info("Démarrage du script Mqtt_meudon")
|
||||
|
||||
# Vérif minimale des variables d'env
|
||||
for var in ["DB_HOST", "DB_USER", "DB_PASS", "DB_NAME"]:
|
||||
if os.getenv(var) in (None, ""):
|
||||
logging.error("Variable d'environnement %s manquante !", var)
|
||||
|
||||
client = mqtt.Client(
|
||||
client_id="Mqtt_meudon_client",
|
||||
callback_api_version=CallbackAPIVersion.VERSION2
|
||||
)
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
try:
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||
except Exception as e:
|
||||
logging.exception("Impossible de se connecter au broker MQTT Meudon : %s", e)
|
||||
return
|
||||
|
||||
logging.info("Boucle MQTT Meudon en cours (Ctrl+C pour arrêter)...")
|
||||
try:
|
||||
client.loop_forever()
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Arrêt demandé par l'utilisateur (Meudon).")
|
||||
finally:
|
||||
client.disconnect()
|
||||
logging.info("Client MQTT Meudon déconnecté.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
166
app/Mqtt_saclay.py
Normal file
166
app/Mqtt_saclay.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mqtt_saclay.py
|
||||
Récupère les mesures MQTT du site Saclay et les insère dans la table Sondes.Saclay.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.client import CallbackAPIVersion
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# =========================
|
||||
# Chargement du .env
|
||||
# =========================
|
||||
load_dotenv()
|
||||
|
||||
DB_HOST = os.getenv("DB_HOST")
|
||||
DB_USER = os.getenv("DB_USER")
|
||||
DB_PASS = os.getenv("DB_PASS")
|
||||
DB_NAME = os.getenv("DB_NAME")
|
||||
|
||||
MQTT_HOST = os.getenv("MQTT_HOST")
|
||||
MQTT_USER = os.getenv("MQTT_USER")
|
||||
MQTT_PASS = os.getenv("MQTT_PASS")
|
||||
MQTT_PORT = int(os.getenv("MQTT_PORT", 1883))
|
||||
|
||||
GYRO_TOPIC_SACLAY = os.getenv("GYRO_MQTT_TOPIC_SACLAY", "Saclay/gyrophare")
|
||||
TABLE_NAME = "Saclay"
|
||||
|
||||
# =========================
|
||||
# Logging
|
||||
# =========================
|
||||
def setup_logging():
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
|
||||
# Console
|
||||
console = logging.StreamHandler()
|
||||
console.setFormatter(formatter)
|
||||
logger.addHandler(console)
|
||||
|
||||
# Logs fichier
|
||||
log_dir = os.getenv("LOG_DIR", "./Logs")
|
||||
try:
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, "Mqtt_saclay.log"),
|
||||
maxBytes=1_000_000,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
logging.warning("Impossible de créer le fichier de log : %s", e)
|
||||
|
||||
|
||||
# =========================
|
||||
# Accès MySQL
|
||||
# =========================
|
||||
def insert_temperature(sonde: str, temperature: float) -> None:
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
database=DB_NAME,
|
||||
)
|
||||
|
||||
cursor = conn.cursor()
|
||||
sql = f"INSERT INTO {TABLE_NAME} (Sonde, Temperature) VALUES (%s, %s)"
|
||||
cursor.execute(sql, (sonde, temperature))
|
||||
conn.commit()
|
||||
|
||||
logging.info("Insertion OK -> %s = %.2f", sonde, temperature)
|
||||
|
||||
except Error as e:
|
||||
logging.exception("Erreur MySQL pour la sonde %s : %s", sonde, e)
|
||||
|
||||
finally:
|
||||
try:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
if conn and conn.is_connected():
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =========================
|
||||
# Callbacks MQTT (API v2)
|
||||
# =========================
|
||||
def on_connect(client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code == 0:
|
||||
logging.info("Connecté au broker MQTT (%s)", MQTT_HOST)
|
||||
client.subscribe("Saclay/#")
|
||||
logging.info("Abonné au topic : Saclay/#")
|
||||
else:
|
||||
logging.error("Échec de connexion MQTT, code retour = %s", reason_code)
|
||||
|
||||
|
||||
def on_message(client, userdata, msg: mqtt.MQTTMessage):
|
||||
topic = msg.topic
|
||||
payload_raw = msg.payload.decode("utf-8", errors="ignore").strip()
|
||||
|
||||
logging.debug("Msg reçu : topic=%s payload=%s", topic, payload_raw)
|
||||
|
||||
if topic == GYRO_TOPIC_SACLAY:
|
||||
return # on ignore le gyrophare
|
||||
|
||||
sonde = topic.split("/")[-1] if "/" in topic else topic
|
||||
|
||||
try:
|
||||
value = float(payload_raw.replace(",", "."))
|
||||
except ValueError:
|
||||
logging.warning("Payload non numérique (topic=%s payload=%s)", topic, payload_raw)
|
||||
return
|
||||
|
||||
insert_temperature(sonde, value)
|
||||
|
||||
|
||||
# =========================
|
||||
# Programme principal
|
||||
# =========================
|
||||
def main():
|
||||
setup_logging()
|
||||
logging.info("Démarrage du script Mqtt_saclay")
|
||||
|
||||
client = mqtt.Client(
|
||||
client_id="Mqtt_saclay_client",
|
||||
callback_api_version=CallbackAPIVersion.VERSION2
|
||||
)
|
||||
client.username_pw_set(MQTT_USER, MQTT_PASS)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_message = on_message
|
||||
|
||||
try:
|
||||
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
|
||||
except Exception as e:
|
||||
logging.exception("Impossible de se connecter au broker MQTT : %s", e)
|
||||
return
|
||||
|
||||
logging.info("Boucle MQTT en cours (Ctrl+C pour arrêter)...")
|
||||
try:
|
||||
client.loop_forever()
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Arrêt demandé par l'utilisateur.")
|
||||
finally:
|
||||
client.disconnect()
|
||||
logging.info("Déconnexion MQTT.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,19 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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}")
|
||||
253
app/mqtt_watchdog.py
Normal file
253
app/mqtt_watchdog.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate
|
||||
import threading
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from dotenv import load_dotenv; load_dotenv()
|
||||
|
||||
# ---------- Configuration par défaut ----------
|
||||
DEFAULT_BROKER_HOST = os.getenv("MQTT_HOST", "54.36.188.119")
|
||||
DEFAULT_BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||
DEFAULT_MQTT_USER = os.getenv("MQTT_USER", "Bwps")
|
||||
DEFAULT_MQTT_PASS = os.getenv("MQTT_PASS", "scJ5ACj2keRfI^")
|
||||
|
||||
# Email (OVH SMTP par ex.)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "ssl0.ovh.net")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) # 465=SSL, 587=STARTTLS
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
||||
MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER or "alerte@exemple.fr")
|
||||
MAIL_TO = os.getenv("MAIL_TO", "") # "toi@domaine.fr,ops@domaine.fr"
|
||||
|
||||
# Webhook SMS optionnel (ex: Free Mobile / OVH / autre)
|
||||
WEBHOOK_SMS_URL = os.getenv("WEBHOOK_SMS_URL", "") # ex: https://smsapi.free-mobile.fr/sendmsg?user=XXX&pass=YYY&msg=
|
||||
|
||||
# ---------- Helpers ----------
|
||||
def setup_logger(logfile: str | None):
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)] if not logfile else [
|
||||
logging.FileHandler(logfile),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
],
|
||||
)
|
||||
|
||||
def now_utc():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def fmt_local(dt: datetime):
|
||||
# Affichage lisible en Europe/Paris
|
||||
try:
|
||||
import zoneinfo
|
||||
tz = zoneinfo.ZoneInfo("Europe/Paris")
|
||||
return dt.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
except Exception:
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
# ---------- Notifiers ----------
|
||||
def send_email(subject: str, body: str):
|
||||
if not (SMTP_HOST and SMTP_USER and SMTP_PASS and MAIL_TO and MAIL_FROM):
|
||||
logging.warning("Email non configuré (variables SMTP_* / MAIL_* manquantes).")
|
||||
return
|
||||
msg = MIMEText(body, _charset="utf-8")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = MAIL_FROM
|
||||
msg["To"] = MAIL_TO
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
|
||||
try:
|
||||
if SMTP_PORT == 465:
|
||||
import ssl
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context, timeout=20) as server:
|
||||
server.login(SMTP_USER, SMTP_PASS)
|
||||
server.sendmail(MAIL_FROM, MAIL_TO.split(","), msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as server:
|
||||
server.ehlo()
|
||||
server.starttls()
|
||||
server.login(SMTP_USER, SMTP_PASS)
|
||||
server.sendmail(MAIL_FROM, MAIL_TO.split(","), msg.as_string())
|
||||
logging.info("Email envoyé.")
|
||||
except Exception as e:
|
||||
logging.error(f"Echec envoi email: {e}")
|
||||
|
||||
def send_sms_via_webhook(text: str):
|
||||
if not WEBHOOK_SMS_URL:
|
||||
return
|
||||
try:
|
||||
import urllib.parse, urllib.request
|
||||
url = WEBHOOK_SMS_URL + urllib.parse.quote(text)
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
_ = resp.read()
|
||||
logging.info("SMS (webhook) envoyé.")
|
||||
except Exception as e:
|
||||
logging.error(f"Echec envoi SMS webhook: {e}")
|
||||
|
||||
def notify(subject: str, body: str):
|
||||
send_email(subject, body)
|
||||
# SMS via webhook (décommente si configuré)
|
||||
send_sms_via_webhook(f"{subject} - {body}")
|
||||
# Ou Twilio (si tu ajoutes la fonction et les variables d'env)
|
||||
|
||||
# ---------- Watchdog ----------
|
||||
class SiteStatus:
|
||||
def __init__(self, name: str, threshold_min: int):
|
||||
self.name = name
|
||||
self.threshold = timedelta(minutes=threshold_min)
|
||||
self.last_seen: datetime | None = None
|
||||
self.alert_sent = False
|
||||
|
||||
def seen_now(self):
|
||||
self.last_seen = now_utc()
|
||||
|
||||
def check_and_alert(self):
|
||||
now = now_utc()
|
||||
if self.last_seen is None:
|
||||
# Au démarrage, on attend de dépasser le seuil avant d’alerter
|
||||
return None
|
||||
delta = now - self.last_seen
|
||||
if delta > self.threshold:
|
||||
if not self.alert_sent:
|
||||
self.alert_sent = True
|
||||
return ("OUTAGE",
|
||||
f"{self.name} : plus de données depuis {fmt_local(self.last_seen)} "
|
||||
f"(écoulé: {int(delta.total_seconds()//60)} min).")
|
||||
else:
|
||||
if self.alert_sent:
|
||||
self.alert_sent = False
|
||||
return ("RECOVERY",
|
||||
f"{self.name} : flux rétabli, dernier message à {fmt_local(self.last_seen)}.")
|
||||
return None
|
||||
|
||||
class MqttWatchdog:
|
||||
def __init__(self, broker_host, broker_port, user, pwd, topics, threshold_min, check_every_s):
|
||||
# API callbacks v2 (évite le DeprecationWarning)
|
||||
self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
||||
self.client.username_pw_set(user, pwd)
|
||||
self.client.on_connect = self._on_connect
|
||||
self.client.on_message = self._on_message
|
||||
self.client.on_disconnect = self._on_disconnect
|
||||
|
||||
self.broker_host = broker_host
|
||||
self.broker_port = broker_port
|
||||
self.topics = topics # liste de tuples (topic, qos)
|
||||
self.check_every_s = check_every_s
|
||||
|
||||
# Statuts par site, déduits du préfixe: "Meudon/#" -> "Meudon"
|
||||
self.sites: dict[str, SiteStatus] = {}
|
||||
for t, _q in topics:
|
||||
site = t.split("/", 1)[0]
|
||||
self.sites[site] = SiteStatus(site, threshold_min)
|
||||
|
||||
self._stop = threading.Event()
|
||||
self._checker_thread = threading.Thread(target=self._checker_loop, daemon=True)
|
||||
|
||||
# MQTT callbacks (API v2)
|
||||
def _on_connect(self, client, userdata, flags, reason_code, properties=None):
|
||||
if reason_code == 0:
|
||||
logging.info("Connecté au broker MQTT.")
|
||||
for t, q in self.topics:
|
||||
client.subscribe(t, qos=q)
|
||||
logging.info(f"Abonné à '{t}' (QoS {q})")
|
||||
else:
|
||||
logging.error(f"Echec connexion MQTT (reason_code={reason_code})")
|
||||
|
||||
def _on_disconnect(self, client, userdata, reason_code, properties=None):
|
||||
logging.warning(f"MQTT déconnecté (reason_code={reason_code}). Reconnexion auto gérée par loop_* si activée.")
|
||||
|
||||
def _on_message(self, client, userdata, msg):
|
||||
# Topic attendu: "Meudon/..." ou "Saclay/..."
|
||||
site = msg.topic.split("/", 1)[0]
|
||||
if site in self.sites:
|
||||
self.sites[site].seen_now()
|
||||
logging.debug(f"{site}: message reçu, mise à jour last_seen.")
|
||||
|
||||
# Thread de vérification périodique
|
||||
def _checker_loop(self):
|
||||
while not self._stop.is_set():
|
||||
for site, status in self.sites.items():
|
||||
event = status.check_and_alert()
|
||||
if event:
|
||||
kind, text = event
|
||||
if kind == "OUTAGE":
|
||||
subject = f"[ALERTE] {site} inactif > seuil"
|
||||
body = (f"Watchdog MQTT : {text}\n"
|
||||
f"Seuil: {status.threshold} | Vérif {self.check_every_s}s\n"
|
||||
f"Broker: {self.broker_host}:{self.broker_port}")
|
||||
notify(subject, body)
|
||||
logging.warning(text)
|
||||
elif kind == "RECOVERY":
|
||||
subject = f"[OK] {site} rétabli"
|
||||
body = (f"Watchdog MQTT : {text}\n"
|
||||
f"Seuil: {status.threshold} | Vérif {self.check_every_s}s\n"
|
||||
f"Broker: {self.broker_host}:{self.broker_port}")
|
||||
notify(subject, body)
|
||||
logging.info(text)
|
||||
self._stop.wait(self.check_every_s)
|
||||
|
||||
def start(self):
|
||||
self.client.connect(self.broker_host, self.broker_port, keepalive=60)
|
||||
self.client.loop_start() # thread interne MQTT + reconnexions auto
|
||||
self._checker_thread.start()
|
||||
logging.info("Watchdog démarré.")
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
self._checker_thread.join(timeout=2)
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
|
||||
# ---------- Main ----------
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Watchdog MQTT par site (inactivité > seuil)")
|
||||
p.add_argument("--log", help="Chemin du fichier log (sinon stdout).")
|
||||
p.add_argument("--broker-host", default=DEFAULT_BROKER_HOST)
|
||||
p.add_argument("--broker-port", type=int, default=DEFAULT_BROKER_PORT)
|
||||
p.add_argument("--mqtt-user", default=DEFAULT_MQTT_USER)
|
||||
p.add_argument("--mqtt-pass", default=DEFAULT_MQTT_PASS)
|
||||
p.add_argument("--threshold-min", type=int, default=15, help="Seuil d'inactivité en minutes")
|
||||
p.add_argument("--check-every-s", type=int, default=60, help="Périodicité de vérification en secondes")
|
||||
p.add_argument("--topics", default="Meudon/#,Saclay/#", help="liste de topics 'Site/#' séparés par des virgules")
|
||||
return p.parse_args()
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
setup_logger(args.log)
|
||||
|
||||
topics = []
|
||||
for t in [x.strip() for x in args.topics.split(",") if x.strip()]:
|
||||
topics.append((t, 1))
|
||||
|
||||
watchdog = MqttWatchdog(
|
||||
broker_host=args.broker_host,
|
||||
broker_port=args.broker_port,
|
||||
user=args.mqtt_user,
|
||||
pwd=args.mqtt_pass,
|
||||
topics=topics,
|
||||
threshold_min=args.threshold_min,
|
||||
check_every_s=args.check_every_s,
|
||||
)
|
||||
|
||||
try:
|
||||
watchdog.start()
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.error(f"Erreur fatale: {e}")
|
||||
finally:
|
||||
watchdog.stop()
|
||||
logging.info("Watchdog arrêté.")
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
Reference in New Issue
Block a user