Compare commits

...

43 Commits

Author SHA1 Message Date
8fbad70cbc Changement de lieux 2026-05-23 13:29:35 +02:00
91145b9976 Changement d'adresse IP 2026-05-22 13:36:00 +02:00
d0037abf53 Réglages des sauvegardes 2026-05-09 10:10:31 +02:00
974e7f6669 Raccordement VM Gitéa 2026-04-26 17:02:13 +02:00
272ad6d80a Mise en place d'un journal de connexions 2026-04-21 17:14:16 +02:00
b00879cdfa Consolider l'envoi de notifications 2026-04-20 13:52:39 +02:00
c0b0770ddf Arrangement des alertes chat 2026-04-20 12:56:33 +02:00
f1203012df Ajout de chat a Monitor_Saclay 2026-04-20 09:36:11 +02:00
5f9d1c0911 Remise en état des alertes tables 2026-02-11 11:13:20 +01:00
7fd74a8ce4 Domo91 insertion dans anomalies 2026-01-13 13:40:04 +01:00
2fd850e426 Modif watchdog relevés 2026-01-08 08:48:33 +01:00
6c3772266c Modif watchdog relevé uniquement par mail 2026-01-05 00:42:35 +01:00
56ee03dc94 Interrupteur chambre froide 2025-12-18 07:29:52 +01:00
92b57df303 Correction bugs mineurs Domo91 2025-12-16 10:26:11 +01:00
b4c2ca8400 Insertion Logo 2025-12-15 14:43:03 +01:00
d54832e558 Consolidation de Domo91 et cosmétique 2025-12-15 11:43:33 +01:00
a0b6d22727 Consolidation de Domo91 2025-12-14 15:55:15 +01:00
0c3457f30a Couleur bandeau gyro 2025-12-12 13:06:23 +01:00
37a00a64b5 Remise en route cuisine_meudon 2025-12-11 17:01:29 +01:00
dca69728e0 Remise en état des relevés temp 2025-11-14 21:31:40 +01:00
367c6a10b8 Sécurité SSH 2025-10-18 11:21:30 +02:00
e9b85bcf40 Règlages des SMS 2025-10-15 11:00:48 +02:00
e1f91660cf Affichage état gyro sur app 2025-10-14 19:05:54 +02:00
e5896ad32f Limitation des SMS 2025-10-14 09:06:00 +02:00
4bd43a3611 Remise en état des userforms inventaire 2025-10-03 13:52:13 +02:00
807f2d318b Création onglet journal erreurs 2025-09-27 11:31:48 +02:00
d776f1bf12 Règlages des alertes dans Monitor_Meudon 2025-09-24 08:51:24 +02:00
072a0cbbc5 Règlages des alertes dans Monitor_Saclay 2025-09-23 13:40:14 +02:00
9131758db7 Révision des codes alertes 2025-09-22 15:02:35 +02:00
bb461a2ed1 relancement cuisines Saclay et Meudon 2025-09-22 11:11:55 +02:00
90aab548d4 Gestion_sondes opérationnel 2025-09-20 18:04:05 +02:00
14b165ff06 Mise au point des monitoring 2025-09-20 16:27:02 +02:00
2fa848b4a7 Remise en état des fichiers Gestion_sondes 2025-09-20 15:59:21 +02:00
511e377dc8 Remise en état des alertes 2025-09-20 12:09:36 +02:00
35a7d13d02 Mise en place des services d'alerte Meudon 2025-09-19 15:21:06 +02:00
72be72a8aa Refonte des alarmes 2025-09-19 12:02:32 +02:00
5b6c31392f MAJ visualiseur logs 2025-09-11 18:15:27 +02:00
7aa7fa2dfe MAJ fichier requirement.txt 2025-09-11 15:14:43 +02:00
ce41a0ef2c Réorganisation fichiers .env 2025-09-06 13:48:49 +02:00
b5a692d2bd Modif Tracker 2025-08-31 18:26:48 +02:00
783fa3d97b Rectifications erreurs fichiers 2025-08-28 14:13:23 +02:00
088e7a4821 Cosmétique Tracker et sécurité 2025-08-26 14:09:52 +02:00
cd6bb7b5cf Ajout date mise en service Tracker 2025-08-26 11:05:06 +02:00
42 changed files with 6011 additions and 1427 deletions

88
.env
View File

@@ -1,26 +1,72 @@
# connexion mysql
DB_HOST=162.19.78.131
DB_USER=excel_auth
DB_PASSWORD=%n#%3Lay1MPa$%kR^5@
DB_USER=sondes
DB_PASS=TX.)-U1!zq5Axdk4
DB_NAME=Sondes
# paramètres mail
SMTP_HOST=smtp.mail.ovh.net
SMTP_PORT=465
EMAIL_FROM=services@domo91.fr
EMAIL_PASSWORD=6ZiCsVtSf9@nEHv@$^0
EMAIL_DESTINATAIRES=services@domo91.fr
DB_USER2=journal_connexions
DB_PASS2=wQ%geAx*2%%HiE2a!9S
DB_NAME2=Acces
# connexion OVH pour les SMS
OVH_APP_KEY=f725d07b2f98a195
OVH_APP_SECRET=5ca392a0a728e2395edd426bb1e11ad6
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
OVH_ENDPOINT=ovh-eu
OVH_SMS_SENDER=DOMO91FR
OVH_SERVICE_NAME=sms-jm164396-1
SMS_RECEIVER=+33635164680
OVH_PASSWORD=w*j&A2j*QT^HL6
ENVOI_SMS=1
PHONE_SACLAY=+33682069405,+33650270939
PHONE_MEUDON=+33666271128
PHONE_ADMIN=+33635164680
AUTH_USERS=[{"user":"Michel","pass":"210462"}]
# --- Auth admin de lapp users ---
ADMIN_USER=Michel
DB_USER3=excel
DB_PASS3='%n#%3Lay1MPa$%kR^5@'
DB_NAME3=Acces
ADMIN_PASSWORD=Gabrielle
ADMIN_PASS_HASH='$2b$12$Dgv7jNLJuR.3hQminSVE9OP6hCSmW4nISArR3HF5LTPGFK0Zw29N2'
# MQTT
MQTT_HOST=162.19.78.131
MQTT_USER=sondes
MQTT_PASS=3J@bjYP0
MQTT_PORT=1883
# Synology Chat
SYNO_CHAT_WEBHOOK_MONITOR_SACLAY=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=zLaQnf5tD2BTsu6N7RQZvUyKipQSDSiXqV57VhWUfblQksL9K8NH22imxEtKas4m
SYNO_CHAT_WEBHOOK_MONITOR_MEUDON=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=3bU0z4cG15CyCxXvz76voErQq8c1SLsms8kxsH6DvUQhGVQ9w5zqLvZ0GLVsLONP
SYNO_CHAT_WEBHOOK_GYRO_SACLAY=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=M0cJHOhtqhEWFEpdd8XTcmZHcTVq9ItDDCodvRq8MJHAJVbn9UTRZf1SxZXRn8mr
SYNO_CHAT_WEBHOOK_GYRO_MEUDON=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=yllI91DMC75XqFDwMW798GraUTLVv5Hb4wGGGmd65fHm4wbQIghlb01cgYPkZLtd
SYNO_CHAT_WEBHOOK_CONNEXIONS=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=R585242twVz04qmzukxbtSTMe7p0GgdroKtO8opBglDx3VLtaLwJhYb93btH6Hya
SYNO_CHAT_WEBHOOK_CONNEXIONS_SIMPLE=https://mj91.fr/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=TahhvDjYKqvA7KbGgVOK7uZI8ri0JS1HSPvCGGXteyZaHGKpKJGvaJS4Favf9Xyj
SYNO_CHAT_BOTNAME_MONITOR="Injection données dans tables"
SYNO_CHAT_BOTNAME_GYRO="Gestion Gyro"
SYNO_CHAT_BOTNAME_CONNEXIONS="Journal Connexions"
SYNO_CHAT_BOTNAME_CONNEXIONS_SIMPLE="Connexions Simples"
SYNO_CHAT_TIMEOUT=10
SYNO_CHAT_VERIFY_SSL=true
SYNO_CHAT_GYRO_ENABLED=1
# Boucle rapide du gyro
GYRO_CHECK_SEC=20
GYRO_NORMAL_CONFIRM=6
GYRO_MODE_CONTINUOUS=1
GYRO_HYSTERESIS=0.3
GYRO_MQTT_TOPIC_SACLAY=Saclay/gyrophare
GYRO_MQTT_TOPIC_MEUDON=Meudon/gyrophare
# Délais
ALERT_CONTINUOUS_MINUTES=30
ALERT_LOOKBACK_MINUTES=120
# Logs
LOGLEVEL=INFO
# paramètres mail
SMTP_HOST=ssl0.ovh.net
SMTP_PORT=465
SMTP_SECURITY=SSL
SMTP_USER=services@domo91.fr
SMTP_PASS=VHq3278YA#sGV*bh#mR
MAIL_FROM=services@domo91.fr
MAIL_TO=robots@domo91.fr
MAIL_TO_SACLAY=robots@domo91.fr,nicolas.thibaut@bw-paris-saclay.com
MAIL_FROM_SACLAY="DOMO91 Saclay <services@domo91.fr>"
MAIL_TO_MEUDON=robots@domo91.fr,chef@parismeudonermitage.com
MAIL_FROM_MEUDON="DOMO91 Meudon <services@domo91.fr>"

View File

@@ -3,6 +3,7 @@
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/myenv/lib/python3.11/site-packages" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (Gestion_sondes)" jdkType="Python SDK" />

3
.idea/misc.xml generated
View File

@@ -7,4 +7,7 @@
<component name="PyPackaging">
<option name="earlyReleasesAsUpgrades" value="true" />
</component>
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>

1
.idea/modules.xml generated
View File

@@ -3,6 +3,7 @@
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Gestion sondes.iml" filepath="$PROJECT_DIR$/.idea/Gestion sondes.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/pandas.iml" filepath="$PROJECT_DIR$/.idea/pandas.iml" />
</modules>
</component>
</project>

View File

@@ -1,34 +0,0 @@
🔁 Mise à jour de la branche product depuis develop : workflow recommandé
⚙️ Étapes standards :
Travaille uniquement sur develop
Quand tu es prêt à mettre en production :
git checkout product
git merge develop
git push origin product
Cela fusionne proprement toutes les modifications testées de develop vers product.
🖥️ Et sur le VPS ?
Si ton VPS suit la branche product, alors une fois que tu as fait :
git push origin product (sur pycharm)
Tu peux te connecter à ton VPS et faire :
cd /home/debian/Gestion_sondes git pull origin product
🔵 Mettre à jour supervisor
cd /etc/supervisor/conf.d
supervisorctl restart all
Et pour vérifier le bon état des services
supervisorctl status
exit
exit
🔵 4. Vérification
pour vérifier que les scripts tournent bien.
#tail -f /home/debian/Gestion_sondes/Logs/monitor.csv

View File

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 151 KiB

BIN
Outils/asset/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

342
Outils/tracker.py Normal file
View File

@@ -0,0 +1,342 @@
# tracker.py
# -------------------------------------------------------------
# Streamlit — Gestion de la table MySQL Sondes.tracker (avec address_hyphen)
# -------------------------------------------------------------
# Schéma attendu :
# id (PK), address (ROM {0x..}), address_hyphen (28-..-..-..-..-..-..-..),
# lieu, repere, mise_en_service (DATE), res_bits, date (timestamp)
# Authentification intégrée via .env (AUTH_USERS JSON)
# -------------------------------------------------------------
import os
import re
import time
import json
import hmac
from typing import Optional
from datetime import date
import pandas as pd
import streamlit as st
import mysql.connector as mysql
from contextlib import contextmanager
from dotenv import load_dotenv
# ==========================
# Configuration / Constantes
# ==========================
load_dotenv() # lit .env si présent
TABLE_NAME = "tracker"
COL_ID = "id"
COL_ADDRESS = "address" # format ROM : {0x28,0xFF,...}
COL_ADDR_HYPHEN = "address_hyphen" # format hyphen : 28-xx-xx-xx-xx-xx-xx-xx
COL_LIEU = "lieu"
COL_REPERE = "repere"
COL_MES = "mise_en_service" # DATE
COL_RESBITS = "res_bits"
COL_DATE = "date"
# Configuration BDD (standardisée sur les variables d'env MYSQL_*)
DB_CFG = dict(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME"),
port=int(os.getenv("MYSQL_PORT", "3306")),
)
# Regex d'une ROM code DS18B20 au format {0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0xCRC}
ROM_REGEX = re.compile(r"^{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}}$")
# Adresse hyphen : 8 octets hexa séparés par des tirets
HYPHEN_REGEX = re.compile(r"^[0-9A-Fa-f]{2}(?:-[0-9A-Fa-f]{2}){7}$")
# Mapping résolution DS18B20 (bits -> infos)
RES_MAP = {
9: {"precision": 0.5, "tconv_ms": 94},
10: {"precision": 0.25, "tconv_ms": 188},
11: {"precision": 0.125, "tconv_ms": 375},
12: {"precision": 0.0625,"tconv_ms": 750},
}
# ==================
# Authentification via .env (AUTH_USERS)
# ==================
AUTH_USERS_RAW = os.getenv("AUTH_USERS", "[]")
def _load_users() -> dict:
try:
data = json.loads(AUTH_USERS_RAW)
return {str(d.get("user", "")).strip(): str(d.get("pass", "")) for d in data if d.get("user") and d.get("pass")}
except Exception:
return {}
USERS = _load_users()
def _constant_time_equals(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
def verify_credentials(username: str, password: str) -> bool:
if not username or not password:
return False
expected = USERS.get(username.strip())
if expected is None:
return False
return _constant_time_equals(password, expected)
def require_login() -> Optional[str]:
if st.session_state.get("auth_ok") and st.session_state.get("auth_user"):
return st.session_state.get("auth_user")
st.markdown("<h2 style='text-align:center;'>🔒 Tracker</h2>", unsafe_allow_html=True)
_, col2, _ = st.columns([1, 2, 1])
with col2:
with st.form("login_form", clear_on_submit=False):
username = st.text_input("Utilisateur")
password = st.text_input("Mot de passe", type="password")
ok = st.form_submit_button("Se connecter")
if ok:
if verify_credentials(username, password):
st.session_state["auth_ok"] = True
st.session_state["auth_user"] = username.strip()
st.success("Connexion réussie.")
time.sleep(0.3)
st.rerun()
else:
st.error("Identifiants invalides.")
st.stop()
# ==================
# Accès Base de Données
# ==================
@contextmanager
def get_conn():
conn = mysql.connect(**DB_CFG)
try:
yield conn
finally:
conn.close()
# -----------------
# Utilitaires
# -----------------
def rom_help() -> str:
return (
"Format ROM attendu : `{0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0x12}` "
"(8 octets en hex). Le premier octet est souvent 0x28 pour DS18B20."
)
def is_valid_rom(address: str) -> bool:
return bool(ROM_REGEX.match(str(address).strip()))
def is_valid_hyphen(address_h: str) -> bool:
return bool(HYPHEN_REGEX.match(str(address_h).strip()))
def rom_to_hyphen(rom: str) -> str:
hexes = re.findall(r"0x([0-9A-Fa-f]{2})", str(rom))
if len(hexes) != 8:
return ""
return "-".join(h.lower() for h in hexes)
def hyphen_to_rom(h: str) -> str:
parts = str(h).strip().split("-")
if len(parts) != 8 or not all(re.fullmatch(r"[0-9A-Fa-f]{2}", p) for p in parts):
return ""
return "{" + ",".join(f"0x{p.upper()}" for p in parts) + "}"
def res_label(bits: int) -> str:
info = RES_MAP.get(bits)
if not info:
return f"{bits} bits (inconnu)"
return f"{bits} bits (±{info['precision']}°C, {info['tconv_ms']} ms)"
# -----------------
# Fonctions SQL
# -----------------
def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame:
query = (
f"SELECT {COL_ID}, {COL_ADDRESS}, {COL_ADDR_HYPHEN}, {COL_LIEU}, "
f"{COL_REPERE}, {COL_MES}, {COL_RESBITS}, {COL_DATE} "
f"FROM {TABLE_NAME}"
)
params = []
if where_lieu:
query += f" WHERE {COL_LIEU} = %s"
params.append(where_lieu)
query += f" ORDER BY {COL_LIEU}, {COL_REPERE}, {COL_ADDR_HYPHEN}, {COL_ADDRESS}"
with get_conn() as conn:
df = pd.read_sql(query, conn, params=params)
return df
def insert_tracker(address: str, lieu: str, res_bits: int,
repere: str | None = None, mise_en_service: date | None = None,
address_hyphen: str | None = None) -> int:
addr_rom = (address or "").strip() if address else ""
addr_hyp = (address_hyphen or "").strip() if address_hyphen else ""
if addr_rom and not addr_hyp:
addr_hyp = rom_to_hyphen(addr_rom)
if addr_hyp and not addr_rom:
addr_rom = hyphen_to_rom(addr_hyp)
if not is_valid_rom(addr_rom) or not is_valid_hyphen(addr_hyp):
raise ValueError("Adresse invalide (ROM ou hyphen).")
sql = f"""
INSERT INTO {TABLE_NAME}
({COL_ADDRESS}, {COL_ADDR_HYPHEN}, {COL_LIEU}, {COL_REPERE}, {COL_MES}, {COL_RESBITS})
VALUES (%s, %s, %s, %s, %s, %s)
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (
addr_rom,
addr_hyp.lower(),
lieu,
(repere.strip() if repere and str(repere).strip() else None),
mise_en_service,
res_bits,
))
conn.commit()
return cur.lastrowid
def update_tracker(row_id: int, address: str, lieu: str, res_bits: int,
repere: str | None, mise_en_service: date | None,
address_hyphen: str | None = None) -> None:
addr_rom = (address or "").strip()
addr_hyp = (address_hyphen or "").strip() if address_hyphen else ""
if addr_rom and not addr_hyp:
addr_hyp = rom_to_hyphen(addr_rom)
if addr_hyp and not addr_rom:
addr_rom = hyphen_to_rom(addr_hyp)
if not is_valid_rom(addr_rom) or not is_valid_hyphen(addr_hyp):
raise ValueError("Adresse invalide (ROM ou hyphen).")
sql = f"""
UPDATE {TABLE_NAME}
SET {COL_ADDRESS}=%s, {COL_ADDR_HYPHEN}=%s, {COL_LIEU}=%s, {COL_REPERE}=%s, {COL_MES}=%s, {COL_RESBITS}=%s
WHERE {COL_ID}=%s
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (
addr_rom,
addr_hyp.lower(),
lieu,
(repere.strip() if repere and str(repere).strip() else None),
mise_en_service,
res_bits,
row_id,
))
conn.commit()
def delete_tracker(row_id: int) -> None:
sql = f"DELETE FROM {TABLE_NAME} WHERE {COL_ID}=%s"
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (row_id,))
conn.commit()
# ==================
# Application Streamlit
# ==================
st.set_page_config(page_title="Tracker", page_icon="🌡️", layout="wide")
user = require_login()
st.title("🌡️ Gestion du parc sondes (stock ou installées)")
with st.expander("Paramètres de connexion (lecture seule)"):
st.write({k: ("***" if k in {"password"} else v) for k, v in DB_CFG.items()})
st.caption("Configurez ces valeurs via le fichier .env")
st.sidebar.header("Filtres & Actions")
st.sidebar.caption(f"Connecté en tant que **{user}**")
_all = fetch_trackers()
lieux = sorted([x for x in _all[COL_LIEU].dropna().unique()]) if not _all.empty else []
lieu_selected = st.sidebar.selectbox("Filtrer par lieu", options=["(Tous)"] + lieux, index=0)
# Formulaire d'ajout
st.sidebar.subheader("Ajouter une sonde")
with st.sidebar.form("add_form", clear_on_submit=True):
new_address = st.text_input("Adresse ROM", placeholder="{0x28,0xFF,...}", help=rom_help())
preview_h = rom_to_hyphen(new_address) if new_address else ""
st.text_input("Adresse hyphen (auto)", value=preview_h, disabled=True)
new_lieu = st.text_input("Lieu d'installation")
new_repere = st.text_input("Repère (optionnel)")
new_mes = st.date_input("Mise en service (optionnel)", value=None, format="YYYY-MM-DD")
new_res = st.selectbox("Résolution (bits)", options=[9,10,11,12])
submitted = st.form_submit_button("Ajouter")
if submitted:
if not is_valid_rom(new_address):
st.warning("Adresse ROM invalide.")
elif not new_lieu.strip():
st.warning("Lieu requis.")
else:
rid = insert_tracker(
new_address.strip(),
new_lieu.strip(),
int(new_res),
new_repere,
new_mes if isinstance(new_mes, date) else None,
address_hyphen=rom_to_hyphen(new_address.strip()),
)
st.success(f"Sonde ajoutée (id={rid}).")
time.sleep(0.6)
st.rerun()
st.sidebar.divider()
if st.sidebar.button("Se déconnecter"):
for _k in list(st.session_state.keys()):
st.session_state.pop(_k, None)
st.success("Déconnecté.")
time.sleep(0.3)
st.rerun()
# Vue principale
if lieu_selected != "(Tous)":
df = fetch_trackers(where_lieu=lieu_selected)
else:
df = _all.copy()
if df.empty:
st.info("Aucune sonde enregistrée.")
else:
df["resolution"] = df[COL_RESBITS].apply(res_label)
st.subheader("Enregistrements")
edited = st.data_editor(
df[[COL_ID, COL_ADDRESS, COL_ADDR_HYPHEN, COL_LIEU, COL_REPERE, COL_MES, COL_RESBITS, "resolution", COL_DATE]],
hide_index=True,
use_container_width=True,
num_rows="dynamic",
)
removed_ids = set(df[COL_ID]) - set(edited[COL_ID])
to_update = []
for _, row in edited.iterrows():
orig = df.loc[df[COL_ID] == row[COL_ID]].iloc[0]
changed = (
(row[COL_ADDRESS] != orig[COL_ADDRESS]) or
(row[COL_ADDR_HYPHEN] != orig[COL_ADDR_HYPHEN]) or
(row[COL_LIEU] != orig[COL_LIEU]) or
(row.get(COL_REPERE) or "") != (orig.get(COL_REPERE) or "") or
(str(row.get(COL_MES) or "")[:10] != str(orig.get(COL_MES) or "")[:10]) or
(int(row[COL_RESBITS]) != int(orig[COL_RESBITS]))
)
if changed:
mes_val = row.get(COL_MES)
if pd.isna(mes_val):
mes_val = None
elif hasattr(mes_val, "date"):
mes_val = mes_val.date()
to_update.append((int(row[COL_ID]), str(row[COL_ADDRESS]), str(row[COL_ADDR_HYPHEN] or ""),
str(row[COL_LIEU]), int(row[COL_RESBITS]),
str(row.get(COL_REPERE) or None), mes_val))
col1, col2 = st.columns([1,1])
if st.button("Enregistrer les modifications"):
for rid, addr_rom, addr_hyp, lieu, rbits, repere, mes in to_update:
update_tracker(rid, addr_rom, lieu, rbits, repere, mes, address_hyphen=addr_hyp)
for rid in removed_ids:
delete_tracker(rid)
st.success("Modifications enregistrées ✔️")
time.sleep(0.6)
st.rerun()
if st.button("Annuler"):
st.rerun()
st.divider()
st.caption("Astuce : collez une adresse ROM {0x..,...} → la version hyphen est générée automatiquement.")

637
Outils/users.py Normal file
View File

@@ -0,0 +1,637 @@
# Streamlit app
import os
from datetime import date, datetime
import re
import bcrypt
import mysql.connector
from mysql.connector import pooling, errorcode
import pandas as pd
import streamlit as st
from dotenv import load_dotenv
import smtplib
from email.message import EmailMessage
from email.utils import formatdate
from PIL import Image
from pathlib import Path
import streamlit as st
st.set_page_config(
page_title="Acces.Utilisateurs",
page_icon="👤",
layout="wide",
initial_sidebar_state="collapsed"
)
BASE_DIR = Path(__file__).resolve().parent
LOGO_PATH = BASE_DIR / "asset" / "Logo.png"
if LOGO_PATH.is_file():
logo = Image.open(LOGO_PATH)
c1, c2, c3 = st.columns([1, 2, 1])
with c2:
st.image(logo, width=160)
else:
st.warning(f"Logo non trouvé : {LOGO_PATH}")
load_dotenv()
SMTP_HOST = os.getenv("SMTP_HOST", "ssl0.ovh.net")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) # OVH: 465 (SSL) ou 587 (STARTTLS)
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASS = os.getenv("SMTP_PASS")
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
# -----------------------
# Auth minimale
# -----------------------
def require_login():
st.markdown(
"<style>div.block-container{padding-top:2rem;}</style>",
unsafe_allow_html=True,
)
admin_user = os.getenv("ADMIN_USER")
admin_hash = os.getenv("ADMIN_PASS_HASH")
if not admin_user or not admin_hash:
st.error("Variables ADMIN_USER et/ou ADMIN_PASS_HASH manquantes dans .env")
st.stop()
if "auth_ok" not in st.session_state:
st.session_state.auth_ok = False
if not st.session_state.auth_ok:
col1, col2, col3 = st.columns([1, 2, 1])
with col2:
st.header("🔐 Accès restreint")
u = st.text_input("Utilisateur")
p = st.text_input("Mot de passe", type="password")
if st.button("Se connecter", width="stretch"):
try:
ok = (u == admin_user) and bcrypt.checkpw(p.encode(), admin_hash.encode())
except Exception:
ok = False
if ok:
st.session_state.auth_ok = True
st.rerun()
else:
st.error("Identifiants invalides.")
st.stop()
def logout_button():
st.markdown(
"""
<style>
div[data-testid="stToolbar"] {visibility: hidden;}
div[data-testid="stDecoration"] {display: none;}
div[data-testid="stStatusWidget"] {display: none;}
div.block-container {padding-top: 1rem;}
.logout-container {text-align:right; margin-top:-3rem;}
</style>
""",
unsafe_allow_html=True
)
st.markdown('<div class="logout-container">', unsafe_allow_html=True)
if st.button("🚪 Quitter", width="content"):
st.session_state.clear()
st.success("Déconnexion effectuée.")
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
require_login()
logout_button()
# -----------------------
# Connexion MySQL via pool
# -----------------------
@st.cache_resource
def get_pool():
host = os.getenv("DB_HOST")
port = int(os.getenv("MYSQL_PORT", "3306")) # ✅ valeur par défaut 3306
user = os.getenv("DB_USER2")
pwd = os.getenv("DB_PASS2")
db = os.getenv("DB_NAME2")
# ✅ contrôle des variables indispensables
missing = [k for k, v in {
"DB_HOST": host,
"DB_USER3": user,
"DB_PASS3": pwd,
"DB_NAME3": db,
}.items() if v in (None, "")]
if missing:
raise RuntimeError(f"Variables manquantes dans .env : {', '.join(missing)}")
return pooling.MySQLConnectionPool(
pool_name="users_pool",
pool_size=5,
pool_reset_session=True,
host=host,
port=port,
user=user,
password=pwd,
database=db,
autocommit=True,
)
# -----------------------
# Helpers SQL + validations
# -----------------------
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PHONE_RE = re.compile(r"\d{10,14}")
def normalize_phone(p: str|None) -> str|None:
if not p:
return None
digits = re.sub(r"\D", "", p)
return digits if PHONE_RE.match(digits) else None
def to_sql_date(d: date | str | None) -> str | None:
if d is None:
return None
if isinstance(d, str):
try:
d = datetime.fromisoformat(d).date()
except Exception:
return None
return d.strftime("%Y-%m-%d")
def hash_password(plain: str, rounds: int = 12) -> str:
salt = bcrypt.gensalt(rounds=rounds)
return bcrypt.hashpw(plain.encode("utf-8"), salt).decode("utf-8")
def user_exists(cur, username: str) -> bool:
cur.execute("SELECT COUNT(*) FROM Acces.Utilisateurs WHERE NomUtilisateur=%s", (username,))
(count,) = cur.fetchone()
return count > 0
def find_users_by_email(cnx, email: str):
cur = cnx.cursor(dictionary=True)
try:
cur.execute(
"SELECT NomUtilisateur, Site FROM Acces.Utilisateurs WHERE email=%s ORDER BY NomUtilisateur",
(email,),
)
return cur.fetchall()
finally:
cur.close()
def list_users(cnx, limit: int = 500, include_password=False):
fields = ["NomUtilisateur", "Nom_complet", "Site", "DateExpiration", "Telephone", "email"]
if include_password:
fields.append("MotDePasse")
sql = f"SELECT {', '.join(fields)} FROM Utilisateurs ORDER BY NomUtilisateur LIMIT %s"
cur = cnx.cursor(dictionary=True)
try:
cur.execute(sql, (limit,))
return cur.fetchall()
finally:
cur.close()
def insert_user(cnx, username, full_name, site, password, expires, phone, email, role):
if not EMAIL_RE.match(email):
raise ValueError("Email invalide.")
phone_norm = normalize_phone(phone)
exp_sql = to_sql_date(expires)
pwd_hash = hash_password(password)
cur = cnx.cursor()
try:
if user_exists(cur, username):
raise RuntimeError("Nom d'utilisateur déjà existant.")
cur.execute(
"""
INSERT INTO Acces.Utilisateurs
(NomUtilisateur, Nom_complet, Site, MotDePasse, MotDePasseHash, DateExpiration, Telephone, email, role)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(username, full_name, site, password, pwd_hash, exp_sql, phone_norm, email, role),
)
return pwd_hash
finally:
cur.close()
def get_user_details(cnx, username: str):
cur = cnx.cursor(dictionary=True)
try:
cur.execute(
"""
SELECT NomUtilisateur, Nom_complet, Site, DateExpiration, Telephone, email
FROM Acces.Utilisateurs
WHERE NomUtilisateur=%s
""",
(username,),
)
return cur.fetchone()
finally:
cur.close()
def update_field(cnx, username: str, field: str, value):
allowed = {
"Nom_complet": "Nom_complet",
"Site": "Site",
"DateExpiration": "DateExpiration",
"Telephone": "Telephone",
"email": "email",
}
if field not in allowed:
raise ValueError("Champ non autorisé.")
sql_field = allowed[field]
if field == "email":
if not EMAIL_RE.match(str(value)):
raise ValueError("Email invalide.")
if field == "Telephone":
value = normalize_phone(str(value)) if value else None
if field == "DateExpiration":
value = to_sql_date(value)
cur = cnx.cursor()
try:
cur.execute(
f"UPDATE Utilisateurs SET {sql_field}=%s WHERE NomUtilisateur=%s",
(value, username),
)
finally:
cur.close()
def update_password(cnx, username: str, new_password: str):
pwd_hash = hash_password(new_password)
cur = cnx.cursor()
try:
cur.execute(
"UPDATE Acces.Utilisateurs SET MotDePasse=NULL, MotDePasseHash=%s WHERE NomUtilisateur=%s",
(pwd_hash, username),
)
return pwd_hash
finally:
cur.close()
def send_mail(to_email: str, subject: str, body_text: str, body_html: str | None = None):
if not (SMTP_HOST and SMTP_PORT and SMTP_USER and SMTP_PASS and SMTP_FROM):
raise RuntimeError("Configuration SMTP incomplète (SMTP_HOST/PORT/USER/PASS/FROM).")
if not to_email:
raise ValueError("Destinataire vide.")
msg = EmailMessage()
msg["From"] = SMTP_FROM
msg["To"] = to_email
msg["Date"] = formatdate(localtime=True)
msg["Subject"] = subject
msg.set_content(body_text)
if body_html:
msg.add_alternative(body_html, subtype="html")
# Choix du protocole en fonction du port
if SMTP_PORT == 465:
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
else:
# 587 (ou autre) : STARTTLS
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s:
s.ehlo()
s.starttls() # passe en TLS
s.ehlo()
s.login(SMTP_USER, SMTP_PASS)
s.send_message(msg)
def get_user_email_and_field(cnx, username: str, field: str):
cols = {"email", "Nom_complet", "Site", "DateExpiration", "Telephone"}
if field not in cols:
field = "Nom_complet" # fallback
with cnx.cursor(dictionary=True) as cur:
cur.execute(
f"SELECT email, {field} AS field_value FROM Utilisateurs WHERE NomUtilisateur=%s",
(username,)
)
row = cur.fetchone()
return (row.get("email") if row else None, row.get("field_value") if row else None)
def get_user_email_and_field(cnx, username: str, field: str):
cols = {"email", "Nom_complet", "Site", "DateExpiration", "Telephone"}
if field not in cols:
field = "Nom_complet" # fallback
cur = cnx.cursor(dictionary=True)
try:
cur.execute(
f"SELECT email, {field} AS field_value FROM Utilisateurs WHERE NomUtilisateur=%s",
(username,),
)
row = cur.fetchone()
return (row.get("email") if row else None,
row.get("field_value") if row else None)
finally:
cur.close()
# -----------------------
# UI
# -----------------------
st.title("Gestion des utilisateurs")
try:
pool = get_pool()
except Exception as e:
st.error(f"❌ Impossible d'initialiser le pool MySQL : {e}")
st.stop()
tab_list, tab_create, tab_edit, tab_security = st.tabs(["Liste", "Créer", "Modifier", "Sécurité"])
# -----------------------
# ONGLET LISTE
# -----------------------
with tab_list:
st.subheader("Utilisateurs")
try:
cnx = pool.get_connection()
try:
include_pw = os.getenv("ADMIN_USER") == st.session_state.get("current_user", os.getenv("ADMIN_USER"))
rows = list_users(cnx, limit=1000, include_password=include_pw)
finally:
cnx.close()
df = pd.DataFrame(rows)
if not df.empty:
from datetime import date
df["DateExpiration"] = pd.to_datetime(df["DateExpiration"], errors="coerce").dt.date
# --- Ajout coloration et statut expiration ---
ALERTE_JOURS = 30
exp = pd.to_datetime(df["DateExpiration"], errors="coerce")
today = pd.Timestamp(date.today())
df["Jours_restant"] = (exp - today).dt.days
def statut_expiration(j):
if pd.isna(j):
return "Inconnu"
j = int(j)
if j < 0:
return "Périmé"
if j <= ALERTE_JOURS:
return f"Bientôt (≤{ALERTE_JOURS}j)"
return "OK"
df["Statut"] = df["Jours_restant"].apply(statut_expiration)
c1, c2, c3 = st.columns([1, 1, 3])
with c1:
only_bad = st.checkbox("📌 Périmés / bientôt", value=False)
with c2:
tri_jours = st.checkbox("🔽 Trier par Jours restants", value=True)
if only_bad:
df = df[df["Statut"].isin(["Périmé", f"Bientôt (≤{ALERTE_JOURS}j)"])]
if tri_jours and "Jours_restant" in df.columns:
df = df.sort_values("Jours_restant", na_position="last")
def colorize(row):
stt = row.get("Statut", "")
n = len(row)
if stt == "Périmé":
return ["background-color:#ffe6e6; color:#b00020;"] * n # rouge clair
if stt.startswith("Bientôt"):
return ["background-color:#fff7e6; color:#8a6d3b;"] * n # orange clair
return [""] * n
styled = df.style.apply(colorize, axis=1)
st.dataframe(
styled,
use_container_width=True,
hide_index=True,
height=700, # ⇐ affiche ~20 lignes sans scroller
column_config={
"DateExpiration": st.column_config.DateColumn("DateExpiration", format="YYYY-MM-DD"),
"Jours_restant": st.column_config.NumberColumn("Jours restants", help="Négatif = périmé"),
},
)
st.caption(f"{len(df)} utilisateur(s) affiché(s)")
else:
st.info("Aucun utilisateur à afficher.")
except Exception as e:
st.warning(f"Impossible de lister les utilisateurs : {e}")
# -----------------------
# ONGLET CREER
# -----------------------
with tab_create:
with st.form("create_user_form", clear_on_submit=False):
st.subheader("Nouveau compte")
c1, c2, c3 = st.columns([1.2, 1.5, 1])
username = c1.text_input("NomUtilisateur", placeholder="ex: cjaquier")
full_name = c2.text_input("Nom_complet", placeholder="Clément JAQUIER")
site = c3.text_input("Site", placeholder="Roissy", value="Roissy")
role = st.selectbox("Rôle", ["Utilisateur", "Administrateur"], index=0)
c4, c5, c6 = st.columns([1.4, 1, 1])
email = c4.text_input("email", placeholder="prenom.nom@domaine.com")
phone = c5.text_input("Téléphone", placeholder="06 12 12 35 32")
expires = c6.date_input("DateExpiration", value=date.today())
c7, c8 = st.columns(2)
password = c7.text_input("Mot de passe", type="password")
password2 = c8.text_input("Confirmer", type="password")
col_cb1, col_cb2 = st.columns([1.2, 1])
notify_welcome = col_cb2.checkbox("Envoyer un e-mail de bienvenue", value=True,
help="Enverra l'identifiant, le nom, le site et le mot de passe en clair")
submitted = st.form_submit_button("Créer l'utilisateur", use_container_width=True)
if submitted:
if not username or not full_name or not site or not email:
st.error("Champs requis manquants.")
elif not EMAIL_RE.match(email):
st.error("Format de-mail invalide.")
elif password != password2:
st.error("Les mots de passe ne correspondent pas.")
else:
try:
cnx = pool.get_connection()
try:
# 🔎 avertir si l'e-mail existe déjà (mais on n'empêche pas)
dup = find_users_by_email(cnx, email)
if dup:
liste = ", ".join(f"{u['NomUtilisateur']}@{u['Site']}" for u in dup)
st.info(f"Cet e-mail est déjà utilisé par : {liste}")
pwd_hash = insert_user(
cnx, username=username, full_name=full_name, site=site,
password=password, expires=expires, phone=phone, email=email, role=role
)
finally:
cnx.close()
st.success("Utilisateur créé avec succès ✅")
st.caption("Hash (MotDePasseHash) :")
st.code(pwd_hash)
# ✉️ Mail de bienvenue (optionnel)
if notify_welcome:
try:
subj = f"[Compte créé] Vos accès — {site}"
body_txt = (
"Bonjour,\n\n"
"Votre compte a été créé.\n\n"
f"Nom dutilisateur : {username}\n"
f"Nom complet : {full_name}\n"
f"Site : {site}\n"
f"Mot de passe : {password}\n"
f"Date d'expiration: {expires.strftime('%Y-%m-%d')}\n\n"
"Cordialement."
)
body_html = f"""
<html><body>
<p>Bonjour,</p>
<p>Votre compte a été créé.</p>
<table cellpadding="6" cellspacing="0" border="0" style="border-collapse:collapse">
<tr><td><b>Nom dutilisateur</b></td><td>{username}</td></tr>
<tr><td><b>Nom complet</b></td><td>{full_name}</td></tr>
<tr><td><b>Site</b></td><td>{site}</td></tr>
<tr><td><b>Mot de passe</b></td><td style="font-family:monospace">{password}</td></tr>
<tr><td><b>Date d'expiration</b></td><td>{expires.strftime('%Y-%m-%d')}</td></tr>
</table>
<p>Cordialement.</p>
</body></html>
"""
send_mail(email, subj, body_txt, body_html)
st.success(f"✉️ E-mail de bienvenue envoyé à {email}")
except Exception as e_mail:
st.warning(f"E-mail non envoyé : {e_mail}")
except mysql.connector.Error as db_err:
if db_err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
st.error("Identifiants MySQL invalides.")
else:
st.error(f"Erreur MySQL : {db_err}")
except Exception as e:
st.error(f"Erreur : {e}")
# -----------------------
# ONGLET MODIFIER
# -----------------------
with tab_edit:
st.subheader("Modifier un utilisateur existant")
try:
cnx = pool.get_connection()
try:
users = list_users(cnx, limit=1000)
finally:
cnx.close()
usernames = [u["NomUtilisateur"] for u in users]
except Exception as e:
users = []
usernames = []
st.warning(f"Impossible de charger la liste des utilisateurs : {e}")
top_left, top_right = st.columns([1.2, 2])
with top_left:
sel_user = st.selectbox("Utilisateur", usernames, placeholder="Choisir un utilisateur")
field = st.selectbox(
"Champ à modifier",
["Nom_complet", "Site", "DateExpiration", "Telephone", "email"]
)
with top_right:
if field == "DateExpiration":
new_value = st.date_input("Nouvelle valeur", value=date.today())
else:
new_value = st.text_input("Nouvelle valeur")
notify_user = st.checkbox("Notifier lutilisateur par e-mail", value=True, help="Envoie un e-mail si coché")
update_btn = st.button("Mettre à jour", disabled=not sel_user, use_container_width=True)
if update_btn and sel_user:
try:
cnx = pool.get_connection()
try:
# 1) Lire email + ancienne valeur
to_email, old_value = get_user_email_and_field(cnx, sel_user, field)
# 2) Appliquer la mise à jour
update_field(cnx, sel_user, field, new_value)
finally:
cnx.close()
st.success(f"{field} mis à jour pour {sel_user}")
# 3) Notification mail si demandé
if notify_user:
try:
nv = new_value.strftime("%Y-%m-%d") if hasattr(new_value, "strftime") else new_value
ov = old_value.strftime("%Y-%m-%d") if hasattr(old_value, "strftime") else old_value
if not to_email:
st.info(" Aucune notification envoyée : adresse e-mail manquante.")
else:
subject = f"[Compte] Mise à jour de votre information : {field}"
body_txt = (
f"Bonjour,\n\n"
f"Votre information '{field}' vient dêtre mise à jour par ladministrateur.\n"
f"Ancienne valeur : {ov}\n"
f"Nouvelle valeur : {nv}\n\n"
f"Si vous nêtes pas à lorigine de cette demande, répondez à cet e-mail.\n"
f"Cordialement."
)
body_html = f"""
<html><body>
<p>Bonjour,</p>
<p>Votre information <b>{field}</b> vient dêtre mise à jour par ladministrateur.</p>
<ul>
<li><b>Ancienne valeur :</b> {ov}</li>
<li><b>Nouvelle valeur :</b> {nv}</li>
</ul>
<p>Si vous nêtes pas à lorigine de cette demande, répondez à cet e-mail.</p>
<p>Cordialement.</p>
</body></html>
"""
send_mail(to_email, subject, body_txt, body_html)
st.success(f"✉️ Notification envoyée à {to_email}")
except Exception as e_mail:
st.warning(f"Notification non envoyée : {e_mail}")
except mysql.connector.Error as db_err:
st.error(f"Erreur MySQL : {db_err}")
except Exception as e:
st.error(f"Erreur : {e}")
# -----------------------
# ONGLET SECURITE
# -----------------------
with tab_security:
st.subheader("Réinitialiser le mot de passe (bcrypt)")
try:
if 'user_cache_for_pw' not in st.session_state:
cnx = pool.get_connection()
try:
st.session_state.user_cache_for_pw = [u["NomUtilisateur"] for u in list_users(cnx, limit=1000)]
finally:
cnx.close()
pw_user_list = st.session_state.user_cache_for_pw
except Exception:
pw_user_list = []
user_pw = st.selectbox("Utilisateur", pw_user_list, key="pw_user", placeholder="Choisir un utilisateur")
colp1, colp2 = st.columns(2)
new_pw = colp1.text_input("Nouveau mot de passe", type="password")
new_pw2 = colp2.text_input("Confirmer", type="password")
if st.button("Mettre à jour le mot de passe", disabled=not user_pw, use_container_width=True):
if not new_pw:
st.error("Mot de passe vide.")
elif new_pw != new_pw2:
st.error("Les mots de passe ne correspondent pas.")
else:
try:
cnx = pool.get_connection()
try:
h = update_password(cnx, user_pw, new_pw)
finally:
cnx.close()
st.success("Mot de passe mis à jour ✅")
st.caption("Hash (MotDePasseHash) :")
st.code(h)
except Exception as e:
st.error(f"Erreur : {e}")

BIN
README.md

Binary file not shown.

342
app/Gyrophare.py Normal file
View File

@@ -0,0 +1,342 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import os
import re
import threading
import time
from typing import Any
import mysql.connector
import paho.mqtt.client as mqtt
import requests
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
_ALLOWED_SITE_RE = re.compile(r"^[A-Za-z0-9_]+$")
def safe_site(site: str) -> str:
site = (site or "").strip()
if not site or not _ALLOWED_SITE_RE.fullmatch(site):
raise ValueError(f"Nom de site invalide: {site!r}")
return site
def _env_str(name: str, default: str = "") -> str:
return (os.getenv(name, default) or "").strip()
def _env_bool(name: str, default: bool = False) -> bool:
value = _env_str(name, "1" if default else "0").lower()
return value in ("1", "true", "yes", "on")
logging.basicConfig(
level=getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
log = logging.getLogger("gyro")
DEF_CHECK_SEC = int(_env_str("GYRO_CHECK_SEC", "20"))
DEF_NORMAL_CONFIRM = int(_env_str("GYRO_NORMAL_CONFIRM", "6"))
def send_synology_chat(message: str, *, site: str, username: str | None = None) -> bool:
webhook = (
_env_str(f"SYNO_CHAT_WEBHOOK_GYRO_{site}") or
_env_str(f"SYNO_CHAT_WEBHOOK_GYRO_{site.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK_GYRO") or
_env_str(f"SYNO_CHAT_WEBHOOK_{site}") or
_env_str(f"SYNO_CHAT_WEBHOOK_{site.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK")
)
if not webhook:
log.info("[%s] Synology Chat non configuré.", site)
return False
botname = (
username or
_env_str("SYNO_CHAT_BOTNAME_GYRO") or
_env_str("SYNO_CHAT_BOTNAME", "Gestion Gyro")
)
timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10"))
verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True)
chat_payload: dict[str, str] = {"text": message}
if botname:
chat_payload["username"] = botname
form_data = {
"payload": json.dumps(chat_payload, ensure_ascii=False)
}
try:
response = requests.post(
webhook,
data=form_data,
timeout=timeout,
verify=verify_ssl,
)
response.raise_for_status()
txt = (response.text or "").strip()
log.info("[%s] Réponse Synology Chat: %s", site, txt[:300] if txt else "<vide>")
try:
data = response.json()
if isinstance(data, dict):
return bool(data.get("success", False))
except ValueError:
pass
return txt.lower() == "ok" or not txt
except requests.RequestException as exc:
log.exception("[%s] Echec envoi Synology Chat: %s", site, exc)
return False
class MqttGyroDriver:
def __init__(self, host: str, port: int, user: str, password: str, topic_cmd: str):
self.topic_cmd = topic_cmd
try:
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
except Exception:
self.client = mqtt.Client()
if user:
self.client.username_pw_set(user, password or "")
self.client.connect(host, int(port), keepalive=30)
self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, topic_cmd)
def set(self, on: bool) -> None:
payload = "ON" if on else "OFF"
result = self.client.publish(self.topic_cmd, payload=payload, qos=1, retain=False)
result.wait_for_publish(timeout=5)
log.info("MQTT → %s : %s", self.topic_cmd, payload)
def close(self) -> None:
try:
self.client.loop_stop()
self.client.disconnect()
except Exception:
pass
class GyroController:
"""
Gyro ON en continu tant qu'il existe au moins une alerte Etat='En cours'.
Gyro OFF après 'normal_confirm' lectures consécutives sans alerte.
Notification Chat sur transition ON/OFF.
"""
def __init__(
self,
*,
site_name: str,
db_cfg: dict[str, Any],
alertes_table: str,
mqtt_driver: MqttGyroDriver,
check_sec: int = DEF_CHECK_SEC,
normal_confirm: int = DEF_NORMAL_CONFIRM,
):
self.site = site_name
self.db_cfg = db_cfg
self.alertes_table = alertes_table
self.mqtt = mqtt_driver
self.check_sec = check_sec
self.normal_confirm = normal_confirm
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._current_on: bool | None = None
self._normal_count = 0
def _send_chat_on(self) -> None:
if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
message = (
f":rotating_light: [{self.site}] GYRO DECLENCHE\n"
f"Table alertes: {self.alertes_table}\n"
"Etat: au moins une alerte en cours"
)
send_synology_chat(message, site=self.site)
def _send_chat_off(self) -> None:
if not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
message = (
f":white_check_mark: [{self.site}] GYRO RETOUR NORMALE\n"
f"Table alertes: {self.alertes_table}\n"
"Etat: plus d'alerte en cours"
)
send_synology_chat(message, site=self.site)
def _set_gyro(self, on: bool) -> None:
if self._current_on is on:
return
previous = self._current_on
self.mqtt.set(on)
self._current_on = on
if previous is None:
log.info("[%s] Etat initial Gyro: %s", self.site, "ON" if on else "OFF")
return
if on:
log.info("[%s] Transition Gyro OFF → ON", self.site)
self._send_chat_on()
else:
log.info("[%s] Transition Gyro ON → OFF", self.site)
self._send_chat_off()
def _has_active_alert(self, cur) -> bool:
cur.execute(f"SELECT COUNT(*) FROM `{self.alertes_table}` WHERE Etat='En cours'")
row = cur.fetchone()
return bool(row and row[0] > 0)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info(
"[%s] GyroController démarré (check=%ss, confirm=%d)",
self.site,
self.check_sec,
self.normal_confirm,
)
def stop(self) -> None:
self._stop.set()
def _connect_mysql(self):
while not self._stop.is_set():
try:
cnx = mysql.connector.connect(autocommit=True, **self.db_cfg)
cur = cnx.cursor()
return cnx, cur
except Exception as exc:
log.error("[%s] Connexion MySQL KO (%s). Retry 5s...", self.site, exc)
time.sleep(5)
return None, None
def _run(self) -> None:
cnx, cur = self._connect_mysql()
if not cnx or not cur:
return
try:
try:
self._set_gyro(False)
except Exception:
pass
while not self._stop.is_set():
try:
active = self._has_active_alert(cur)
except Exception as exc:
log.error("[%s] Lecture alertes KO: %s -> reconnexion MySQL", self.site, exc)
try:
cur.close()
cnx.close()
except Exception:
pass
cnx, cur = self._connect_mysql()
if not cnx or not cur:
break
active = False
if active:
self._normal_count = 0
self._set_gyro(True)
else:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self._set_gyro(False)
time.sleep(self.check_sec)
finally:
try:
self._set_gyro(False)
except Exception:
pass
try:
cur.close()
cnx.close()
except Exception:
pass
log.info("[%s] GyroController stoppé", self.site)
def build_db_cfg() -> dict[str, Any]:
return {
"host": _env_str("DB_HOST", "162.19.78.131"),
"user": _env_str("DB_USER", "sondes"),
"password": _env_str("DB_PASS"),
"database": _env_str("DB_NAME", "Sondes"),
"port": int(_env_str("DB_PORT", "3306")),
}
def build_topic(site: str) -> str:
return (
_env_str(f"GYRO_MQTT_TOPIC_{site}")
or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}")
or _env_str("GYRO_MQTT_TOPIC")
or f"{site}/gyrophare"
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Contrôle du gyrophare via table d'alertes")
parser.add_argument("--site", default=_env_str("SITE", "Saclay"))
parser.add_argument("--test-chat", action="store_true")
args = parser.parse_args()
site = safe_site(args.site)
if args.test_chat:
send_synology_chat(f":speech_balloon: [TEST {site}] Notification Synology Chat OK", site=site)
raise SystemExit(0)
alertes_table = _env_str("ALERTES_TABLE", f"Alertes_{site}")
db_cfg = build_db_cfg()
mqtt_host = _env_str("MQTT_HOST", "162.19.78.131")
mqtt_port = int(_env_str("MQTT_PORT", "1883"))
mqtt_user = _env_str("MQTT_USER", "sondes")
mqtt_pass = _env_str("MQTT_PASS")
topic_cmd = build_topic(site)
log.info("[%s] MQTT host=%s port=%s topic=%s", site, mqtt_host, mqtt_port, topic_cmd)
driver = MqttGyroDriver(mqtt_host, mqtt_port, mqtt_user, mqtt_pass, topic_cmd)
controller = GyroController(
site_name=site,
db_cfg=db_cfg,
alertes_table=alertes_table,
mqtt_driver=driver,
check_sec=DEF_CHECK_SEC,
normal_confirm=DEF_NORMAL_CONFIRM,
)
controller.start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
controller.stop()
driver.close()

View File

@@ -1,43 +1,49 @@
Date;Lieu;Sonde;Température;Seuil;État
2025-08-23 13:08:36;Saclay;Congelateur;-16.75;-15.0;Normal
2025-08-23 13:03:33;Saclay;Congelateur;-17.50;-15.0;Normal
2025-08-23 12:58:31;Saclay;Congelateur;-16.25;-15.0;Normal
2025-08-23 12:53:28;Saclay;Congelateur;-17.00;-15.0;Normal
2025-08-23 12:48:26;Saclay;Congelateur;-17.75;-15.0;Normal
2025-08-23 12:43:23;Saclay;Congelateur;-16.25;-15.0;Normal
2025-08-23 13:08:36;Saclay;BOF;2.00;8.0;Normal
2025-08-23 13:03:34;Saclay;BOF;1.25;8.0;Normal
2025-08-23 12:58:31;Saclay;BOF;1.00;8.0;Normal
2025-08-23 12:53:29;Saclay;BOF;2.00;8.0;Normal
2025-08-23 12:48:26;Saclay;BOF;0.50;8.0;Normal
2025-08-23 12:43:23;Saclay;BOF;2.50;8.0;Normal
2025-08-23 13:08:37;Saclay;Legumes;5.25;10.0;Normal
2025-08-23 13:03:35;Saclay;Legumes;4.75;10.0;Normal
2025-08-23 12:58:32;Saclay;Legumes;3.75;10.0;Normal
2025-08-23 12:53:30;Saclay;Legumes;5.50;10.0;Normal
2025-08-23 12:48:27;Saclay;Legumes;3.00;10.0;Normal
2025-08-23 12:43:24;Saclay;Legumes;6.25;10.0;Normal
2025-08-23 13:08:38;Saclay;MeP;4.75;8.0;Normal
2025-08-23 13:03:35;Saclay;MeP;3.50;8.0;Normal
2025-08-23 12:58:33;Saclay;MeP;3.75;8.0;Normal
2025-08-23 12:53:30;Saclay;MeP;4.50;8.0;Normal
2025-08-23 12:48:28;Saclay;MeP;3.00;8.0;Normal
2025-08-23 12:43:25;Saclay;MeP;5.50;8.0;Normal
2025-08-23 13:04:25;Meudon;Viandes;4.31;6.0;Normal
2025-08-23 12:59:24;Meudon;Viandes;4.13;6.0;Normal
2025-08-23 12:54:24;Meudon;Viandes;3.94;6.0;Normal
2025-08-23 12:49:23;Meudon;Viandes;3.75;6.0;Normal
2025-08-23 12:44:22;Meudon;Viandes;3.94;6.0;Normal
2025-08-23 12:39:21;Meudon;Viandes;4.13;6.0;Normal
2025-08-23 13:04:25;Meudon;Poissons;4.56;6.0;Normal
2025-08-23 12:59:24;Meudon;Poissons;3.94;6.0;Normal
2025-08-23 12:54:24;Meudon;Poissons;3.94;6.0;Normal
2025-08-23 12:49:23;Meudon;Poissons;3.88;6.0;Normal
2025-08-23 12:44:22;Meudon;Poissons;3.75;6.0;Normal
2025-08-23 12:39:21;Meudon;Poissons;3.75;6.0;Normal
2025-08-23 13:04:25;Meudon;BOF;2.00;8.0;Normal
2025-08-23 12:59:24;Meudon;BOF;2.00;8.0;Normal
2025-08-23 12:54:24;Meudon;BOF;2.25;8.0;Normal
2025-08-23 12:49:23;Meudon;BOF;2.50;8.0;Normal
2025-08-23 12:44:22;Meudon;BOF;2.50;8.0;Normal
2025-08-23 12:39:21;Meudon;BOF;2.75;8.0;Normal
2025-09-02 09:29:07;Saclay;Congelateur;-18.50;-15.0;Normal
2025-09-02 09:24:04;Saclay;Congelateur;-19.00;-15.0;Normal
2025-09-02 09:19:01;Saclay;Congelateur;-18.50;-15.0;Normal
2025-09-02 09:13:59;Saclay;Congelateur;-17.75;-15.0;Normal
2025-09-02 09:08:56;Saclay;Congelateur;-18.25;-15.0;Normal
2025-09-02 09:03:54;Saclay;Congelateur;-18.75;-15.0;Normal
2025-09-02 09:29:07;Saclay;BOF;2.50;8.0;Normal
2025-09-02 09:24:05;Saclay;BOF;0.75;8.0;Normal
2025-09-02 09:19:02;Saclay;BOF;2.00;8.0;Normal
2025-09-02 09:13:59;Saclay;BOF;2.00;8.0;Normal
2025-09-02 09:08:57;Saclay;BOF;0.75;8.0;Normal
2025-09-02 09:03:54;Saclay;BOF;1.75;8.0;Normal
2025-09-02 09:29:08;Saclay;Viandes;2.75;6.0;Normal
2025-09-02 09:24:05;Saclay;Viandes;2.00;6.0;Normal
2025-09-02 09:19:02;Saclay;Viandes;4.75;6.0;Normal
2025-09-02 09:14:00;Saclay;Viandes;4.25;6.0;Normal
2025-09-02 09:08:57;Saclay;Viandes;3.75;6.0;Normal
2025-09-02 09:03:55;Saclay;Viandes;2.50;6.0;Normal
2025-09-02 09:29:08;Saclay;Legumes;5.00;10.0;Normal
2025-09-02 09:24:06;Saclay;Legumes;4.50;10.0;Normal
2025-09-02 09:19:03;Saclay;Legumes;5.00;10.0;Normal
2025-09-02 09:14:00;Saclay;Legumes;5.50;10.0;Normal
2025-09-02 09:08:58;Saclay;Legumes;4.25;10.0;Normal
2025-09-02 09:03:55;Saclay;Legumes;5.75;10.0;Normal
2025-09-02 09:29:09;Saclay;MeP;6.50;8.0;Normal
2025-09-02 09:24:06;Saclay;MeP;3.00;8.0;Normal
2025-09-02 09:19:03;Saclay;MeP;5.75;8.0;Normal
2025-09-02 09:14:01;Saclay;MeP;7.25;8.0;Normal
2025-09-02 09:08:58;Saclay;MeP;4.00;8.0;Normal
2025-09-02 09:03:56;Saclay;MeP;4.25;8.0;Normal
2025-09-02 09:30:38;Meudon;Viandes;4.69;6.0;Normal
2025-09-02 09:25:37;Meudon;Viandes;5.38;6.0;Normal
2025-09-02 09:20:36;Meudon;Viandes;5.25;6.0;Normal
2025-09-02 09:15:36;Meudon;Viandes;4.88;6.0;Normal
2025-09-02 09:10:35;Meudon;Viandes;4.69;6.0;Normal
2025-09-02 09:05:34;Meudon;Viandes;4.44;6.0;Normal
2025-09-02 09:30:38;Meudon;Poissons;4.06;6.0;Normal
2025-09-02 09:25:37;Meudon;Poissons;4.13;6.0;Normal
2025-09-02 09:20:36;Meudon;Poissons;4.13;6.0;Normal
2025-09-02 09:15:36;Meudon;Poissons;3.94;6.0;Normal
2025-09-02 09:10:35;Meudon;Poissons;3.94;6.0;Normal
2025-09-02 09:05:34;Meudon;Poissons;4.25;6.0;Normal
2025-09-02 09:30:38;Meudon;BOF;3.25;8.0;Normal
2025-09-02 09:25:37;Meudon;BOF;2.50;8.0;Normal
2025-09-02 09:20:37;Meudon;BOF;2.50;8.0;Normal
2025-09-02 09:15:36;Meudon;BOF;2.50;8.0;Normal
2025-09-02 09:10:35;Meudon;BOF;2.25;8.0;Normal
2025-09-02 09:05:34;Meudon;BOF;2.50;8.0;Normal
1 Date Lieu Sonde Température Seuil État
2 2025-08-23 13:08:36 2025-09-02 09:29:07 Saclay Congelateur -16.75 -18.50 -15.0 Normal
3 2025-08-23 13:03:33 2025-09-02 09:24:04 Saclay Congelateur -17.50 -19.00 -15.0 Normal
4 2025-08-23 12:58:31 2025-09-02 09:19:01 Saclay Congelateur -16.25 -18.50 -15.0 Normal
5 2025-08-23 12:53:28 2025-09-02 09:13:59 Saclay Congelateur -17.00 -17.75 -15.0 Normal
6 2025-08-23 12:48:26 2025-09-02 09:08:56 Saclay Congelateur -17.75 -18.25 -15.0 Normal
7 2025-08-23 12:43:23 2025-09-02 09:03:54 Saclay Congelateur -16.25 -18.75 -15.0 Normal
8 2025-08-23 13:08:36 2025-09-02 09:29:07 Saclay BOF 2.00 2.50 8.0 Normal
9 2025-08-23 13:03:34 2025-09-02 09:24:05 Saclay BOF 1.25 0.75 8.0 Normal
10 2025-08-23 12:58:31 2025-09-02 09:19:02 Saclay BOF 1.00 2.00 8.0 Normal
11 2025-08-23 12:53:29 2025-09-02 09:13:59 Saclay BOF 2.00 8.0 Normal
12 2025-08-23 12:48:26 2025-09-02 09:08:57 Saclay BOF 0.50 0.75 8.0 Normal
13 2025-08-23 12:43:23 2025-09-02 09:03:54 Saclay BOF 2.50 1.75 8.0 Normal
14 2025-08-23 13:08:37 2025-09-02 09:29:08 Saclay Legumes Viandes 5.25 2.75 10.0 6.0 Normal
15 2025-08-23 13:03:35 2025-09-02 09:24:05 Saclay Legumes Viandes 4.75 2.00 10.0 6.0 Normal
16 2025-08-23 12:58:32 2025-09-02 09:19:02 Saclay Legumes Viandes 3.75 4.75 10.0 6.0 Normal
17 2025-08-23 12:53:30 2025-09-02 09:14:00 Saclay Legumes Viandes 5.50 4.25 10.0 6.0 Normal
18 2025-08-23 12:48:27 2025-09-02 09:08:57 Saclay Legumes Viandes 3.00 3.75 10.0 6.0 Normal
19 2025-08-23 12:43:24 2025-09-02 09:03:55 Saclay Legumes Viandes 6.25 2.50 10.0 6.0 Normal
20 2025-08-23 13:08:38 2025-09-02 09:29:08 Saclay MeP Legumes 4.75 5.00 8.0 10.0 Normal
21 2025-08-23 13:03:35 2025-09-02 09:24:06 Saclay MeP Legumes 3.50 4.50 8.0 10.0 Normal
22 2025-08-23 12:58:33 2025-09-02 09:19:03 Saclay MeP Legumes 3.75 5.00 8.0 10.0 Normal
23 2025-08-23 12:53:30 2025-09-02 09:14:00 Saclay MeP Legumes 4.50 5.50 8.0 10.0 Normal
24 2025-08-23 12:48:28 2025-09-02 09:08:58 Saclay MeP Legumes 3.00 4.25 8.0 10.0 Normal
25 2025-08-23 12:43:25 2025-09-02 09:03:55 Saclay MeP Legumes 5.50 5.75 8.0 10.0 Normal
26 2025-08-23 13:04:25 2025-09-02 09:29:09 Meudon Saclay Viandes MeP 4.31 6.50 6.0 8.0 Normal
27 2025-08-23 12:59:24 2025-09-02 09:24:06 Meudon Saclay Viandes MeP 4.13 3.00 6.0 8.0 Normal
28 2025-08-23 12:54:24 2025-09-02 09:19:03 Meudon Saclay Viandes MeP 3.94 5.75 6.0 8.0 Normal
29 2025-08-23 12:49:23 2025-09-02 09:14:01 Meudon Saclay Viandes MeP 3.75 7.25 6.0 8.0 Normal
30 2025-08-23 12:44:22 2025-09-02 09:08:58 Meudon Saclay Viandes MeP 3.94 4.00 6.0 8.0 Normal
31 2025-08-23 12:39:21 2025-09-02 09:03:56 Meudon Saclay Viandes MeP 4.13 4.25 6.0 8.0 Normal
32 2025-08-23 13:04:25 2025-09-02 09:30:38 Meudon Poissons Viandes 4.56 4.69 6.0 Normal
33 2025-08-23 12:59:24 2025-09-02 09:25:37 Meudon Poissons Viandes 3.94 5.38 6.0 Normal
34 2025-08-23 12:54:24 2025-09-02 09:20:36 Meudon Poissons Viandes 3.94 5.25 6.0 Normal
35 2025-08-23 12:49:23 2025-09-02 09:15:36 Meudon Poissons Viandes 3.88 4.88 6.0 Normal
36 2025-08-23 12:44:22 2025-09-02 09:10:35 Meudon Poissons Viandes 3.75 4.69 6.0 Normal
37 2025-08-23 12:39:21 2025-09-02 09:05:34 Meudon Poissons Viandes 3.75 4.44 6.0 Normal
38 2025-08-23 13:04:25 2025-09-02 09:30:38 Meudon BOF Poissons 2.00 4.06 8.0 6.0 Normal
39 2025-08-23 12:59:24 2025-09-02 09:25:37 Meudon BOF Poissons 2.00 4.13 8.0 6.0 Normal
40 2025-08-23 12:54:24 2025-09-02 09:20:36 Meudon BOF Poissons 2.25 4.13 8.0 6.0 Normal
41 2025-08-23 12:49:23 2025-09-02 09:15:36 Meudon BOF Poissons 2.50 3.94 8.0 6.0 Normal
42 2025-08-23 12:44:22 2025-09-02 09:10:35 Meudon BOF Poissons 2.50 3.94 8.0 6.0 Normal
43 2025-08-23 12:39:21 2025-09-02 09:05:34 Meudon BOF Poissons 2.75 4.25 8.0 6.0 Normal
44 2025-09-02 09:30:38 Meudon BOF 3.25 8.0 Normal
45 2025-09-02 09:25:37 Meudon BOF 2.50 8.0 Normal
46 2025-09-02 09:20:37 Meudon BOF 2.50 8.0 Normal
47 2025-09-02 09:15:36 Meudon BOF 2.50 8.0 Normal
48 2025-09-02 09:10:35 Meudon BOF 2.25 8.0 Normal
49 2025-09-02 09:05:34 Meudon BOF 2.50 8.0 Normal

View File

@@ -1,147 +0,0 @@
import os
import time
from datetime import datetime, timedelta
from pathlib import Path
from utils_db import connect_to_mysql
from dotenv import load_dotenv
from 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(lieu, message)
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(lieu, message)
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)
try:
df_logs.to_csv(log_dir / "monitor.csv", sep=";", index=False)
print(f"✅ Log écrit dans {log_dir}/monitor.csv", flush=True)
except Exception as e:
print(f"❌ Erreur lors de l'écriture du fichier de log : {e}", flush=True)
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

1012
app/Monitor_Meudon.py Normal file

File diff suppressed because it is too large Load Diff

946
app/Monitor_Saclay.py Normal file
View File

@@ -0,0 +1,946 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ========= Site =========
SITE = "Saclay"
PROGRAM_NAME = f"Monitor_{SITE}"
# ========= Imports & .env =========
import json
import logging
import os
import smtplib
import ssl
import time
import threading
import enum
import datetime as dt
from datetime import datetime
from email.message import EmailMessage
from typing import Any, cast
from zoneinfo import ZoneInfo
import requests
import mysql.connector
from mysql.connector import Error as MySQLError
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True), override=False)
def _env_str(name: str, default: str = "") -> str:
return (os.getenv(name, default) or "").strip()
def _env_bool(name: str, default: bool) -> bool:
value = _env_str(name, "1" if default else "0").lower()
return value in ("1", "true", "yes", "on")
# MQTT
try:
import paho.mqtt.client as mqtt
_mqtt_ok = True
except Exception:
mqtt = None # type: ignore[assignment]
_mqtt_ok = False
# ========= Logger =========
level = getattr(logging, _env_str("LOGLEVEL", "INFO").upper(), logging.INFO)
log = logging.getLogger(PROGRAM_NAME.lower())
if not log.handlers:
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s")
# ========= Helpers types =========
def _to_float(value: Any) -> float:
return float(cast(Any, value))
def _to_datetime(value: Any) -> datetime:
if isinstance(value, datetime):
return value
raise TypeError(f"datetime attendu, reçu: {type(value)!r}")
# ========= DB utils =========
def get_db():
return mysql.connector.connect(
host=_env_str("DB_HOST"),
user=_env_str("DB_USER"),
password=_env_str("DB_PASS"),
database=_env_str("DB_NAME", "Sondes"),
port=int(_env_str("DB_PORT", "3306")),
autocommit=True,
)
def open_alert(conn, table_alertes: str, sonde: str, dt_: datetime) -> bool:
"""
Ouvre UNE alerte si aucune alerte 'En cours' n'existe encore pour la sonde.
Retourne True si une nouvelle alerte a été créée.
"""
cur = conn.cursor()
cur.execute(
f"SELECT 1 FROM `{table_alertes}` WHERE Sonde=%s AND Etat='En cours' LIMIT 1",
(sonde,),
)
if cur.fetchone():
cur.close()
return False
cur.execute(
f"INSERT INTO `{table_alertes}` (Sonde, Debut_defaut, Etat) VALUES (%s, %s, 'En cours')",
(sonde, dt_.strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
cur.close()
return True
def close_alert(conn, table_alertes: str, sonde: str) -> bool:
"""
Ferme l'alerte 'En cours' si présente.
Retourne True si une alerte est passée à 'Acquitté'.
"""
cur = conn.cursor()
cur.execute(
f"UPDATE `{table_alertes}` SET Etat='Acquitté' "
f"WHERE Sonde=%s AND Etat='En cours' "
f"ORDER BY Debut_defaut DESC LIMIT 1",
(sonde,),
)
changed = cur.rowcount == 1
conn.commit()
cur.close()
return changed
# --- Journalisation Gyro en table dédiée `Gyro` ---
def insert_gyro_log(
lieu: str,
etat: str,
topic: str,
payload_raw: str,
qos: int | None,
retained: int | None,
when: datetime,
) -> None:
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
"INSERT INTO Sondes.Gyro (Lieu, Sonde, Etat, Date, Topic, Payload, QoS, Retained) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
lieu,
_env_str("GYRO_SONDE_NAME", "Gyro"),
etat,
when.strftime("%Y-%m-%d %H:%M:%S"),
topic,
payload_raw,
qos,
retained,
),
)
cnx.commit()
log.info("Gyro inséré: %s %s (%s)", lieu, etat, topic)
except MySQLError as err:
log.exception("Erreur DB insert_gyro_log: %s", err)
finally:
cnx.close()
def should_insert_gyro(lieu: str, etat: str, sonde: str = "Gyro") -> bool:
sql = "SELECT Etat FROM Sondes.Gyro WHERE Lieu=%s AND Sonde=%s ORDER BY Date DESC LIMIT 1"
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(sql, (lieu, sonde))
row = cur.fetchone()
return (row is None) or (row[0] != etat)
finally:
cnx.close()
# --- Lecture des dernières mesures de température ---
def lire_sondes_depuis_db(site: str) -> list[dict[str, Any]]:
sql = f"""
SELECT t1.Sonde, t1.Temperature, t1.Date
FROM `{site}` t1
JOIN (
SELECT Sonde, MAX(Date) AS MaxDate
FROM `{site}`
WHERE Temperature IS NOT NULL
GROUP BY Sonde
) t2 ON t1.Sonde=t2.Sonde AND t1.Date=t2.MaxDate
WHERE t1.Temperature IS NOT NULL
"""
cnx = get_db()
try:
cur = cnx.cursor(dictionary=True)
cur.execute(sql)
rows = cast(list[dict[str, Any]], cur.fetchall())
for row in rows:
row["Temperature"] = float(row["Temperature"])
return rows
except MySQLError as err:
log.exception("Erreur DB (lire_sondes_depuis_db): %s", err)
return []
finally:
cnx.close()
def lire_cfg_chambres(site: str) -> dict[str, dict[str, float | bool]]:
"""
Retourne {sonde: {"temp_max": float, "active": bool}} depuis Chambres_froides.
"""
dbname = _env_str("DB_NAME", "Sondes")
sql = f"""
SELECT Sonde, Temp_Max, Etat
FROM `{dbname}`.`Chambres_froides`
WHERE Lieu=%s
"""
cnx = get_db()
cfg: dict[str, dict[str, float | bool]] = {}
try:
cur = cnx.cursor()
cur.execute(sql, (site,))
for sonde, temp_max, etat in cur.fetchall():
cfg[str(sonde)] = {
"temp_max": float(temp_max),
"active": str(etat).upper() == "ON",
}
return cfg
except MySQLError as err:
log.exception("Erreur DB (lire_cfg_chambres): %s", err)
return cfg
finally:
cnx.close()
def compute_site_alarm(
last_values: list[dict[str, Any]],
cfg: dict[str, dict[str, float | bool]],
hysteresis: float = 0.0,
) -> tuple[bool, tuple[str, float, float] | None]:
"""
Retourne (is_on, trigger) avec trigger = (sonde, temperature, seuil).
"""
for row in last_values:
sonde = str(row["Sonde"])
meta = cfg.get(sonde)
if not meta or not meta.get("active", False):
continue
temp = _to_float(row["Temperature"])
seuil = _to_float(meta["temp_max"])
if temp > seuil + hysteresis:
return True, (sonde, temp, seuil)
return False, None
def depassement_depuis_30min(site: str, sonde: str, seuil: float) -> bool:
"""
True si la sonde est > seuil de façon continue depuis CONT_MIN minutes.
"""
cont_min = int(_env_str("ALERT_CONTINUOUS_MINUTES", "30"))
lookback = int(
_env_str(
"ALERT_LOOKBACK_MINUTES",
str(max(60, int(_env_str("ALERT_CONTINUOUS_MINUTES", "30")) * 3)),
)
)
cnx = get_db()
try:
cur = cnx.cursor()
cur.execute(
f"""
SELECT Temperature, Date
FROM `{site}`
WHERE Sonde=%s
AND Date >= (NOW() - INTERVAL %s MINUTE)
ORDER BY Date DESC
""",
(sonde, lookback),
)
rows = cur.fetchall()
if not rows:
return False
first_row = cast(tuple[Any, Any], rows[0])
last_temp = _to_float(first_row[0])
last_dt = _to_datetime(first_row[1])
if last_temp <= seuil:
return False
start_dt = last_dt
for temp, row_dt in rows[1:]:
if _to_float(temp) > seuil:
start_dt = _to_datetime(row_dt)
else:
break
tzinfo = getattr(start_dt, "tzinfo", None)
now = dt.datetime.now(tz=tzinfo)
dur_min = (now - start_dt).total_seconds() / 60.0
log.debug(
"Seq>seuil %s: start=%s, now=%s, dur=%.1fmin, need>=%d",
sonde,
start_dt,
now,
dur_min,
cont_min,
)
return dur_min >= cont_min
except MySQLError as err:
log.exception("Erreur DB (depassement_depuis_30min): %s", err)
return False
finally:
cnx.close()
# ========= Synology Chat =========
def send_synology_chat(message: str, *, username: str | None = None) -> bool:
webhook = (
_env_str(f"SYNO_CHAT_WEBHOOK_MONITOR_{SITE}") or
_env_str(f"SYNO_CHAT_WEBHOOK_MONITOR_{SITE.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK_MONITOR") or
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE}") or
_env_str(f"SYNO_CHAT_WEBHOOK_{SITE.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK")
)
if not webhook:
log.info("Synology Chat non configuré.")
return False
botname = (
username
or _env_str("SYNO_CHAT_BOTNAME_MONITOR")
or _env_str("SYNO_CHAT_BOTNAME")
)
timeout = int(_env_str("SYNO_CHAT_TIMEOUT", "10"))
verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True)
chat_payload: dict[str, str] = {"text": message}
if botname:
chat_payload["username"] = botname
form_data = {
"payload": json.dumps(chat_payload, ensure_ascii=False)
}
try:
response = requests.post(
webhook,
data=form_data,
timeout=timeout,
verify=verify_ssl,
)
txt = (response.text or "").strip()
log.info("Réponse Synology Chat: %s", txt[:300] if txt else "<vide>")
response.raise_for_status()
try:
data = response.json()
if isinstance(data, dict):
success = bool(data.get("success", False))
if not success:
log.warning("Synology Chat a répondu sans succès: %s", data)
return success
except ValueError:
pass
return txt.lower() == "ok" or not txt
except requests.RequestException as err:
log.exception("Echec envoi Synology Chat: %s", err)
return False
# ========= Notifier mail =========
class Notifier:
def __init__(self) -> None:
self.smtp_host = _env_str("SMTP_HOST")
self.smtp_port = int(_env_str("SMTP_PORT", "465"))
self.smtp_user = _env_str("SMTP_USER")
self.smtp_pass = _env_str("SMTP_PASS")
self.smtp_security = _env_str("SMTP_SECURITY", "SSL").upper()
raw_mail_to = (
_env_str(f"MAIL_TO_{SITE}")
or _env_str(f"MAIL_TO_{SITE.upper()}")
or _env_str("MAIL_TO")
)
self.mail_to = [x.strip() for x in raw_mail_to.replace(";", ",").split(",") if x.strip()]
self.mail_from = (
_env_str(f"MAIL_FROM_{SITE}")
or _env_str(f"MAIL_FROM_{SITE.upper()}")
or _env_str("MAIL_FROM")
or self.smtp_user
)
self.smtp_enabled = all([
self.smtp_host,
self.smtp_port,
self.smtp_user,
self.smtp_pass,
self.mail_to,
])
def send_email(self, subject: str, body: str) -> bool:
if not self.smtp_enabled:
log.warning("SMTP non configuré, email non envoyé.")
return False
msg = EmailMessage()
msg["From"] = self.mail_from
msg["To"] = ", ".join(self.mail_to)
msg["Subject"] = subject
msg.set_content(body)
timeout = int(_env_str("SMTP_TIMEOUT", "60"))
debug = _env_bool("SMTP_DEBUG", False)
def _send_ssl() -> None:
with smtplib.SMTP_SSL(
self.smtp_host,
self.smtp_port,
context=ssl.create_default_context(),
timeout=timeout,
) as server:
if debug:
server.set_debuglevel(1)
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
def _send_starttls() -> None:
with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=timeout) as server:
if debug:
server.set_debuglevel(1)
server.ehlo()
server.starttls(context=ssl.create_default_context())
server.ehlo()
server.login(self.smtp_user, self.smtp_pass)
server.send_message(msg)
try:
if self.smtp_security == "STARTTLS":
try:
_send_starttls()
except (smtplib.SMTPServerDisconnected, TimeoutError, smtplib.SMTPConnectError) as err:
log.warning("STARTTLS/587 a échoué (%s). Tentative en SSL/465...", err)
_send_ssl()
else:
_send_ssl()
log.info("Email envoyé à %s", self.mail_to)
return True
except (smtplib.SMTPException, ssl.SSLError, TimeoutError) as err:
log.exception("Erreur SMTP: %s", err)
return False
except Exception as err:
log.exception("Échec envoi email: %s", err)
return False
# ========= Mise en forme messages =========
PARIS = ZoneInfo("Europe/Paris")
def fmt_deg(value: float) -> str:
return f"{float(value):.1f}".replace(".", ",") + "°C"
def now_paris() -> dt.datetime:
return dt.datetime.now(tz=PARIS)
def build_alert_text(
site: str,
sonde: str,
temp: float,
seuil: float,
when: dt.datetime | None = None,
) -> tuple[str, str, str]:
when_dt = when if when is not None else now_paris()
subject = f"[ALERTE {site}] {sonde} au-dessus du seuil"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} (seuil {fmt_deg(seuil)})",
f"Site: {site}",
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}",
]
txt = "\n".join(lines)
return subject, txt, txt
def build_ok_text(
site: str,
sonde: str,
temp: float,
seuil: float,
when: dt.datetime | None = None,
) -> tuple[str, str, str]:
when_dt = when if when is not None else now_paris()
subject = f"[OK {site}] {sonde} revenue normale"
lines = [
subject + ":",
f"Sonde: {sonde}",
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}",
f"Site: {site}",
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}",
]
txt = "\n".join(lines)
return subject, txt, txt
def build_gyro_chat_alert(
site: str,
sonde: str,
temp: float,
seuil: float,
when: dt.datetime | None = None,
) -> str:
when_dt = when if when is not None else now_paris()
return (
f":rotating_light: [{site}] GYRO DECLENCHE\n"
f"Sonde: {sonde}\n"
f"Température: {fmt_deg(temp)} > seuil {fmt_deg(seuil)}\n"
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}"
)
def build_gyro_chat_ok(
site: str,
sonde: str,
temp: float,
seuil: float,
when: dt.datetime | None = None,
) -> str:
when_dt = when if when is not None else now_paris()
return (
f":white_check_mark: [{site}] GYRO RETOUR NORMALE\n"
f"Sonde: {sonde}\n"
f"Température: {fmt_deg(temp)} <= seuil {fmt_deg(seuil)}\n"
f"Heure: {when_dt.strftime('%Y-%m-%d %H:%M:%S')}"
)
# ========= Gyrophare MQTT =========
class MQTTPublisher:
def __init__(self, site: str):
self.enabled = bool(_mqtt_ok)
self.site = site
self.topic = (
_env_str(f"GYRO_MQTT_TOPIC_{site}")
or _env_str(f"GYRO_MQTT_TOPIC_{site.upper()}")
or _env_str("GYRO_MQTT_TOPIC")
or f"Sondes/{site}/Gyro/cmd"
)
self.last_state: bool | None = None
self.client: Any | None = None
if not self.enabled:
log.info("Gyro MQTT désactivé (paho-mqtt absent).")
return
if not self.topic:
log.warning("Topic MQTT manquant pour %s.", site)
self.enabled = False
return
host = _env_str("MQTT_HOST", "localhost")
port = int(_env_str("MQTT_PORT", "1883"))
user = _env_str("MQTT_USER")
pwd = _env_str("MQTT_PASS")
tls = _env_bool("MQTT_TLS", False)
try:
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) # type: ignore[union-attr]
except Exception:
try:
self.client = mqtt.Client() # type: ignore[union-attr]
except TypeError:
self.client = mqtt.Client(client_id="") # type: ignore[union-attr]
if user and pwd:
self.client.username_pw_set(user, pwd)
if tls:
self.client.tls_set()
try:
self.client.on_message = self._on_message
self.client.connect(host, port, keepalive=30)
subs_env = (
_env_str(f"GYRO_MQTT_SUB_{site}")
or _env_str(f"GYRO_MQTT_SUB_{site.upper()}")
or _env_str("GYRO_MQTT_SUB")
)
subs = [topic.strip() for topic in subs_env.split(",") if topic.strip()]
if not subs:
subs = [
self.topic,
f"Sondes/{site}/Gyro/#",
f"{site}/Gyro/#",
"Gyro/#",
]
for topic in subs:
try:
self.client.subscribe(topic, qos=2)
log.info("MQTT subscribe: %s", topic)
except Exception as err:
log.warning("Subscribe échoué (%s): %s", topic, err)
self.client.loop_start()
log.info("MQTT connecté (%s:%s), topic=%s", host, port, self.topic)
except Exception as err:
log.exception("MQTT connexion impossible: %s", err)
self.enabled = False
def _on_message(self, _client, _userdata, msg) -> None:
lieu = self.site
topic = str(msg.topic)
payload_raw = msg.payload.decode(errors="ignore").strip()
upper = payload_raw.upper()
if upper in ("ON", "OFF") or "gyro" in topic.lower() or "gyrophare" in topic.lower():
etat = upper if upper in ("ON", "OFF") else ("ON" if "ON" in upper else "OFF")
try:
if should_insert_gyro(lieu, etat):
insert_gyro_log(
lieu=lieu,
etat=etat,
topic=topic,
payload_raw=payload_raw,
qos=getattr(msg, "qos", None),
retained=getattr(msg, "retain", None),
when=now_paris(),
)
except Exception as err:
log.exception("Insert Gyro échoué: %s", err)
return
try:
float(payload_raw.replace(",", "."))
except ValueError:
log.debug("Payload non géré (ni gyro ni nombre): %s %s", topic, payload_raw)
def set(self, on: bool) -> None:
if not self.enabled or self.client is None:
return
if self.last_state is not None and self.last_state == on:
return
payload = "ON" if on else "OFF"
try:
result = self.client.publish(self.topic, payload=payload, qos=2, retain=True)
try:
result.wait_for_publish(timeout=3)
except Exception:
pass
if getattr(result, "rc", 0) != 0:
log.warning("MQTT publish rc=%s (topic=%s)", getattr(result, "rc", None), self.topic)
else:
log.info("Gyro %s -> %s (MQTT)", self.site, payload)
try:
insert_gyro_log(
lieu=self.site,
etat=payload,
topic=self.topic,
payload_raw=payload,
qos=2,
retained=1 if getattr(result, "is_published", lambda: False)() else None,
when=now_paris(),
)
except Exception as err:
log.exception("Insert événement gyro en base a échoué: %s", err)
self.last_state = on
except Exception as err:
log.exception("MQTT publish erreur: %s", err)
# ========= Contrôleur Gyro réactif =========
class _GyroState(enum.Enum):
IDLE = 0
PULSE_ON = 1
COOLDOWN = 2
class GyroPulseController:
"""
Boucle rapide indépendante :
- MODE CONTINU : ON tant que lalarme persiste, OFF quand retour normal confirmé.
- MODE PULSE : ON puis OFF pendant cooldown tant que lalarme persiste.
Notifications conservées :
- Synology Chat immédiat au déclenchement Gyro
- Synology Chat immédiat au retour à la normale
"""
def __init__(
self,
site: str,
beacon: MQTTPublisher,
*,
check_sec: int = int(_env_str("GYRO_CHECK_SEC", "20")),
pulse_sec: int = int(_env_str("GYRO_PULSE_SEC", "60")),
cooldown_sec: int = int(_env_str("GYRO_COOLDOWN_SEC", "600")),
normal_confirm: int = int(_env_str("GYRO_NORMAL_CONFIRM", "2")),
):
self.site = site
self.beacon = beacon
self.check_sec = check_sec
self.pulse_sec = pulse_sec
self.cooldown_sec = cooldown_sec
self.normal_confirm = normal_confirm
self.state = _GyroState.IDLE
self._t_pulse_end = 0.0
self._t_cooldown_end = 0.0
self._normal_count = 0
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._current: bool | None = None
self._last_trigger: tuple[str, float, float] | None = None
def _set_gyro(self, on: bool) -> None:
if self._current is not on:
self.beacon.set(on)
self._current = on
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
log.info(
"GyroPulseController démarré (site=%s, check=%ss, pulse=%ss, cooldown=%ss, confirm=%d)",
self.site,
self.check_sec,
self.pulse_sec,
self.cooldown_sec,
self.normal_confirm,
)
def stop(self) -> None:
self._stop.set()
def _send_chat_alert(self, trigger: tuple[str, float, float] | None) -> None:
if not trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
sonde, temp, seuil = trigger
chat_msg = build_gyro_chat_alert(self.site, sonde, temp, seuil, when=now_paris())
send_synology_chat(chat_msg)
def _send_chat_ok_from_last_trigger(self) -> None:
if not self._last_trigger or not _env_bool("SYNO_CHAT_GYRO_ENABLED", True):
return
sonde, _temp_prev, seuil = self._last_trigger
rows = lire_sondes_depuis_db(self.site)
curr_temp: float | None = None
for row in rows:
if str(row["Sonde"]) == sonde:
curr_temp = float(row["Temperature"])
break
if curr_temp is None:
curr_temp = seuil - 0.1
chat_msg = build_gyro_chat_ok(self.site, sonde, curr_temp, seuil, when=now_paris())
send_synology_chat(chat_msg)
self._last_trigger = None
def _is_alarm_now(self) -> tuple[bool, tuple[str, float, float] | None]:
last_rows = lire_sondes_depuis_db(self.site)
cfg = lire_cfg_chambres(self.site)
return compute_site_alarm(
last_rows,
cfg,
hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")),
)
def _run(self) -> None:
while not self._stop.is_set():
now = time.time()
try:
active, trigger = self._is_alarm_now()
except Exception as err:
log.exception("Gyro fast-loop: erreur lecture état: %s", err)
active, trigger = False, None
if self.state == _GyroState.IDLE:
if active:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self._normal_count = 0
self.state = _GyroState.PULSE_ON
self._last_trigger = trigger
if trigger:
sonde, temp, seuil = trigger
mode = "CONTINU" if _env_bool("GYRO_MODE_CONTINUOUS", True) else "PULSE"
log.info("Gyro → ON déclenché par %s: %.2f > %.2f (mode %s)", sonde, temp, seuil, mode)
self._send_chat_alert(trigger)
elif self.state == _GyroState.PULSE_ON:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self._set_gyro(False)
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro → OFF (retour à la normale confirmé)")
self._send_chat_ok_from_last_trigger()
else:
self._normal_count = 0
if not _env_bool("GYRO_MODE_CONTINUOUS", True) and now >= self._t_pulse_end:
self._set_gyro(False)
self._t_cooldown_end = now + self.cooldown_sec
self.state = _GyroState.COOLDOWN
log.info("Gyro → OFF, cooldown %ss (alerte persiste)", self.cooldown_sec)
elif self.state == _GyroState.COOLDOWN:
if not active:
self._normal_count += 1
if self._normal_count >= self.normal_confirm:
self.state = _GyroState.IDLE
self._normal_count = 0
log.info("Gyro: retour IDLE (plus dalerte)")
else:
self._normal_count = 0
if now >= self._t_cooldown_end:
self._set_gyro(True)
self._t_pulse_end = now + self.pulse_sec
self.state = _GyroState.PULSE_ON
log.info("Gyro → ON (re-pulse)")
time.sleep(self.check_sec)
# ========= Notifs haut-niveau =========
notifier = Notifier()
beacon = MQTTPublisher(SITE)
def notifier_sur_depassement(site: str, sonde: str, temp: float, seuil: float) -> None:
"""
Mail quand l'alerte est confirmée (≥ 30 min) et ouverte en base.
"""
subject, _mail_text, email_body = build_alert_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body)
def notifier_acquittement(site: str, sonde: str, temp: float, seuil: float) -> None:
"""
Mail lorsque lalerte est acquittée en base.
"""
subject, _mail_text, email_body = build_ok_text(site, sonde, temp, seuil)
notifier.send_email(subject, email_body)
# ========= Cycle & boucle =========
def run_monitor_cycle(site: str = SITE) -> None:
last_rows = lire_sondes_depuis_db(site)
cfg = lire_cfg_chambres(site)
try:
_gyro_on, trigger = compute_site_alarm(
last_rows,
cfg,
hysteresis=float(_env_str("GYRO_HYSTERESIS", "0.0")),
)
if trigger:
sonde, temp, seuil = trigger
log.info("Dépassement détecté (gyro géré par boucle rapide) : %s %.2f > %.2f", sonde, temp, seuil)
else:
log.info("Aucun dépassement au moment du cycle")
except Exception as err:
log.exception("Erreur calcul alarme (info): %s", err)
seuils = {sonde: float(meta["temp_max"]) for sonde, meta in cfg.items() if meta.get("active", False)}
for row in last_rows:
nom = str(row["Sonde"])
temp = float(row["Temperature"])
if nom not in seuils:
continue
seuil = seuils[nom]
now_ = now_paris()
if temp > seuil:
if depassement_depuis_30min(site, nom, seuil):
conn = None
try:
conn = get_db()
if open_alert(conn, f"Alertes_{site}", nom, now_):
notifier_sur_depassement(site, nom, temp, seuil)
finally:
if conn:
conn.close()
else:
conn = None
try:
conn = get_db()
if close_alert(conn, f"Alertes_{site}", nom):
notifier_acquittement(site, nom, temp, seuil)
finally:
if conn:
conn.close()
def run_monitor_loop(site: str = SITE, period_sec: int = 300) -> None:
log.info("%s démarré (site=%s, période=%ss) ✅", PROGRAM_NAME, site, period_sec)
try:
global _gyro_controller
_gyro_controller = GyroPulseController(site, beacon)
_gyro_controller.start()
except Exception as err:
log.exception("Impossible de démarrer le GyroPulseController: %s", err)
while True:
t0 = time.time()
try:
run_monitor_cycle(site)
except Exception as err:
log.exception("Erreur cycle monitoring: %s", err)
time.sleep(max(0.0, period_sec - (time.time() - t0)))
# ========= CLI =========
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=PROGRAM_NAME)
parser.add_argument("--period", type=int, default=300)
parser.add_argument("--test-mail", action="store_true")
parser.add_argument("--test-alert", action="store_true")
parser.add_argument("--test-ok", action="store_true")
parser.add_argument("--test-chat", action="store_true")
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
if args.test_mail:
notifier.send_email(f"[TEST {SITE}] Mail", "OK")
elif args.test_alert:
notifier_sur_depassement(SITE, "Congelateur", -14.5, -15.0)
elif args.test_ok:
notifier_acquittement(SITE, "Congelateur", -15.2, -15.0)
elif args.test_chat:
send_synology_chat(f":speech_balloon: [TEST {SITE}] Notification Synology Chat OK")
else:
if args.once:
run_monitor_cycle(SITE)
else:
run_monitor_loop(SITE, period_sec=args.period)

228
app/Monitor_connexions.py Normal file
View File

@@ -0,0 +1,228 @@
import os
import time
import logging
import json
from typing import Optional
from pathlib import Path
import pymysql
import requests
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s"
)
log = logging.getLogger("journal_connexions")
def env_str(name: str, default: Optional[str] = None) -> Optional[str]:
value = os.getenv(name, default)
if value is None:
return None
value = value.strip()
return value if value else default
DB_CONFIG = {
"host": env_str("DB_HOST"),
"port": int(env_str("DB_PORT", "3306")),
"user": env_str("DB_USER2"),
"password": env_str("DB_PASS2"),
"database": env_str("DB_NAME2", "Acces"),
"charset": "utf8mb4",
"cursorclass": pymysql.cursors.DictCursor,
"autocommit": True,
}
SYNO_CHAT_WEBHOOK = env_str("SYNO_CHAT_WEBHOOK_CONNEXIONS")
SYNO_CHAT_BOTNAME = env_str("SYNO_CHAT_BOTNAME_CONNEXIONS", "Journal Connexions")
SYNO_CHAT_WEBHOOK_SIMPLE = env_str("SYNO_CHAT_WEBHOOK_CONNEXIONS_SIMPLE")
SYNO_CHAT_BOTNAME_SIMPLE = env_str("SYNO_CHAT_BOTNAME_CONNEXIONS_SIMPLE", "Connexions Simples")
POLL_INTERVAL = int(env_str("POLL_INTERVAL", "10"))
def get_connection():
return pymysql.connect(**DB_CONFIG)
def format_message(row: dict) -> str:
return (
f"[Connexion MySQL]\n"
f"Utilisateur : {row.get('NomUtilisateur', '')}\n"
f"Poste : {row.get('PosteClient', '')}\n"
f"Tableur : {row.get('TableurSource', '')}\n"
f"Windows : {row.get('UtilisateurWindows', '')}\n"
f"Site : {row.get('SiteDemande', '')}\n"
f"Service : {row.get('ServiceDemande', '')}\n"
f"DSN : {row.get('DSN', '')}\n"
f"BDD : {row.get('BDD', '')}\n"
f"Statut : {row.get('Statut', '')}\n"
f"Motif : {row.get('Motif', '')}\n"
f"Heure : {row.get('DateHeure', '')}\n"
f"Session : {row.get('SessionID', '')}"
)
def format_simple_message(row: dict) -> str:
user = row.get("NomUtilisateur", "")
site = row.get("SiteDemande", "")
statut = row.get("Statut", "")
return f"[Connexion site]\n{user} -> {site} ({statut})"
def send_synology_chat(message: str) -> None:
if not SYNO_CHAT_WEBHOOK:
raise RuntimeError("SYNO_CHAT_WEBHOOK_CONNEXIONS non configure")
syno_payload = {
"text": message,
"botName": SYNO_CHAT_BOTNAME
}
response = requests.post(
SYNO_CHAT_WEBHOOK,
data={"payload": json.dumps(syno_payload, ensure_ascii=False)},
timeout=10
)
log.info("Synology Chat DETAIL HTTP=%s body=%s", response.status_code, response.text)
response.raise_for_status()
body = response.json()
if not body.get("success", False):
raise RuntimeError(f"Synology Chat DETAIL erreur: {body}")
def send_synology_chat_simple(message: str) -> None:
if not SYNO_CHAT_WEBHOOK_SIMPLE:
return
syno_payload = {
"text": message,
"botName": SYNO_CHAT_BOTNAME_SIMPLE
}
response = requests.post(
SYNO_CHAT_WEBHOOK_SIMPLE,
data={"payload": json.dumps(syno_payload, ensure_ascii=False)},
timeout=10
)
log.info("Synology Chat SIMPLE HTTP=%s body=%s", response.status_code, response.text)
response.raise_for_status()
body = response.json()
if not body.get("success", False):
raise RuntimeError(f"Synology Chat SIMPLE erreur: {body}")
def fetch_pending_rows(conn) -> list[dict]:
sql = """
SELECT
Id_Journal,
DateHeure,
NomUtilisateur,
PosteClient,
TableurSource,
UtilisateurWindows,
SiteDemande,
ServiceDemande,
DSN,
BDD,
Statut,
Motif,
SessionID
FROM `Acces`.`JournalConnexions`
WHERE NotificationEnvoyee = 0
ORDER BY DateHeure ASC, Id_Journal ASC
LIMIT 50
"""
with conn.cursor() as cur:
cur.execute(sql)
return cur.fetchall()
def mark_sent(conn, row_id: int) -> None:
sql = """
UPDATE `Acces`.`JournalConnexions`
SET NotificationEnvoyee = 1,
DateNotification = NOW(),
ErreurNotification = NULL
WHERE Id_Journal = %s
"""
with conn.cursor() as cur:
cur.execute(sql, (row_id,))
def mark_error(conn, row_id: int, error_msg: str) -> None:
sql = """
UPDATE `Acces`.`JournalConnexions`
SET ErreurNotification = %s
WHERE Id_Journal = %s
"""
with conn.cursor() as cur:
cur.execute(sql, (error_msg[:255], row_id))
def process_once() -> None:
with get_connection() as conn:
rows = fetch_pending_rows(conn)
if not rows:
log.info("Aucune nouvelle connexion à notifier.")
return
log.info("%s connexion(s) à notifier.", len(rows))
for row in rows:
row_id = row["Id_Journal"]
try:
detailed_message = format_message(row)
send_synology_chat(detailed_message)
# Envoi simplifie uniquement pour la vraie connexion site,
# pas pour la ligne meta Domo91 / Acces
if row.get("SiteDemande") and row.get("DSN") != "Domo91":
simple_message = format_simple_message(row)
send_synology_chat_simple(simple_message)
mark_sent(conn, row_id)
log.info("Notification envoyee pour Id_Journal=%s", row_id)
except Exception as exc:
log.exception("Erreur d'envoi pour Id_Journal=%s", row_id)
try:
mark_error(conn, row_id, str(exc))
except Exception:
log.exception("Impossible d'ecrire ErreurNotification pour Id_Journal=%s", row_id)
def main():
missing = [k for k, v in DB_CONFIG.items() if v is None and k != "port"]
if missing:
raise RuntimeError(f"Variables d'environnement manquantes : {', '.join(missing)}")
if not SYNO_CHAT_WEBHOOK:
raise RuntimeError("SYNO_CHAT_WEBHOOK_CONNEXIONS manquant")
log.info("Surveillance de JournalConnexions demarree.")
while True:
try:
process_once()
except Exception:
log.exception("Erreur generale dans la boucle de surveillance")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
main()

274
app/Mqtt_Meudon.py Normal file
View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Mqtt_Meudon.py
--------------------------------
- S'abonne à Meudon/# sur le broker MQTT
- Parse les messages (topic -> nom de sonde, payload -> température)
- Insère les relevés dans MySQL (table `Meudon` par défaut)
Variables d'environnement attendues (exemples) :
- MQTT_HOST, MQTT_PORT, MQTT_USER, MQTT_PASS
- DB_HOST, DB_USER, DB_PASS, DB_NAME
Optionnelles :
- DB_TABLE (défaut: Meudon)
- LOG_FILE (défaut: Mqtt_meudon.log)
- LOG_LEVEL (défaut: INFO)
- DB_POOL_SIZE (défaut: 5)
"""
from __future__ import annotations
import os
import sys
import time
import logging
from logging.handlers import RotatingFileHandler
from typing import Any, Optional, cast
import paho.mqtt.client as mqtt
from paho.mqtt.client import CallbackAPIVersion
from mysql.connector import pooling
from mysql.connector.abstracts import MySQLConnectionAbstract
from mysql.connector.cursor import MySQLCursor
# =========================
# Configuration (ENV)
# =========================
from dotenv import load_dotenv
load_dotenv()
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_USER = os.getenv("DB_USER")
DB_PASS = os.getenv("DB_PASS")
DB_NAME = os.getenv("DB_NAME", "Sondes")
DB_TABLE = os.getenv("DB_TABLE", "Meudon")
DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5"))
MQTT_HOST = os.getenv("MQTT_HOST", "192.168.1.100")
MQTT_USER = os.getenv("MQTT_USER", "Sondes")
MQTT_PASS = os.getenv("MQTT_PASS", "3J@bjYP0")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
GYRO_TOPIC_MEUDON = os.getenv("GYRO_MQTT_TOPIC_MEUDON", "Meudon/gyrophare")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
LOG_FILE = os.getenv(
"LOG_FILE",
"/home/domo91/Gestion_sondes/Logs/cuisine_meudon_script.log"
)
# =========================
# Logging
# =========================
def setup_logging() -> None:
"""Configure un log propre (rotation) sans dupliquer les handlers."""
logger = logging.getLogger()
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
# Évite la duplication si setup_logging() est rappelé
if any(isinstance(h, RotatingFileHandler) for h in logger.handlers):
return
fmt = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=2_000_000,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(fmt)
file_handler.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(fmt)
console_handler.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
logger.addHandler(file_handler)
logger.addHandler(console_handler)
# =========================
# MySQL (pool)
# =========================
_db_pool: Optional[pooling.MySQLConnectionPool] = None
def init_db_pool() -> None:
global _db_pool
if _db_pool is not None:
return
_db_pool = pooling.MySQLConnectionPool(
pool_name="meudon_pool",
pool_size=DB_POOL_SIZE,
host=DB_HOST,
user=DB_USER,
password=DB_PASS,
database=DB_NAME,
autocommit=False,
)
logging.info("Pool MySQL initialisé (db=%s, size=%s)", DB_NAME, DB_POOL_SIZE)
def insert_temperature(sonde: str, temperature: float) -> None:
"""Insère un relevé dans la table configurée."""
if _db_pool is None:
init_db_pool()
conn: Optional[MySQLConnectionAbstract] = None
cursor: Optional[MySQLCursor] = None
try:
assert _db_pool is not None # pour le typage
conn = cast(MySQLConnectionAbstract, _db_pool.get_connection())
cursor = conn.cursor()
# Colonnes supposées : Sonde, Temperature, Date
sql = f"INSERT INTO `{DB_TABLE}` (Sonde, Temperature, Date) VALUES (%s, %s, NOW())"
cursor.execute(sql, (sonde, temperature))
conn.commit()
except Exception as exc:
logging.error("Erreur MySQL (insert %s): %s", sonde, exc)
# En cas d'erreur, on rollback pour garder la connexion saine
try:
if conn is not None:
conn.rollback()
except Exception:
pass
finally:
try:
if cursor is not None:
cursor.close()
finally:
if conn is not None:
conn.close()
# =========================
# MQTT callbacks
# =========================
def _topic_to_sonde(topic: str) -> str:
"""Convertit un topic MQTT en nom de sonde.
Exemple : 'Meudon/Chambre1' -> 'Chambre1'
"""
parts = topic.split("/")
if len(parts) >= 2:
return parts[-1].strip()
return topic.strip()
def _payload_to_float(payload: bytes) -> Optional[float]:
"""Convertit un payload en float (accepte virgule)."""
try:
s = payload.decode("utf-8", errors="replace").strip()
if not s:
return None
s = s.replace(",", ".")
return float(s)
except Exception:
return None
def on_connect(
client: mqtt.Client,
_userdata: Any,
_flags: Any,
reason_code: int,
properties: Any = None,
) -> None:
# `properties` est fourni par l'API v2 ; on le garde pour compatibilité.
_ = properties # évite les avertissements "non utilisé"
if reason_code == 0:
logging.info("Connecté au broker MQTT Meudon (%s:%s)", MQTT_HOST, MQTT_PORT)
result, mid = client.subscribe("Meudon/#")
logging.info("Abonné au topic : Meudon/# (result=%s, mid=%s)", result, mid)
else:
logging.error("Échec de connexion MQTT (Meudon), reason_code=%s", reason_code)
def on_disconnect(
_client: mqtt.Client,
_userdata: Any,
reason_code: int,
properties: Any = None,
) -> None:
_ = properties
logging.warning("Déconnecté du broker MQTT (reason_code=%s)", reason_code)
def on_message(_client: mqtt.Client, _userdata: Any, msg: mqtt.MQTTMessage) -> None:
topic = (msg.topic or "").strip()
sonde = _topic_to_sonde(topic)
temp = _payload_to_float(msg.payload)
if temp is None:
logging.warning("Payload non numérique ignoré (topic=%s, payload=%r)", topic, msg.payload)
return
logging.info("Reçu: topic=%s -> sonde=%s | température=%s", topic, sonde, temp)
insert_temperature(sonde, temp)
# =========================
# Main
# =========================
def build_mqtt_client() -> mqtt.Client:
client = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2)
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
# Reconnexion automatique
client.reconnect_delay_set(min_delay=1, max_delay=30)
return client
def main() -> None:
setup_logging()
init_db_pool()
client = build_mqtt_client()
while True:
try:
logging.info("Connexion MQTT en cours (%s:%s)...", MQTT_HOST, MQTT_PORT)
client.connect(MQTT_HOST, MQTT_PORT, keepalive=60)
break
except Exception as exc:
logging.error("Impossible de se connecter au broker MQTT: %s", exc)
time.sleep(5)
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:
try:
client.disconnect()
except Exception:
pass
logging.info("Client MQTT Meudon déconnecté.")
if __name__ == "__main__":
main()

166
app/Mqtt_saclay.py Normal file
View 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 = "192.168.1.100"
MQTT_USER = "Sondes"
MQTT_PASS = "3J@bjYP0"
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()

20
app/Test_Chat.py Normal file
View File

@@ -0,0 +1,20 @@
import json
import requests
WEBHOOK_URL = "https://192.168.1.250/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming&version=2&token=UN7nhD70vrhrHFh1VeDdOpsklIHiIFRop2qB7b6YusMEY3clY3R8CXe4hFzz4KKc"
payload = {
"text": "✅ Test VPS vers Synology Chat"
}
try:
r = requests.post(
WEBHOOK_URL,
data={"payload": json.dumps(payload, ensure_ascii=False)},
timeout=10,
verify=False
)
print("HTTP:", r.status_code)
print("Réponse:", r.text)
except Exception as e:
print("Erreur:", repr(e))

View File

View File

@@ -1,174 +0,0 @@
import mysql.connector
from datetime import datetime, timedelta
import time
from dotenv import load_dotenv
import os
from pathlib import Path
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"
# --- Config MySQL ---
config = {
"host": os.getenv("DB_HOST"),
"user": os.getenv("DB_USER"),
"password": os.getenv("DB_PASSWORD"),
"database": os.getenv("DB_NAME")
}
# --- Suivi des alertes actives pour rappels ---
alertes_actives = {}
# --- Fonction d'envoi de mail ---
def envoyer_sms_ovh(message, lieu):
try:
import requests
sms_data = {
"account": os.getenv("OVH_SMS_ACCOUNT"),
"login": os.getenv("OVH_SERVICE_NAME"),
"password": os.getenv("OVH_PASSWORD"),
"message": f"{lieu}: {message}",
"receivers": os.getenv("SMS_RECEIVER", "").split(","),
"sender": os.getenv("OVH_SMS_SENDER")
}
# Exemple d'envoi avec l'API OVH (à adapter selon ton endpoint exact)
response = requests.post("https://www.ovh.com/cgi-bin/sms/http2sms.cgi", data=sms_data)
print(f"📱 SMS envoyé : {response.text}", flush=True)
except Exception as e:
print(f"Erreur envoi SMS : {e}", flush=True)
# --- Fonction de surveillance ---
def surveiller():
global alertes_actives
log_entries = []
try:
conn = mysql.connector.connect(**config)
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT DISTINCT Lieu FROM Sondes.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'", (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)
sujet = f"🚨 ALERTE TEMPÉRATURE - {nom_sonde} ({lieu})"
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_ovh(message, lieu)
# Suivi pour rappels
alertes_actives[nom_sonde] = maintenant
else:
# Alerte déjà en cours : vérifier s'il faut faire un rappel
dernier_envoi = alertes_actives.get(nom_sonde)
if dernier_envoi and (maintenant - dernier_envoi >= timedelta(hours=1)):
sujet = f"🔔 RAPPEL ALERTE TEMPÉRATURE - {nom_sonde} ({lieu})"
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_ovh(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,))
# Nettoyage du suivi si normalisé
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)
# Sauvegarde principale
df_logs.to_csv(log_dir / "monitor.csv", sep=";", index=False)
# Sauvegarde secondaire (Linux uniquement)
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

BIN
app/assets/Logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
app/assets/QR_Domo91FR.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
app/assets/qr_domo91.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -1,82 +0,0 @@
# 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()

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
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}")

View File

@@ -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
View 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")
DEFAULT_BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
DEFAULT_MQTT_USER = os.getenv("MQTT_USER")
DEFAULT_MQTT_PASS = os.getenv("MQTT_PASS")
# 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 dalerter
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é.")

View File

@@ -36,7 +36,7 @@ if envoyer_mail:
try:
with smtplib.SMTP_SSL("smtp.mail.ovh.net", 465) as server:
server.login("services@domo91.fr", "6ZiCsVtSf9@nEHv@$^0")
server.login("services@domo91.fr", "VHq3278YA#sGV*bh#mR")
server.sendmail(msg["From"], [msg["To"]], msg.as_string())
print("📧 Mail envoyé.")
except Exception as e:

View File

@@ -1,101 +1,507 @@
#!/home/debian/Gestion_sondes/myenv/bin/python3
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Surveillance des réceptions de données dans les tables (par site)
+ alerte mail
+ alerte Synology Chat
+ retour à la normale
"""
from __future__ import annotations
from datetime import datetime, timedelta
from dotenv import load_dotenv
import os
import utils_db
from pathlib import Path
import json
import logging
from utils_sms import envoyer_sms
import os
import sys
import re
# Dossier Logs
LOG_DIR = '/home/debian/Gestion_sondes/Logs'
os.makedirs(LOG_DIR, exist_ok=True)
import mysql.connector
from contextlib import closing
from typing import cast
from mysql.connector.connection import MySQLConnection
from mysql.connector.cursor import MySQLCursor
import requests
from dotenv import load_dotenv
# Fichier de log (nommé par date)
log_filename = os.path.join(LOG_DIR, datetime.now().strftime("surveillance_%Y-%m-%d.log"))
import utils_db
from utils_mail import envoyer_mail
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')
# ============================================================
# PATHS / 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')
APP_DIR = Path(__file__).resolve().parent # .../Gestion_sondes/app
ROOT_DIR = APP_DIR.parent # .../Gestion_sondes
ENV_PATH = ROOT_DIR / ".env"
LOG_DIR = ROOT_DIR / "Logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Etat persistant dans le projet (évite les surprises de /tmp)
STATE_DIR = APP_DIR / "state"
STATE_DIR.mkdir(parents=True, exist_ok=True)
load_dotenv(ENV_PATH, override=True)
# ============================================================
# CONFIGURATION
# ============================================================
# Table de configuration : seuls les sites avec Actif = 'ON' seront surveillés.
TABLE_SITES_SURVEILLANCE = "Sites_Surveillance"
# Sécurité / secours : utilisé uniquement si la table Sites_Surveillance est absente
# ou si elle ne retourne aucun site actif.
TABLES_FALLBACK = ["Saclay", "Meudon"]
tables = ['Saclay', 'Meudon', 'Chaufferie']
DELAI_MINUTES = 15
RAPPEL_HEURES = 6
STATE_DIR = '/tmp/surveillance_states'
os.makedirs(STATE_DIR, exist_ok=True)
def _env_str(name: str, default: str = "") -> str:
return (os.getenv(name, default) or "").strip()
def should_send_alert(site):
state_file = os.path.join(STATE_DIR, f'{site}.state')
def _env_bool(name: str, default: bool) -> bool:
value = _env_str(name, "1" if default else "0").lower()
return value in ("1", "true", "yes", "on")
# ============================================================
# LOGGING
# ============================================================
log_filename = LOG_DIR / f"surveillance_{datetime.now():%Y-%m-%d}.log"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
handlers=[
logging.FileHandler(log_filename, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
force=True,
)
for handler in logging.getLogger().handlers:
if isinstance(handler, logging.StreamHandler):
try:
handler.stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ============================================================
# ETAT DES ALERTES
# ============================================================
def state_file(site: str) -> Path:
return STATE_DIR / f"{site}.json"
def read_state(site: str) -> dict:
sf = state_file(site)
if not sf.exists():
return {
"status": "ok", # ok | alerting
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
}
try:
data = json.loads(sf.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("format JSON invalide")
return {
"status": data.get("status", "ok"),
"first_alert_at": data.get("first_alert_at"),
"last_alert_at": data.get("last_alert_at"),
"last_data_at": data.get("last_data_at"),
}
except Exception as e:
logging.warning(f"Etat corrompu pour {site} ({sf}) : {e}. Etat réinitialisé.")
return {
"status": "ok",
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
}
def write_state(site: str, state: dict) -> None:
sf = state_file(site)
try:
sf.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError as e:
logging.warning(f"Impossible d'écrire l'état {sf} : {e}")
def dt_to_iso(value) -> str | None:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def iso_to_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def enter_alert_state(site: str, last_update) -> None:
state = read_state(site)
now = datetime.now()
if not os.path.exists(state_file):
with open(state_file, 'w') as f:
f.write(now.isoformat())
state["status"] = "alerting"
state["first_alert_at"] = state["first_alert_at"] or now.isoformat()
state["last_alert_at"] = now.isoformat()
state["last_data_at"] = dt_to_iso(last_update)
write_state(site, state)
def update_last_data(site: str, last_update) -> None:
state = read_state(site)
state["last_data_at"] = dt_to_iso(last_update)
write_state(site, state)
def clear_state(site: str) -> None:
write_state(site, {
"status": "ok",
"first_alert_at": None,
"last_alert_at": None,
"last_data_at": None,
})
def is_alerting(site: str) -> bool:
return read_state(site).get("status") == "alerting"
def should_send_alert(site: str) -> bool:
"""
Règle :
- 1ère alerte dès que l'absence de données dépasse DELAI_MINUTES
- puis 1 rappel toutes les RAPPEL_HEURES tant que le défaut persiste
"""
state = read_state(site)
if state.get("status") != "alerting":
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())
last_alert = iso_to_dt(state.get("last_alert_at"))
if last_alert is None:
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)
return (datetime.now() - last_alert) >= timedelta(hours=RAPPEL_HEURES)
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 = []
# ============================================================
# NOTIFICATIONS
# ============================================================
for table in tables:
cursor.execute(f"SELECT MAX(Date) FROM {table}")
result = cursor.fetchone()
last_update = result[0]
def envoyer_chat(site: str, titre: str, message: str) -> None:
webhook = (
_env_str(f"SYNO_CHAT_WEBHOOK_MONITOR_{site}") or
_env_str(f"SYNO_CHAT_WEBHOOK_MONITOR_{site.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK_MONITOR") or
_env_str(f"SYNO_CHAT_WEBHOOK_{site}") or
_env_str(f"SYNO_CHAT_WEBHOOK_{site.upper()}") or
_env_str("SYNO_CHAT_WEBHOOK")
)
if not webhook:
logging.warning(f"Webhook Synology Chat monitor non configuré pour {site} : notification Chat ignorée.")
return
verify_ssl = _env_bool("SYNO_CHAT_VERIFY_SSL", True)
botname = _env_str("SYNO_CHAT_BOTNAME_MONITOR", "Injection données dans tables")
texte = f"{titre}\n{message}"
payload: dict[str, str] = {"text": texte}
if botname:
payload["username"] = botname
response = requests.post(
webhook,
data={"payload": json.dumps(payload, ensure_ascii=False)},
timeout=int(_env_str("SYNO_CHAT_TIMEOUT", "10")),
verify=verify_ssl,
)
response.raise_for_status()
try:
rep_json = response.json()
if isinstance(rep_json, dict) and rep_json.get("success") is False:
raise RuntimeError(f"Synology Chat a refusé le message : {rep_json}")
except ValueError:
pass
logging.info("💬 Notification Synology Chat envoyée pour %s.", site)
def envoyer_notifications(site: str, sujet: str, message: str) -> None:
"""
Envoie mail + chat.
Lève une erreur si au moins un des deux canaux échoue.
"""
erreurs: list[str] = []
try:
envoyer_mail(sujet, message)
logging.info("📧 Mail envoyé.")
except Exception as e:
erreurs.append(f"mail: {e}")
try:
envoyer_chat(site, sujet, message)
except Exception as e:
erreurs.append(f"chat: {e}")
if erreurs:
raise RuntimeError(" | ".join(erreurs))
# ============================================================
# ACCES BASE
# ============================================================
def is_safe_identifier(name: str) -> bool:
"""
Autorise uniquement les noms simples de tables/sites :
lettres, chiffres et underscore.
Cela évite toute injection SQL via un nom de table dynamique.
"""
return bool(re.fullmatch(r"[A-Za-z0-9_]+", name or ""))
def quote_identifier(name: str) -> str:
if not is_safe_identifier(name):
raise ValueError(f"Nom de table/site invalide : {name!r}")
return f"`{name}`"
def get_last_update(cursor, table: str) -> datetime | None:
cursor.execute(f"SELECT MAX(Date) FROM {quote_identifier(table)}")
row = cursor.fetchone()
if not row or row[0] is None:
return None
value = row[0]
if isinstance(value, datetime):
return value
return None
def get_tables_a_surveiller(cursor) -> list[str]:
"""
Lit la table Sites_Surveillance.
Seuls les sites Actif = 'ON' sont surveillés.
Les sites Actif = 'OFF' sont journalisés mais ignorés.
"""
try:
cursor.execute(f"""
SELECT Lieu, Actif, Commentaire
FROM {quote_identifier(TABLE_SITES_SURVEILLANCE)}
ORDER BY Lieu
""")
rows = cursor.fetchall()
except mysql.connector.Error as e:
logging.error(
"Impossible de lire %s : %s. Utilisation du fallback : %s",
TABLE_SITES_SURVEILLANCE,
e,
", ".join(TABLES_FALLBACK),
)
return TABLES_FALLBACK.copy()
tables_actives: list[str] = []
for lieu, actif, commentaire in rows:
lieu = str(lieu).strip()
actif = str(actif).strip().upper()
if not is_safe_identifier(lieu):
logging.warning("Site ignoré dans %s : nom invalide %r", TABLE_SITES_SURVEILLANCE, lieu)
continue
if actif == "ON":
tables_actives.append(lieu)
else:
if commentaire:
logging.info("⏸️ %s ignoré : surveillance OFF (%s)", lieu, commentaire)
else:
logging.info("⏸️ %s ignoré : surveillance OFF", lieu)
if not tables_actives:
logging.warning(
"Aucun site actif dans %s. Utilisation du fallback : %s",
TABLE_SITES_SURVEILLANCE,
", ".join(TABLES_FALLBACK),
)
return TABLES_FALLBACK.copy()
logging.info("Sites surveillés : %s", ", ".join(tables_actives))
return tables_actives
# ============================================================
# TRAITEMENT DES TABLES
# ============================================================
def traiter_table(cursor, table: str, limite: datetime,
tables_autorisees: set[str],
defauts_en_cours: list[str],
alertes_envoyees: list[str],
erreurs_sql: list[str]) -> None:
"""
Gère la surveillance d'une table.
"""
if table not in tables_autorisees:
logging.warning(f"Table ignorée (non autorisée) : {table}")
return
if not is_safe_identifier(table):
logging.warning(f"Table ignorée (nom invalide) : {table}")
return
try:
last_update = get_last_update(cursor, table)
except mysql.connector.Error as e:
erreurs_sql.append(table)
logging.error(f"Erreur SQL sur {table} : {e}")
if not last_update or last_update < limite:
if should_send_alert(table):
problemes.append(f"{table} (dernier relevé : {last_update})")
try:
envoyer_notifications(
table,
f"⚠️ ALERTE : erreur SQL sur {table}",
f"Erreur SQL détectée sur la table {table}.\n\nDétail :\n{e}"
)
enter_alert_state(table, None)
alertes_envoyees.append(f"{table} (SQL)")
except Exception as notif_e:
logging.error(f"Impossible d'envoyer les notifications SQL pour {table} : {notif_e}")
else:
logging.info(f"Problème déjà signalé pour {table}, attente du délai de rappel.")
logging.info(f"{table} : erreur SQL déjà signalée, rappel dans {RAPPEL_HEURES}h.")
return
# Cas défaut : aucune donnée ou donnée trop ancienne
if (last_update is None) or (last_update < limite):
defauts_en_cours.append(table)
if should_send_alert(table):
logging.warning(f"⚠️ {table} en défaut (dernier relevé : {last_update})")
try:
envoyer_notifications(
table,
f"⚠️ ALERTE : {table} absence de relevés",
f"Pas de relevés depuis plus de {DELAI_MINUTES} min.\nDernier relevé : {last_update}"
)
enter_alert_state(table, last_update)
alertes_envoyees.append(f"{table} (dernier : {last_update})")
except Exception as notif_e:
logging.error(f"Erreur envoi notifications alerte pour {table} : {notif_e}")
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)
logging.info(f"{table} déjà signalé, rappel dans {RAPPEL_HEURES}h.")
return
# Cas normal : de nouvelles données sont présentes
was_alerting = is_alerting(table)
previous_state = read_state(table)
previous_last_data = previous_state.get("last_data_at")
current_last_data = dt_to_iso(last_update)
# on mémorise la dernière donnée vue, même en état normal
update_last_data(table, last_update)
# retour à la normale seulement si on sort réellement d'un état d'alerte
# et qu'une donnée plus récente est arrivée
if was_alerting and current_last_data != previous_last_data:
message = f"{table} : relevés à nouveau reçus (dernier : {last_update}). Situation normale."
try:
envoyer_notifications(
table,
f"✅ OK : {table} relevés reçus",
message
)
clear_state(table)
logging.info(f"📩 SMS de retour à la normale envoyé pour {table}.")
logging.info(f"📩 Retour à la normale envoyé pour {table}.")
except Exception as notif_e:
logging.error(f"Erreur envoi notifications retour à la normale pour {table} : {notif_e}")
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)
# ============================================================
# MAIN
# ============================================================
def main() -> None:
limite = datetime.now() - timedelta(minutes=DELAI_MINUTES)
defauts_en_cours: list[str] = []
alertes_envoyees: list[str] = []
erreurs_sql: list[str] = []
try:
cnx = cast(MySQLConnection, utils_db.connect_to_mysql())
with closing(cnx):
cursor = cast(MySQLCursor, cnx.cursor())
with closing(cursor):
tables_a_surveiller = get_tables_a_surveiller(cursor)
tables_autorisees = set(tables_a_surveiller)
for table in tables_a_surveiller:
traiter_table(
cursor=cursor,
table=table,
limite=limite,
tables_autorisees=tables_autorisees,
defauts_en_cours=defauts_en_cours,
alertes_envoyees=alertes_envoyees,
erreurs_sql=erreurs_sql,
)
except mysql.connector.Error as e:
logging.error(f"MySQL KO : {e}")
try:
envoyer_notifications(
"GLOBAL",
"⚠️ ALERTE : Base MySQL inaccessible",
"Connexion MySQL impossible : la surveillance des relevés ne peut pas sexécuter."
)
except Exception as notif_e:
logging.error(f"Impossible d'envoyer les notifications MySQL KO : {notif_e}")
return
if alertes_envoyees:
logging.info("📧/💬 Notification(s) envoyée(s) : " + ", ".join(alertes_envoyees))
elif defauts_en_cours or erreurs_sql:
bloc = []
if defauts_en_cours:
bloc.append("défaut(s) relevés : " + ", ".join(defauts_en_cours))
if erreurs_sql:
bloc.append("erreur(s) SQL : " + ", ".join(erreurs_sql))
logging.info("⚠️ " + " | ".join(bloc) + " (déjà signalé / pas de notification envoyée à ce run)")
else:
logging.info("👍 Tout est OK, aucun SMS envoyé.")
logging.info("👍 Tout est OK, aucune notification envoyée.")
if __name__ == "__main__":
main()

View File

@@ -1,285 +0,0 @@
# tracker_app.py
# -------------------------------------------------------------
# Streamlit — Gestion de la table MySQL Sondes.tracker
# -------------------------------------------------------------
# Schéma attendu : id (PK), address, lieu, repere (optionnel), res_bits, date (timestamp)
# -------------------------------------------------------------
import os
import re
import time
import pandas as pd
import streamlit as st
import mysql.connector as mysql
from contextlib import contextmanager
from dotenv import load_dotenv
# ==========================
# Configuration / Constantes
# ==========================
TABLE_DB = os.getenv("MYSQL_DB", "Sondes")
TABLE_NAME = "tracker"
COL_ID = "id"
COL_ADDRESS = "address"
COL_LIEU = "lieu"
COL_REPERE = "repere" # 🆕 Nouveau champ
COL_RESBITS = "res_bits"
COL_DATE = "date"
# Regex d'une ROM code DS18B20 au format {0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0xCRC}
ROM_REGEX = re.compile(r"^\{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}\}$")
# Mapping résolution DS18B20 (bits -> infos)
RES_MAP = {
9: {"precision": 0.5, "tconv_ms": 94},
10: {"precision": 0.25, "tconv_ms": 188},
11: {"precision": 0.125, "tconv_ms": 375},
12: {"precision": 0.0625,"tconv_ms": 750},
}
# ==================
# Accès Base de Données
# ==================
load_dotenv() # lit .env si présent
DB_CFG = dict(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
database=os.getenv("DB_NAME"),
port=int(os.getenv("MYSQL_PORT", "3306")),
)
@contextmanager
def get_conn():
conn = mysql.connect(**DB_CFG)
try:
yield conn
finally:
conn.close()
# --------------
# Fonctions SQL
# --------------
def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame:
query = (
f"SELECT {COL_ID}, {COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_RESBITS}, {COL_DATE} "
f"FROM {TABLE_NAME}"
)
params = []
if where_lieu:
query += f" WHERE {COL_LIEU} = %s"
params.append(where_lieu)
query += f" ORDER BY {COL_LIEU}, {COL_REPERE}, {COL_ADDRESS}"
with get_conn() as conn:
df = pd.read_sql(query, conn, params=params)
return df
def insert_tracker(address: str, lieu: str, res_bits: int, repere: str | None = None) -> int:
sql = f"""
INSERT INTO {TABLE_NAME} ({COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_RESBITS})
VALUES (%s, %s, %s, %s)
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (address, lieu, (repere.strip() if repere and repere.strip() else None), res_bits))
conn.commit()
return cur.lastrowid
def update_tracker(row_id: int, address: str, lieu: str, res_bits: int, repere: str | None) -> None:
sql = f"""
UPDATE {TABLE_NAME}
SET {COL_ADDRESS}=%s, {COL_LIEU}=%s, {COL_REPERE}=%s, {COL_RESBITS}=%s
WHERE {COL_ID}=%s
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (address, lieu, (repere.strip() if repere and repere.strip() else None), res_bits, row_id))
conn.commit()
def delete_tracker(row_id: int) -> None:
sql = f"DELETE FROM {TABLE_NAME} WHERE {COL_ID}=%s"
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (row_id,))
conn.commit()
# --------------
# Utilitaires UI
# --------------
def is_valid_rom(address: str) -> bool:
return bool(ROM_REGEX.match(address.strip()))
def rom_help() -> str:
return (
"Format attendu : `{0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0x12}` (8 octets en hex).\n"
"Le premier octet (famille) est souvent 0x28 pour DS18B20."
)
def res_label(bits: int) -> str:
info = RES_MAP.get(bits)
if not info:
return f"{bits} bits (inconnu)"
return f"{bits} bits (±{info['precision']}°C, {info['tconv_ms']} ms)"
# ==================
# Application Streamlit
# ==================
st.set_page_config(page_title="Gestion des sondes — tracker", page_icon="🌡️", layout="wide")
st.title("🌡️ Gestion de Sondes.tracker")
with st.expander("Paramètres de connexion (lecture seule)"):
st.write({k: ("***" if k in {"password"} else v) for k, v in DB_CFG.items()})
st.caption("Configurez ces valeurs via le fichier .env (MYSQL_HOST, MYSQL_DB, MYSQL_USER, MYSQL_PASSWORD, MYSQL_PORT)")
# Barre latérale — Filtres & Actions
st.sidebar.header("Filtres & Actions")
# Récupération de la liste des lieux existants
_all = fetch_trackers()
lieux = sorted([x for x in _all[COL_LIEU].dropna().unique()]) if not _all.empty else []
lieu_selected = st.sidebar.selectbox("Filtrer par lieu", options=["(Tous)"] + lieux, index=0)
# Boutons d'export
col_exp1, col_exp2 = st.sidebar.columns(2)
with col_exp1:
if st.button("Exporter CSV"):
st.session_state["export_csv"] = True
with col_exp2:
if st.button("Recharger"):
st.cache_data.clear()
st.rerun()
# Formulaire d'ajout
st.sidebar.subheader("Ajouter une sonde")
with st.sidebar.form("add_form", clear_on_submit=True):
new_address = st.text_input("Adresse ROM", placeholder="{0x28,0xFF,...}", help=rom_help())
new_lieu = st.text_input("Lieu d'installation", placeholder="Ex: Chaufferie / Saclay")
new_repere = st.text_input("Repère (optionnel)", placeholder="Ex: R1, Panneau N°, Local 3…") # 🆕
new_res = st.selectbox("Résolution (bits)", options=[9,10,11,12])
submitted = st.form_submit_button("Ajouter")
if submitted:
if not is_valid_rom(new_address):
st.warning("Adresse ROM invalide. Voir l'aide sur le format.")
elif not new_lieu.strip():
st.warning("Lieu requis.")
else:
rid = insert_tracker(new_address.strip(), new_lieu.strip(), int(new_res), new_repere)
st.success(f"Sonde ajoutée (id={rid}).")
time.sleep(0.6)
st.rerun()
# Bouton de déconnexion (EXIT)
st.sidebar.divider()
st.sidebar.subheader("Sécurité")
if st.sidebar.button("EXIT / Déconnexion", type="primary"):
for _k in list(st.session_state.keys()):
try:
del st.session_state[_k]
except Exception:
pass
st.markdown('<meta http-equiv="refresh" content="0; url=/logout">', unsafe_allow_html=True)
st.stop()
# Vue principale (liste / édition)
if lieu_selected != "(Tous)":
df = fetch_trackers(where_lieu=lieu_selected)
else:
df = _all.copy()
if df.empty:
st.info("Aucune sonde enregistrée.")
else:
# Colonne lisible pour la résolution
df["resolution"] = df[COL_RESBITS].apply(res_label)
st.subheader("Enregistrements")
st.caption("Double-cliquez pour éditer les cellules. Les colonnes *resolution* et *date* sont non éditables.")
edited = st.data_editor(
df[[COL_ID, COL_ADDRESS, COL_LIEU, COL_REPERE, COL_RESBITS, "resolution", COL_DATE]],
hide_index=True,
column_config={
COL_ID: st.column_config.NumberColumn("ID", disabled=True),
COL_ADDRESS: st.column_config.TextColumn("Adresse (ROM)", help=rom_help()),
COL_LIEU: st.column_config.TextColumn("Lieu"),
COL_REPERE: st.column_config.TextColumn("Repère"), # 🆕 éditable
COL_RESBITS: st.column_config.NumberColumn("Résolution (bits)", min_value=9, max_value=12, step=1),
"resolution": st.column_config.TextColumn("Détails", disabled=True),
COL_DATE: st.column_config.DatetimeColumn("Date insertion", disabled=True, format="YYYY-MM-DD HH:mm:ss"),
},
use_container_width=True,
num_rows="dynamic",
)
# Détection des suppressions (si des lignes ont disparu)
removed_ids = set(df[COL_ID]) - set(edited[COL_ID])
# Détection des modifications cellule par cellule
to_update = []
for _, row in edited.iterrows():
orig = df.loc[df[COL_ID] == row[COL_ID]].iloc[0]
changed = (
(row[COL_ADDRESS] != orig[COL_ADDRESS]) or
(row[COL_LIEU] != orig[COL_LIEU]) or
((str(row.get(COL_REPERE) or "").strip()) != (str(orig.get(COL_REPERE) or "").strip())) or
(int(row[COL_RESBITS]) != int(orig[COL_RESBITS]))
)
if changed:
to_update.append(
(
int(row[COL_ID]),
str(row[COL_ADDRESS]),
str(row[COL_LIEU]),
int(row[COL_RESBITS]),
(str(row.get(COL_REPERE)).strip() if row.get(COL_REPERE) and str(row.get(COL_REPERE)).strip() else None),
)
)
# Validation des adresses modifiées
invalid_ids = [rid for (rid, addr, *_rest) in to_update if not is_valid_rom(addr)]
if invalid_ids:
st.error(f"Adresses ROM invalides pour id: {sorted(invalid_ids)}. Aucune mise à jour effectuée.")
else:
col1, col2, col3 = st.columns([1,1,2])
with col1:
if st.button("Enregistrer les modifications", disabled=(len(to_update)==0 and len(removed_ids)==0)):
for rid, addr, lieu, rbits, repere in to_update:
update_tracker(rid, addr, lieu, rbits, repere)
for rid in removed_ids:
delete_tracker(int(rid))
st.success("Modifications enregistrées ✔️")
time.sleep(0.6)
st.rerun()
with col2:
if st.button("Annuler les modifs"):
st.rerun()
with col3:
st.caption(f"À enregistrer : {len(to_update)} mise(s) à jour, {len(removed_ids)} suppression(s)")
# Export CSV si demandé
if st.session_state.get("export_csv"):
out = edited.drop(columns=["resolution"]).copy()
st.download_button(
label="Télécharger CSV",
data=out.to_csv(index=False).encode("utf-8"),
file_name="trackers_export.csv",
mime="text/csv",
)
st.session_state["export_csv"] = False
# Pied de page
st.divider()
st.caption(
"Astuce : vous pouvez coller directement une adresse ROM depuis vos logs au format {0x..,0x..,0x..,0x..,0x..,0x..,0x..,0x..}.\n"
"Si vos noms de colonnes diffèrent (ex. 'res.bits'), ajustez les constantes en tête de fichier."
)

View File

@@ -4,22 +4,24 @@ import os
load_dotenv()
def connect_to_mysql():
return mysql.connector.connect(
host=os.getenv("DB_HOST"),
user=os.getenv("DB_USER"),
password=os.getenv("DB_PASSWORD"),
password=os.getenv("DB_PASS"),
database=os.getenv("DB_NAME")
)
def get_latest_chaufferie():
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Sonde, Temperature, Date, Topic
FROM Chaufferie
FROM Sondes.Chaufferie
WHERE Date >= NOW() - INTERVAL 5 MINUTE
ORDER BY Date DESC
ORDER BY Date DESC \
"""
cursor.execute(query)
result = cursor.fetchall()
@@ -27,15 +29,16 @@ def get_latest_chaufferie():
conn.close()
return result
def get_history_by_sonde(sonde):
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Sonde, Temperature, Date
FROM Chaufferie
FROM Sondes.Chaufferie
WHERE Sonde = %s
AND Date >= NOW() - INTERVAL 1 DAY
ORDER BY Date ASC
AND Date >= NOW() - INTERVAL 1 DAY \
"""
cursor.execute(query, (sonde,))
result = cursor.fetchall()
@@ -43,14 +46,15 @@ def get_history_by_sonde(sonde):
conn.close()
return result
def lire_alertes_sondes():
conn = connect_to_mysql()
cursor = conn.cursor(dictionary=True)
query = """
SELECT Id, Sonde, Debut_defaut, Etat
FROM Alertes_Chaufferie
FROM Sondes.Alertes_Chaufferie
WHERE Etat != 'Acquitté'
ORDER BY Debut_defaut DESC
ORDER BY Debut_defaut DESC \
"""
cursor.execute(query)
result = cursor.fetchall()
@@ -58,10 +62,11 @@ def lire_alertes_sondes():
conn.close()
return result
def acquitter_alerte(id_alerte):
conn = connect_to_mysql()
cursor = conn.cursor()
query = "UPDATE Alertes_Chaufferie SET Etat = 'Acquitté' WHERE Id = %s"
query = "UPDATE Sondes.Alertes_Chaufferie SET Etat = 'Acquitté' WHERE Id = %s"
cursor.execute(query, (id_alerte,))
conn.commit()
cursor.close()

44
app/utils_mail.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import smtplib
import logging
from email.mime.text import MIMEText
from dotenv import load_dotenv
# Charge le .env (et override pour éviter des variables vides héritées de cron/supervisor)
load_dotenv('/home/debian/Gestion_sondes/.env', override=True)
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_LOGIN = os.getenv("SMTP_USER") # <-- correspond à ton .env
SMTP_PASSWORD = os.getenv("SMTP_PASS") # <-- correspond à ton .env
MAIL_FROM = os.getenv("MAIL_FROM")
MAIL_TO = os.getenv("MAIL_TO")
def envoyer_mail(sujet: str, contenu: str, destinataires=None) -> None:
if destinataires is None:
if not MAIL_TO:
raise ValueError("MAIL_TO manquant dans le .env")
destinataires = [a.strip() for a in MAIL_TO.split(",") if a.strip()]
elif isinstance(destinataires, str):
destinataires = [destinataires]
if not SMTP_HOST:
raise ValueError("SMTP_HOST manquant dans le .env")
if not SMTP_LOGIN or not SMTP_PASSWORD:
raise ValueError("SMTP_USER / SMTP_PASS manquants dans le .env")
if not MAIL_FROM:
raise ValueError("MAIL_FROM manquant dans le .env")
msg = MIMEText(contenu)
msg["Subject"] = sujet
msg["From"] = MAIL_FROM
msg["To"] = ", ".join(destinataires)
# SSL direct (OVH ssl0.ovh.net:465)
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.login(SMTP_LOGIN, SMTP_PASSWORD)
server.sendmail(MAIL_FROM, destinataires, msg.as_string())
logging.info("📧 Mail envoyé: %s -> %s", sujet, destinataires)

View File

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

View File

@@ -1,53 +0,0 @@
import streamlit as st
import os
import platform
# 🟢 CECI DOIT ÊTRE LA PREMIÈRE COMMANDE STREAMLIT !
st.set_page_config(page_title="Visualiseur de Logs", layout="wide")
# 🔧 Détection du bon dossier selon l'OS
if platform.system() == "Windows":
LOG_DIR = "C:/Users/miche/PycharmProjects/Gestion_sondes/Logs"
else:
LOG_DIR = "/home/debian/Gestion_sondes/Logs"
# Titre
st.title("🧾 Visualiseur de fichiers logs")
# Liste des fichiers .log ou similaires
try:
fichiers = sorted(
[f for f in os.listdir(LOG_DIR) if ".log" in f],
key=lambda x: os.path.getmtime(os.path.join(LOG_DIR, x)),
reverse=True
)
except FileNotFoundError:
st.error(f"📁 Dossier introuvable : {LOG_DIR}")
st.stop()
if not fichiers:
st.warning("Aucun fichier log trouvé dans le dossier.")
st.stop()
# Choix du fichier
choix = st.selectbox("📂 Sélectionnez un fichier log :", fichiers)
log_path = os.path.join(LOG_DIR, choix)
# Options d'affichage
col1, col2 = st.columns([1, 1])
with col1:
filtre_erreurs = st.checkbox("🔍 Afficher uniquement les erreurs", value=False)
with col2:
nb_lignes = st.slider("📏 Nombre de lignes à afficher", 10, 5000, 300)
# Lecture du fichier
with open(log_path, "r", encoding="utf-8", errors="ignore") as f:
lignes = f.readlines()
if filtre_erreurs:
mots_cles = ["ERROR", "", "Traceback", "failed", "exception"]
lignes = [l for l in lignes if any(m in l.lower() for m in mots_cles)]
# Affichage
dernieres = lignes[-nb_lignes:]
st.text_area("📄 Contenu du fichier log :", "".join(dernieres), height=600)

View File

@@ -6,8 +6,6 @@ This module returns the installation location of cacert.pem or its contents.
"""
import sys
DEBIAN_CA_CERTS_PATH = '/etc/ssl/certs/ca-certificates.crt'
if sys.version_info >= (3, 11):
from importlib.resources import as_file, files

Binary file not shown.

89
scripts/backup_VM.sh Normal file
View File

@@ -0,0 +1,89 @@
#!/bin/bash
set -Eeuo pipefail
DATE="$(date +'%Y-%m-%d_%H-%M')"
BACKUP_DIR="/home/domo91/backup"
LOG_DIR="/home/domo91/Gestion_sondes/Logs"
mkdir -p "$LOG_DIR" "$BACKUP_DIR"
LOG_FILE="$LOG_DIR/backup_$DATE.log"
exec > >(tee -a "$LOG_FILE") 2>&1
# Verrou anti-doublon
exec 9>/tmp/backup_mysql.lock
flock -n 9 || { echo "🔒 Un autre backup est en cours. Abandon."; exit 1; }
BACKUP_FILE="$BACKUP_DIR/mysql_backup_$DATE.sql"
# Cible NAS via WireGuard / LAN
NAS_HOST="192.168.1.250" # à adapter avec l'IP locale réelle du NAS
NAS_PORT="4422" # mettre 4422 seulement si DSM écoute réellement sur 4422 en local
NAS_USER="Michel"
NAS_DIR="/volume1/backups/VM_Debian13"
SSH_KEY="/home/domo91/.ssh/id_ed25519"
SSH_OPTS="-i $SSH_KEY -p $NAS_PORT \
-o BatchMode=yes -o PreferredAuthentications=publickey \
-o PasswordAuthentication=no -o PubkeyAuthentication=yes \
-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 \
-o ServerAliveInterval=30 -o ServerAliveCountMax=2"
# Credentials MySQL
MYSQL_DEFAULTS="/home/domo91/.my.cnf"
# PATH minimal pour cron
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
echo "🔷 Démarrage $(date '+%F %T') sur $(hostname -s)"
echo "🔷 Dossier local : $BACKUP_DIR"
echo "🔷 Dossier NAS : $NAS_DIR (hôte $NAS_HOST)"
# 1) Pré-check SSH & droits écriture NAS
echo "🔷 Test SSH NAS…"
if ! ssh $SSH_OPTS "$NAS_USER@$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then
echo "❌ Impossible d'écrire sur $NAS_HOST:$NAS_DIR (clé SSH ? user ? droits ? SSH NAS activé ?)"
exit 20
fi
# 2) Dump MySQL
echo "🔷 Dump MySQL…"
if [[ -f "$MYSQL_DEFAULTS" ]]; then
DUMP="mysqldump --defaults-file=$MYSQL_DEFAULTS --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
else
DUMP="mysqldump --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
fi
# Baisse de priorité
IONICE="$(command -v ionice >/dev/null 2>&1 && echo 'ionice -c2 -n7' || true)"
NICE="$(command -v nice >/dev/null 2>&1 && echo 'nice -n 10' || true)"
bash -c "$IONICE $NICE $DUMP > '$BACKUP_FILE'"
# Vérification locale
if [[ ! -s "$BACKUP_FILE" ]]; then
echo "❌ Fichier de backup vide : $BACKUP_FILE"
exit 21
fi
LOCAL_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || wc -c < "$BACKUP_FILE")
echo "✅ Dump OK : $BACKUP_FILE ($LOCAL_SIZE octets)"
# 3) Transfert NAS
scp -O -P 4422 -i /home/domo91/.ssh/id_ed25519 \
-o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no \
-o StrictHostKeyChecking=accept-new \
"$BACKUP_FILE" "$NAS_USER@$NAS_HOST:$NAS_DIR/"
# 4) Vérification taille distante = locale
BASENAME="$(basename "$BACKUP_FILE")"
REMOTE_SIZE=$(ssh -p 4422 -i /home/domo91/.ssh/id_ed25519 \
-o BatchMode=yes -o PreferredAuthentications=publickey -o PasswordAuthentication=no \
-o StrictHostKeyChecking=accept-new \
"$NAS_USER@$NAS_HOST" "wc -c < '$NAS_DIR/$BASENAME'" || echo 0)
if [[ "$REMOTE_SIZE" != "$LOCAL_SIZE" ]]; then
echo "❌ Taille différente après transfert (local=$LOCAL_SIZE, distant=$REMOTE_SIZE)"
exit 22
fi
echo "✅ Transfert OK → $NAS_HOST:$NAS_DIR/$BASENAME"

View File

@@ -1,37 +1,97 @@
#!/bin/bash
set -Eeuo pipefail
# Variables
DATE=$(date +'%Y-%m-%d_%H-%M')
DATE="$(date +'%Y-%m-%d_%H-%M')"
BACKUP_DIR="/home/debian/backup"
LOG_DIR="/home/debian/Gestion_sondes/Logs"
SSH_KEY="/home/debian/.ssh/id_ed25519"
mkdir -p "$LOG_DIR" "$BACKUP_DIR"
LOG_FILE="$LOG_DIR/backup_$DATE.log"
exec > "$LOG_FILE" 2>&1
exec > >(tee -a "$LOG_FILE") 2>&1
# Verrou anti-doublon
exec 9>/tmp/backup_mysql.lock
flock -n 9 || { echo "🔒 Un autre backup est en cours. Abandon."; exit 1; }
BACKUP_FILE="$BACKUP_DIR/mysql_backup_$DATE.sql"
NAS_HOST="mon-nas" # défini dans /home/debian/.ssh/config
MYSQL_USER="root"
MYSQL_PASSWORD="$%kavYKeb1EY3Vl136O&o"
NAS_DIR="/volume1/nfs/vps_gra" # chemin sur le NAS
# Création du dossier local si besoin
mkdir -p "$BACKUP_DIR"
# Cible NAS via WireGuard / LAN
NAS_HOST="192.168.1.250"
NAS_PORT="4422"
NAS_USER="Michel"
NAS_DIR="/volume1/backups/VPS_Ovh"
echo "🔷 Sauvegarde des bases MySQL..."
if mysqldump -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" --all-databases > "$BACKUP_FILE"; then
echo "✅ Sauvegarde locale terminée : $BACKUP_FILE"
else
echo "❌ Erreur lors de la sauvegarde MySQL"
exit 1
SSH_KEY="/home/debian/.ssh/id_ed25519"
SSH_OPTS="-i $SSH_KEY -p $NAS_PORT \
-o BatchMode=yes -o PreferredAuthentications=publickey \
-o PasswordAuthentication=no -o PubkeyAuthentication=yes \
-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 \
-o ServerAliveInterval=30 -o ServerAliveCountMax=2"
# Credentials MySQL
MYSQL_DEFAULTS="/home/debian/.my.cnf"
# PATH minimal pour cron
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
echo "🔷 Démarrage $(date '+%F %T') sur $(hostname -s)"
echo "🔷 Dossier local : $BACKUP_DIR"
echo "🔷 Dossier NAS : $NAS_DIR (hôte $NAS_HOST)"
# 1) Pré-check SSH & droits écriture NAS
echo "🔷 Test SSH NAS…"
if ! ssh $SSH_OPTS "$NAS_USER@$NAS_HOST" "mkdir -p '$NAS_DIR' && test -w '$NAS_DIR' && echo __SSH_OK__"; then
echo "❌ Impossible d'écrire sur $NAS_HOST:$NAS_DIR (clé SSH ? user ? droits ? SSH NAS activé ?)"
exit 20
fi
echo "🔷 Envoi de la sauvegarde vers le NAS..."
if rsync -av -e ssh "$BACKUP_FILE" "$NAS_HOST:$NAS_DIR/"; then
echo "✅ Sauvegarde transférée sur le NAS : $NAS_HOST"
# 2) Dump MySQL
echo "🔷 Dump MySQL…"
if [[ -f "$MYSQL_DEFAULTS" ]]; then
DUMP="mysqldump --defaults-extra-file=$MYSQL_DEFAULTS --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
else
echo "❌ Échec du transfert vers le NAS"
exit 1
DUMP="mysqldump --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
fi
echo "🔷 Nettoyage des sauvegardes locales de plus de 7 jours..."
find "$BACKUP_DIR" -type f -name "*.sql" -mtime +7 -exec rm -f {} \;
# Baisse de priorité
IONICE="$(command -v ionice >/dev/null 2>&1 && echo 'ionice -c2 -n7' || true)"
NICE="$(command -v nice >/dev/null 2>&1 && echo 'nice -n 10' || true)"
echo "🎉 Opération terminée avec succès !"
bash -c "$IONICE $NICE $DUMP > '$BACKUP_FILE'"
# Vérification locale
if [[ ! -s "$BACKUP_FILE" ]]; then
echo "❌ Fichier de backup vide : $BACKUP_FILE"
exit 21
fi
LOCAL_SIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || wc -c < "$BACKUP_FILE")
echo "✅ Dump OK : $BACKUP_FILE ($LOCAL_SIZE octets)"
# 3) Transfert NAS
echo "🔷 Transfert NAS…"
timeout 30m scp -O -i "$SSH_KEY" -P "$NAS_PORT" -C \
-o BatchMode=yes \
-o PreferredAuthentications=publickey \
-o PasswordAuthentication=no \
-o PubkeyAuthentication=yes \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=10 \
"$BACKUP_FILE" "$NAS_USER@$NAS_HOST:$NAS_DIR/"
echo "✅ Copie scp terminée"
# 4) Vérification taille distante = locale
BASENAME="$(basename "$BACKUP_FILE")"
echo "🔷 Vérification taille distante…"
REMOTE_SIZE=$(ssh $SSH_OPTS "$NAS_USER@$NAS_HOST" "stat -c%s '$NAS_DIR/$BASENAME' 2>/dev/null || wc -c < '$NAS_DIR/$BASENAME'" || echo 0)
REMOTE_SIZE="$(echo "$REMOTE_SIZE" | tr -dc '0-9')"
echo "🔷 Taille distante : $REMOTE_SIZE octets"
if [[ "$REMOTE_SIZE" != "$LOCAL_SIZE" ]]; then
echo "❌ Taille différente après transfert (local=$LOCAL_SIZE, distant=$REMOTE_SIZE)"
exit 22
fi
echo "✅ Transfert OK → $NAS_HOST:$NAS_DIR/$BASENAME"

View File

@@ -1,32 +0,0 @@
<?php
/**
* Lists and displays the details for each SMS account
*
* Go to https://eu.api.ovh.com/createToken/index.cgi?GET=/sms/&GET=/sms/*/jobs&POST=/sms/*/jobs
* to generate the API access keys for:
*
* GET /sms
* GET /sms/*/jobs
* POST /sms/*/jobs
*/
require __DIR__ . '/vendor/autoload.php';
use \Ovh\Api;
$endpoint = 'ovh-eu';
$applicationKey = "f725d07b2f98a195";
$applicationSecret = "5ca392a0a728e2395edd426bb1e11ad6";
$consumer_key = "305f2e8611e58b83930de84ee65c99f9";
$conn = new Api( $applicationKey,
$applicationSecret,
$endpoint,
$consumer_key);
$smsServices = $conn->get('/sms/');
foreach ($smsServices as $smsService) {
print_r($smsService);
}
?>

View File

@@ -0,0 +1,12 @@
#!/bin/bash
cd /home/domo91/Gestion_sondes || exit 1
echo "Mise à jour depuis Gitea..."
git pull --ff-only origin master
echo "Redémarrage de l'application..."
sudo supervisorctl restart Interface
echo "Statut Supervisor :"
sudo supervisorctl status