Compare commits
43 Commits
6592e98bee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fbad70cbc | |||
| 91145b9976 | |||
| d0037abf53 | |||
| 974e7f6669 | |||
| 272ad6d80a | |||
| b00879cdfa | |||
| c0b0770ddf | |||
| f1203012df | |||
| 5f9d1c0911 | |||
| 7fd74a8ce4 | |||
| 2fd850e426 | |||
| 6c3772266c | |||
| 56ee03dc94 | |||
| 92b57df303 | |||
| b4c2ca8400 | |||
| d54832e558 | |||
| a0b6d22727 | |||
| 0c3457f30a | |||
| 37a00a64b5 | |||
| dca69728e0 | |||
| 367c6a10b8 | |||
| e9b85bcf40 | |||
| e1f91660cf | |||
| e5896ad32f | |||
| 4bd43a3611 | |||
| 807f2d318b | |||
| d776f1bf12 | |||
| 072a0cbbc5 | |||
| 9131758db7 | |||
| bb461a2ed1 | |||
| 90aab548d4 | |||
| 14b165ff06 | |||
| 2fa848b4a7 | |||
| 511e377dc8 | |||
| 35a7d13d02 | |||
| 72be72a8aa | |||
| 5b6c31392f | |||
| 7aa7fa2dfe | |||
| ce41a0ef2c | |||
| b5a692d2bd | |||
| 783fa3d97b | |||
| 088e7a4821 | |||
| cd6bb7b5cf |
90
.env
90
.env
@@ -1,26 +1,72 @@
|
|||||||
#connexion mysql
|
# connexion mysql
|
||||||
DB_HOST=162.19.78.131
|
DB_HOST=162.19.78.131
|
||||||
DB_USER=excel_auth
|
DB_USER=sondes
|
||||||
DB_PASSWORD=%n#%3Lay1MPa$%kR^5@
|
DB_PASS=TX.)-U1!zq5Axdk4
|
||||||
DB_NAME=Sondes
|
DB_NAME=Sondes
|
||||||
|
|
||||||
# paramètres mail
|
DB_USER2=journal_connexions
|
||||||
SMTP_HOST=smtp.mail.ovh.net
|
DB_PASS2=wQ%geAx*2%%HiE2a!9S
|
||||||
SMTP_PORT=465
|
DB_NAME2=Acces
|
||||||
EMAIL_FROM=services@domo91.fr
|
|
||||||
EMAIL_PASSWORD=6ZiCsVtSf9@nEHv@$^0
|
|
||||||
EMAIL_DESTINATAIRES=services@domo91.fr
|
|
||||||
|
|
||||||
# connexion OVH pour les SMS
|
AUTH_USERS=[{"user":"Michel","pass":"210462"}]
|
||||||
OVH_APP_KEY=f725d07b2f98a195
|
|
||||||
OVH_APP_SECRET=5ca392a0a728e2395edd426bb1e11ad6
|
# --- Auth admin de l’app users ---
|
||||||
OVH_CONSUMER_KEY=305f2e8611e58b83930de84ee65c99f9
|
ADMIN_USER=Michel
|
||||||
OVH_ENDPOINT=ovh-eu
|
DB_USER3=excel
|
||||||
OVH_SMS_SENDER=DOMO91FR
|
DB_PASS3='%n#%3Lay1MPa$%kR^5@'
|
||||||
OVH_SERVICE_NAME=sms-jm164396-1
|
DB_NAME3=Acces
|
||||||
SMS_RECEIVER=+33635164680
|
ADMIN_PASSWORD=Gabrielle
|
||||||
OVH_PASSWORD=w*j&A2j*QT^HL6
|
ADMIN_PASS_HASH='$2b$12$Dgv7jNLJuR.3hQminSVE9OP6hCSmW4nISArR3HF5LTPGFK0Zw29N2'
|
||||||
ENVOI_SMS=1
|
|
||||||
PHONE_SACLAY=+33682069405,+33650270939
|
# MQTT
|
||||||
PHONE_MEUDON=+33666271128
|
MQTT_HOST=162.19.78.131
|
||||||
PHONE_ADMIN=+33635164680
|
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>"
|
||||||
1
.idea/Gestion sondes.iml
generated
1
.idea/Gestion sondes.iml
generated
@@ -3,6 +3,7 @@
|
|||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
<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" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.13 (Gestion_sondes)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.13 (Gestion_sondes)" jdkType="Python SDK" />
|
||||||
|
|||||||
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@@ -7,4 +7,7 @@
|
|||||||
<component name="PyPackaging">
|
<component name="PyPackaging">
|
||||||
<option name="earlyReleasesAsUpgrades" value="true" />
|
<option name="earlyReleasesAsUpgrades" value="true" />
|
||||||
</component>
|
</component>
|
||||||
|
<component name="PythonCompatibilityInspectionAdvertiser">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
</project>
|
</project>
|
||||||
1
.idea/modules.xml
generated
1
.idea/modules.xml
generated
@@ -3,6 +3,7 @@
|
|||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Gestion sondes.iml" filepath="$PROJECT_DIR$/.idea/Gestion sondes.iml" />
|
<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>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
@@ -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
|
|
||||||
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
BIN
Outils/asset/logo.png
Normal file
BIN
Outils/asset/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
342
Outils/tracker.py
Normal file
342
Outils/tracker.py
Normal 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
637
Outils/users.py
Normal 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 d’e-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 d’utilisateur : {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 d’utilisateur</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 l’utilisateur 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 l’administrateur.\n"
|
||||||
|
f"Ancienne valeur : {ov}\n"
|
||||||
|
f"Nouvelle valeur : {nv}\n\n"
|
||||||
|
f"Si vous n’êtes pas à l’origine 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 l’administrateur.</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Ancienne valeur :</b> {ov}</li>
|
||||||
|
<li><b>Nouvelle valeur :</b> {nv}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Si vous n’êtes pas à l’origine 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}")
|
||||||
342
app/Gyrophare.py
Normal file
342
app/Gyrophare.py
Normal 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()
|
||||||
@@ -1,43 +1,49 @@
|
|||||||
Date;Lieu;Sonde;Température;Seuil;État
|
Date;Lieu;Sonde;Température;Seuil;État
|
||||||
2025-08-23 13:08:36;Saclay;Congelateur;-16.75;-15.0;Normal
|
2025-09-02 09:29:07;Saclay;Congelateur;-18.50;-15.0;Normal
|
||||||
2025-08-23 13:03:33;Saclay;Congelateur;-17.50;-15.0;Normal
|
2025-09-02 09:24:04;Saclay;Congelateur;-19.00;-15.0;Normal
|
||||||
2025-08-23 12:58:31;Saclay;Congelateur;-16.25;-15.0;Normal
|
2025-09-02 09:19:01;Saclay;Congelateur;-18.50;-15.0;Normal
|
||||||
2025-08-23 12:53:28;Saclay;Congelateur;-17.00;-15.0;Normal
|
2025-09-02 09:13:59;Saclay;Congelateur;-17.75;-15.0;Normal
|
||||||
2025-08-23 12:48:26;Saclay;Congelateur;-17.75;-15.0;Normal
|
2025-09-02 09:08:56;Saclay;Congelateur;-18.25;-15.0;Normal
|
||||||
2025-08-23 12:43:23;Saclay;Congelateur;-16.25;-15.0;Normal
|
2025-09-02 09:03:54;Saclay;Congelateur;-18.75;-15.0;Normal
|
||||||
2025-08-23 13:08:36;Saclay;BOF;2.00;8.0;Normal
|
2025-09-02 09:29:07;Saclay;BOF;2.50;8.0;Normal
|
||||||
2025-08-23 13:03:34;Saclay;BOF;1.25;8.0;Normal
|
2025-09-02 09:24:05;Saclay;BOF;0.75;8.0;Normal
|
||||||
2025-08-23 12:58:31;Saclay;BOF;1.00;8.0;Normal
|
2025-09-02 09:19:02;Saclay;BOF;2.00;8.0;Normal
|
||||||
2025-08-23 12:53:29;Saclay;BOF;2.00;8.0;Normal
|
2025-09-02 09:13:59;Saclay;BOF;2.00;8.0;Normal
|
||||||
2025-08-23 12:48:26;Saclay;BOF;0.50;8.0;Normal
|
2025-09-02 09:08:57;Saclay;BOF;0.75;8.0;Normal
|
||||||
2025-08-23 12:43:23;Saclay;BOF;2.50;8.0;Normal
|
2025-09-02 09:03:54;Saclay;BOF;1.75;8.0;Normal
|
||||||
2025-08-23 13:08:37;Saclay;Legumes;5.25;10.0;Normal
|
2025-09-02 09:29:08;Saclay;Viandes;2.75;6.0;Normal
|
||||||
2025-08-23 13:03:35;Saclay;Legumes;4.75;10.0;Normal
|
2025-09-02 09:24:05;Saclay;Viandes;2.00;6.0;Normal
|
||||||
2025-08-23 12:58:32;Saclay;Legumes;3.75;10.0;Normal
|
2025-09-02 09:19:02;Saclay;Viandes;4.75;6.0;Normal
|
||||||
2025-08-23 12:53:30;Saclay;Legumes;5.50;10.0;Normal
|
2025-09-02 09:14:00;Saclay;Viandes;4.25;6.0;Normal
|
||||||
2025-08-23 12:48:27;Saclay;Legumes;3.00;10.0;Normal
|
2025-09-02 09:08:57;Saclay;Viandes;3.75;6.0;Normal
|
||||||
2025-08-23 12:43:24;Saclay;Legumes;6.25;10.0;Normal
|
2025-09-02 09:03:55;Saclay;Viandes;2.50;6.0;Normal
|
||||||
2025-08-23 13:08:38;Saclay;MeP;4.75;8.0;Normal
|
2025-09-02 09:29:08;Saclay;Legumes;5.00;10.0;Normal
|
||||||
2025-08-23 13:03:35;Saclay;MeP;3.50;8.0;Normal
|
2025-09-02 09:24:06;Saclay;Legumes;4.50;10.0;Normal
|
||||||
2025-08-23 12:58:33;Saclay;MeP;3.75;8.0;Normal
|
2025-09-02 09:19:03;Saclay;Legumes;5.00;10.0;Normal
|
||||||
2025-08-23 12:53:30;Saclay;MeP;4.50;8.0;Normal
|
2025-09-02 09:14:00;Saclay;Legumes;5.50;10.0;Normal
|
||||||
2025-08-23 12:48:28;Saclay;MeP;3.00;8.0;Normal
|
2025-09-02 09:08:58;Saclay;Legumes;4.25;10.0;Normal
|
||||||
2025-08-23 12:43:25;Saclay;MeP;5.50;8.0;Normal
|
2025-09-02 09:03:55;Saclay;Legumes;5.75;10.0;Normal
|
||||||
2025-08-23 13:04:25;Meudon;Viandes;4.31;6.0;Normal
|
2025-09-02 09:29:09;Saclay;MeP;6.50;8.0;Normal
|
||||||
2025-08-23 12:59:24;Meudon;Viandes;4.13;6.0;Normal
|
2025-09-02 09:24:06;Saclay;MeP;3.00;8.0;Normal
|
||||||
2025-08-23 12:54:24;Meudon;Viandes;3.94;6.0;Normal
|
2025-09-02 09:19:03;Saclay;MeP;5.75;8.0;Normal
|
||||||
2025-08-23 12:49:23;Meudon;Viandes;3.75;6.0;Normal
|
2025-09-02 09:14:01;Saclay;MeP;7.25;8.0;Normal
|
||||||
2025-08-23 12:44:22;Meudon;Viandes;3.94;6.0;Normal
|
2025-09-02 09:08:58;Saclay;MeP;4.00;8.0;Normal
|
||||||
2025-08-23 12:39:21;Meudon;Viandes;4.13;6.0;Normal
|
2025-09-02 09:03:56;Saclay;MeP;4.25;8.0;Normal
|
||||||
2025-08-23 13:04:25;Meudon;Poissons;4.56;6.0;Normal
|
2025-09-02 09:30:38;Meudon;Viandes;4.69;6.0;Normal
|
||||||
2025-08-23 12:59:24;Meudon;Poissons;3.94;6.0;Normal
|
2025-09-02 09:25:37;Meudon;Viandes;5.38;6.0;Normal
|
||||||
2025-08-23 12:54:24;Meudon;Poissons;3.94;6.0;Normal
|
2025-09-02 09:20:36;Meudon;Viandes;5.25;6.0;Normal
|
||||||
2025-08-23 12:49:23;Meudon;Poissons;3.88;6.0;Normal
|
2025-09-02 09:15:36;Meudon;Viandes;4.88;6.0;Normal
|
||||||
2025-08-23 12:44:22;Meudon;Poissons;3.75;6.0;Normal
|
2025-09-02 09:10:35;Meudon;Viandes;4.69;6.0;Normal
|
||||||
2025-08-23 12:39:21;Meudon;Poissons;3.75;6.0;Normal
|
2025-09-02 09:05:34;Meudon;Viandes;4.44;6.0;Normal
|
||||||
2025-08-23 13:04:25;Meudon;BOF;2.00;8.0;Normal
|
2025-09-02 09:30:38;Meudon;Poissons;4.06;6.0;Normal
|
||||||
2025-08-23 12:59:24;Meudon;BOF;2.00;8.0;Normal
|
2025-09-02 09:25:37;Meudon;Poissons;4.13;6.0;Normal
|
||||||
2025-08-23 12:54:24;Meudon;BOF;2.25;8.0;Normal
|
2025-09-02 09:20:36;Meudon;Poissons;4.13;6.0;Normal
|
||||||
2025-08-23 12:49:23;Meudon;BOF;2.50;8.0;Normal
|
2025-09-02 09:15:36;Meudon;Poissons;3.94;6.0;Normal
|
||||||
2025-08-23 12:44:22;Meudon;BOF;2.50;8.0;Normal
|
2025-09-02 09:10:35;Meudon;Poissons;3.94;6.0;Normal
|
||||||
2025-08-23 12:39:21;Meudon;BOF;2.75;8.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
|
||||||
|
|||||||
|
147
app/Monitor.py
147
app/Monitor.py
@@ -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
1012
app/Monitor_Meudon.py
Normal file
File diff suppressed because it is too large
Load Diff
946
app/Monitor_Saclay.py
Normal file
946
app/Monitor_Saclay.py
Normal 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 l’alarme persiste, OFF quand retour normal confirmé.
|
||||||
|
- MODE PULSE : ON puis OFF pendant cooldown tant que l’alarme 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 d’alerte)")
|
||||||
|
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 l’alerte 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
228
app/Monitor_connexions.py
Normal 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
274
app/Mqtt_Meudon.py
Normal 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
166
app/Mqtt_saclay.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Mqtt_saclay.py
|
||||||
|
Récupère les mesures MQTT du site Saclay et les insère dans la table Sondes.Saclay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
import mysql.connector
|
||||||
|
from mysql.connector import Error
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from paho.mqtt.client import CallbackAPIVersion
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# =========================
|
||||||
|
# Chargement du .env
|
||||||
|
# =========================
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("DB_HOST")
|
||||||
|
DB_USER = os.getenv("DB_USER")
|
||||||
|
DB_PASS = os.getenv("DB_PASS")
|
||||||
|
DB_NAME = os.getenv("DB_NAME")
|
||||||
|
|
||||||
|
MQTT_HOST = "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
20
app/Test_Chat.py
Normal 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))
|
||||||
@@ -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
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
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
BIN
app/assets/qr_domo91.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
82
app/db.py
82
app/db.py
@@ -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 l’historique des températures d’une 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()
|
|
||||||
1085
app/domo91.py
1085
app/domo91.py
File diff suppressed because it is too large
Load Diff
@@ -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}")
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import paho.mqtt.client as mqtt_client
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import logging
|
|
||||||
from logger_config import setup_logger
|
|
||||||
from utils_db import connect_to_mysql
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
def on_message(table_sql, _client, _userdata, msg):
|
|
||||||
try:
|
|
||||||
logging.info(f"Message reçu sur {msg.topic}: {msg.payload.decode()}")
|
|
||||||
cursor = mydb.cursor()
|
|
||||||
sonde_name = '/'.join(msg.topic.split('/')[1:])
|
|
||||||
sql = f"INSERT INTO {table_sql} (Sonde, Temperature) VALUES (%s, %s)"
|
|
||||||
val = (sonde_name, msg.payload.decode())
|
|
||||||
cursor.execute(sql, val)
|
|
||||||
mydb.commit()
|
|
||||||
logging.info(f"Insertion réussie : {val}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erreur lors de l'insertion du message : {e}")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("--log", required=True, help="Nom du fichier de log")
|
|
||||||
parser.add_argument("--table", required=True, help="Nom complet de la table SQL")
|
|
||||||
parser.add_argument("--topic", required=True, help="Topic MQTT à écouter")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# 📋 Initialiser le logger
|
|
||||||
setup_logger(args.log)
|
|
||||||
|
|
||||||
# 🔑 Charger les variables d'environnement
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# 🔌 Connexion MySQL
|
|
||||||
mydb = connect_to_mysql()
|
|
||||||
|
|
||||||
# 📡 Connexion MQTT
|
|
||||||
try:
|
|
||||||
client = mqtt_client.Client()
|
|
||||||
client.username_pw_set("Bwps", "scJ5ACj2keRfI^")
|
|
||||||
client.on_message = partial(on_message, args.table)
|
|
||||||
client.connect("54.36.188.119", 1883, 60)
|
|
||||||
client.subscribe(args.topic)
|
|
||||||
logging.info(f"Connexion MQTT réussie et abonnement au topic '{args.topic}'.")
|
|
||||||
client.loop_forever()
|
|
||||||
except Exception as err:
|
|
||||||
logging.error(f"Erreur MQTT : {err}")
|
|
||||||
253
app/mqtt_watchdog.py
Normal file
253
app/mqtt_watchdog.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formatdate
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from dotenv import load_dotenv; load_dotenv()
|
||||||
|
|
||||||
|
# ---------- Configuration par défaut ----------
|
||||||
|
DEFAULT_BROKER_HOST = os.getenv("MQTT_HOST")
|
||||||
|
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 d’alerter
|
||||||
|
return None
|
||||||
|
delta = now - self.last_seen
|
||||||
|
if delta > self.threshold:
|
||||||
|
if not self.alert_sent:
|
||||||
|
self.alert_sent = True
|
||||||
|
return ("OUTAGE",
|
||||||
|
f"{self.name} : plus de données depuis {fmt_local(self.last_seen)} "
|
||||||
|
f"(écoulé: {int(delta.total_seconds()//60)} min).")
|
||||||
|
else:
|
||||||
|
if self.alert_sent:
|
||||||
|
self.alert_sent = False
|
||||||
|
return ("RECOVERY",
|
||||||
|
f"{self.name} : flux rétabli, dernier message à {fmt_local(self.last_seen)}.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class MqttWatchdog:
|
||||||
|
def __init__(self, broker_host, broker_port, user, pwd, topics, threshold_min, check_every_s):
|
||||||
|
# API callbacks v2 (évite le DeprecationWarning)
|
||||||
|
self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2)
|
||||||
|
self.client.username_pw_set(user, pwd)
|
||||||
|
self.client.on_connect = self._on_connect
|
||||||
|
self.client.on_message = self._on_message
|
||||||
|
self.client.on_disconnect = self._on_disconnect
|
||||||
|
|
||||||
|
self.broker_host = broker_host
|
||||||
|
self.broker_port = broker_port
|
||||||
|
self.topics = topics # liste de tuples (topic, qos)
|
||||||
|
self.check_every_s = check_every_s
|
||||||
|
|
||||||
|
# Statuts par site, déduits du préfixe: "Meudon/#" -> "Meudon"
|
||||||
|
self.sites: dict[str, SiteStatus] = {}
|
||||||
|
for t, _q in topics:
|
||||||
|
site = t.split("/", 1)[0]
|
||||||
|
self.sites[site] = SiteStatus(site, threshold_min)
|
||||||
|
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._checker_thread = threading.Thread(target=self._checker_loop, daemon=True)
|
||||||
|
|
||||||
|
# MQTT callbacks (API v2)
|
||||||
|
def _on_connect(self, client, userdata, flags, reason_code, properties=None):
|
||||||
|
if reason_code == 0:
|
||||||
|
logging.info("Connecté au broker MQTT.")
|
||||||
|
for t, q in self.topics:
|
||||||
|
client.subscribe(t, qos=q)
|
||||||
|
logging.info(f"Abonné à '{t}' (QoS {q})")
|
||||||
|
else:
|
||||||
|
logging.error(f"Echec connexion MQTT (reason_code={reason_code})")
|
||||||
|
|
||||||
|
def _on_disconnect(self, client, userdata, reason_code, properties=None):
|
||||||
|
logging.warning(f"MQTT déconnecté (reason_code={reason_code}). Reconnexion auto gérée par loop_* si activée.")
|
||||||
|
|
||||||
|
def _on_message(self, client, userdata, msg):
|
||||||
|
# Topic attendu: "Meudon/..." ou "Saclay/..."
|
||||||
|
site = msg.topic.split("/", 1)[0]
|
||||||
|
if site in self.sites:
|
||||||
|
self.sites[site].seen_now()
|
||||||
|
logging.debug(f"{site}: message reçu, mise à jour last_seen.")
|
||||||
|
|
||||||
|
# Thread de vérification périodique
|
||||||
|
def _checker_loop(self):
|
||||||
|
while not self._stop.is_set():
|
||||||
|
for site, status in self.sites.items():
|
||||||
|
event = status.check_and_alert()
|
||||||
|
if event:
|
||||||
|
kind, text = event
|
||||||
|
if kind == "OUTAGE":
|
||||||
|
subject = f"[ALERTE] {site} inactif > seuil"
|
||||||
|
body = (f"Watchdog MQTT : {text}\n"
|
||||||
|
f"Seuil: {status.threshold} | Vérif {self.check_every_s}s\n"
|
||||||
|
f"Broker: {self.broker_host}:{self.broker_port}")
|
||||||
|
notify(subject, body)
|
||||||
|
logging.warning(text)
|
||||||
|
elif kind == "RECOVERY":
|
||||||
|
subject = f"[OK] {site} rétabli"
|
||||||
|
body = (f"Watchdog MQTT : {text}\n"
|
||||||
|
f"Seuil: {status.threshold} | Vérif {self.check_every_s}s\n"
|
||||||
|
f"Broker: {self.broker_host}:{self.broker_port}")
|
||||||
|
notify(subject, body)
|
||||||
|
logging.info(text)
|
||||||
|
self._stop.wait(self.check_every_s)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.client.connect(self.broker_host, self.broker_port, keepalive=60)
|
||||||
|
self.client.loop_start() # thread interne MQTT + reconnexions auto
|
||||||
|
self._checker_thread.start()
|
||||||
|
logging.info("Watchdog démarré.")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop.set()
|
||||||
|
self._checker_thread.join(timeout=2)
|
||||||
|
self.client.loop_stop()
|
||||||
|
self.client.disconnect()
|
||||||
|
|
||||||
|
# ---------- Main ----------
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser(description="Watchdog MQTT par site (inactivité > seuil)")
|
||||||
|
p.add_argument("--log", help="Chemin du fichier log (sinon stdout).")
|
||||||
|
p.add_argument("--broker-host", default=DEFAULT_BROKER_HOST)
|
||||||
|
p.add_argument("--broker-port", type=int, default=DEFAULT_BROKER_PORT)
|
||||||
|
p.add_argument("--mqtt-user", default=DEFAULT_MQTT_USER)
|
||||||
|
p.add_argument("--mqtt-pass", default=DEFAULT_MQTT_PASS)
|
||||||
|
p.add_argument("--threshold-min", type=int, default=15, help="Seuil d'inactivité en minutes")
|
||||||
|
p.add_argument("--check-every-s", type=int, default=60, help="Périodicité de vérification en secondes")
|
||||||
|
p.add_argument("--topics", default="Meudon/#,Saclay/#", help="liste de topics 'Site/#' séparés par des virgules")
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = parse_args()
|
||||||
|
setup_logger(args.log)
|
||||||
|
|
||||||
|
topics = []
|
||||||
|
for t in [x.strip() for x in args.topics.split(",") if x.strip()]:
|
||||||
|
topics.append((t, 1))
|
||||||
|
|
||||||
|
watchdog = MqttWatchdog(
|
||||||
|
broker_host=args.broker_host,
|
||||||
|
broker_port=args.broker_port,
|
||||||
|
user=args.mqtt_user,
|
||||||
|
pwd=args.mqtt_pass,
|
||||||
|
topics=topics,
|
||||||
|
threshold_min=args.threshold_min,
|
||||||
|
check_every_s=args.check_every_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
watchdog.start()
|
||||||
|
while True:
|
||||||
|
time.sleep(3600)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Erreur fatale: {e}")
|
||||||
|
finally:
|
||||||
|
watchdog.stop()
|
||||||
|
logging.info("Watchdog arrêté.")
|
||||||
@@ -36,7 +36,7 @@ if envoyer_mail:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP_SSL("smtp.mail.ovh.net", 465) as server:
|
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())
|
server.sendmail(msg["From"], [msg["To"]], msg.as_string())
|
||||||
print("📧 Mail envoyé.")
|
print("📧 Mail envoyé.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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 datetime import datetime, timedelta
|
||||||
from dotenv import load_dotenv
|
|
||||||
import os
|
from pathlib import Path
|
||||||
import utils_db
|
import json
|
||||||
import logging
|
import logging
|
||||||
from utils_sms import envoyer_sms
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
# Dossier Logs
|
import mysql.connector
|
||||||
LOG_DIR = '/home/debian/Gestion_sondes/Logs'
|
from contextlib import closing
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
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)
|
import utils_db
|
||||||
log_filename = os.path.join(LOG_DIR, datetime.now().strftime("surveillance_%Y-%m-%d.log"))
|
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_DIR = Path(__file__).resolve().parent # .../Gestion_sondes/app
|
||||||
APP_KEY = os.getenv('OVH_APP_KEY')
|
ROOT_DIR = APP_DIR.parent # .../Gestion_sondes
|
||||||
APP_SECRET = os.getenv('OVH_APP_SECRET')
|
ENV_PATH = ROOT_DIR / ".env"
|
||||||
CONSUMER_KEY = os.getenv('OVH_CONSUMER_KEY')
|
LOG_DIR = ROOT_DIR / "Logs"
|
||||||
SERVICE_NAME = os.getenv('OVH_SERVICE_NAME')
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
SMS_RECEIVER = os.getenv('SMS_RECEIVER')
|
|
||||||
SMS_SENDER = os.getenv('OVH_SMS_SENDER')
|
# 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
|
DELAI_MINUTES = 15
|
||||||
RAPPEL_HEURES = 6
|
RAPPEL_HEURES = 6
|
||||||
|
|
||||||
STATE_DIR = '/tmp/surveillance_states'
|
def _env_str(name: str, default: str = "") -> str:
|
||||||
os.makedirs(STATE_DIR, exist_ok=True)
|
return (os.getenv(name, default) or "").strip()
|
||||||
|
|
||||||
def should_send_alert(site):
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
state_file = os.path.join(STATE_DIR, f'{site}.state')
|
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()
|
now = datetime.now()
|
||||||
if not os.path.exists(state_file):
|
state["status"] = "alerting"
|
||||||
with open(state_file, 'w') as f:
|
state["first_alert_at"] = state["first_alert_at"] or now.isoformat()
|
||||||
f.write(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
|
return True
|
||||||
with open(state_file, 'r') as f:
|
|
||||||
last_alert = datetime.fromisoformat(f.read().strip())
|
last_alert = iso_to_dt(state.get("last_alert_at"))
|
||||||
if now - last_alert >= timedelta(hours=RAPPEL_HEURES):
|
if last_alert is None:
|
||||||
with open(state_file, 'w') as f:
|
|
||||||
f.write(now.isoformat())
|
|
||||||
return True
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
def clear_state(site):
|
return (datetime.now() - last_alert) >= timedelta(hours=RAPPEL_HEURES)
|
||||||
state_file = os.path.join(STATE_DIR, f'{site}.state')
|
|
||||||
if os.path.exists(state_file):
|
|
||||||
os.remove(state_file)
|
|
||||||
|
|
||||||
def main():
|
|
||||||
cnx = utils_db.connect_to_mysql() # ← appel via db_utils
|
|
||||||
cursor = cnx.cursor()
|
|
||||||
|
|
||||||
now = datetime.now()
|
# ============================================================
|
||||||
limite = now - timedelta(minutes=DELAI_MINUTES)
|
# NOTIFICATIONS
|
||||||
problemes = []
|
# ============================================================
|
||||||
|
|
||||||
for table in tables:
|
def envoyer_chat(site: str, titre: str, message: str) -> None:
|
||||||
cursor.execute(f"SELECT MAX(Date) FROM {table}")
|
webhook = (
|
||||||
result = cursor.fetchone()
|
_env_str(f"SYNO_CHAT_WEBHOOK_MONITOR_{site}") or
|
||||||
last_update = result[0]
|
_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):
|
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:
|
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:
|
else:
|
||||||
if os.path.exists(os.path.join(STATE_DIR, f'{table}.state')):
|
logging.info(f"⏳ {table} déjà signalé, rappel dans {RAPPEL_HEURES}h.")
|
||||||
message = f"✅ {table} : relevés à nouveau reçus. Situation normale."
|
|
||||||
envoyer_sms(message)
|
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)
|
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:
|
else:
|
||||||
logging.info(f"✅ {table} OK (dernier relevé : {last_update})")
|
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)
|
# MAIN
|
||||||
envoyer_sms(message)
|
# ============================================================
|
||||||
|
|
||||||
|
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 s’exé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:
|
else:
|
||||||
logging.info("👍 Tout est OK, aucun SMS envoyé.")
|
logging.info("👍 Tout est OK, aucune notification envoyée.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
285
app/tracker.py
285
app/tracker.py
@@ -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."
|
|
||||||
)
|
|
||||||
@@ -4,22 +4,24 @@ import os
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
def connect_to_mysql():
|
def connect_to_mysql():
|
||||||
return mysql.connector.connect(
|
return mysql.connector.connect(
|
||||||
host=os.getenv("DB_HOST"),
|
host=os.getenv("DB_HOST"),
|
||||||
user=os.getenv("DB_USER"),
|
user=os.getenv("DB_USER"),
|
||||||
password=os.getenv("DB_PASSWORD"),
|
password=os.getenv("DB_PASS"),
|
||||||
database=os.getenv("DB_NAME")
|
database=os.getenv("DB_NAME")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_latest_chaufferie():
|
def get_latest_chaufferie():
|
||||||
conn = connect_to_mysql()
|
conn = connect_to_mysql()
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT Sonde, Temperature, Date, Topic
|
SELECT Sonde, Temperature, Date, Topic
|
||||||
FROM Chaufferie
|
FROM Sondes.Chaufferie
|
||||||
WHERE Date >= NOW() - INTERVAL 5 MINUTE
|
WHERE Date >= NOW() - INTERVAL 5 MINUTE
|
||||||
ORDER BY Date DESC
|
ORDER BY Date DESC \
|
||||||
"""
|
"""
|
||||||
cursor.execute(query)
|
cursor.execute(query)
|
||||||
result = cursor.fetchall()
|
result = cursor.fetchall()
|
||||||
@@ -27,15 +29,16 @@ def get_latest_chaufferie():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_history_by_sonde(sonde):
|
def get_history_by_sonde(sonde):
|
||||||
conn = connect_to_mysql()
|
conn = connect_to_mysql()
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT Sonde, Temperature, Date
|
SELECT Sonde, Temperature, Date
|
||||||
FROM Chaufferie
|
FROM Sondes.Chaufferie
|
||||||
WHERE Sonde = %s
|
WHERE Sonde = %s
|
||||||
AND Date >= NOW() - INTERVAL 1 DAY
|
AND Date >= NOW() - INTERVAL 1 DAY \
|
||||||
ORDER BY Date ASC
|
|
||||||
"""
|
"""
|
||||||
cursor.execute(query, (sonde,))
|
cursor.execute(query, (sonde,))
|
||||||
result = cursor.fetchall()
|
result = cursor.fetchall()
|
||||||
@@ -43,14 +46,15 @@ def get_history_by_sonde(sonde):
|
|||||||
conn.close()
|
conn.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def lire_alertes_sondes():
|
def lire_alertes_sondes():
|
||||||
conn = connect_to_mysql()
|
conn = connect_to_mysql()
|
||||||
cursor = conn.cursor(dictionary=True)
|
cursor = conn.cursor(dictionary=True)
|
||||||
query = """
|
query = """
|
||||||
SELECT Id, Sonde, Debut_defaut, Etat
|
SELECT Id, Sonde, Debut_defaut, Etat
|
||||||
FROM Alertes_Chaufferie
|
FROM Sondes.Alertes_Chaufferie
|
||||||
WHERE Etat != 'Acquitté'
|
WHERE Etat != 'Acquitté'
|
||||||
ORDER BY Debut_defaut DESC
|
ORDER BY Debut_defaut DESC \
|
||||||
"""
|
"""
|
||||||
cursor.execute(query)
|
cursor.execute(query)
|
||||||
result = cursor.fetchall()
|
result = cursor.fetchall()
|
||||||
@@ -58,10 +62,11 @@ def lire_alertes_sondes():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def acquitter_alerte(id_alerte):
|
def acquitter_alerte(id_alerte):
|
||||||
conn = connect_to_mysql()
|
conn = connect_to_mysql()
|
||||||
cursor = conn.cursor()
|
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,))
|
cursor.execute(query, (id_alerte,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|||||||
44
app/utils_mail.py
Normal file
44
app/utils_mail.py
Normal 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)
|
||||||
@@ -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")
|
|
||||||
@@ -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)
|
|
||||||
@@ -6,8 +6,6 @@ This module returns the installation location of cacert.pem or its contents.
|
|||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
DEBIAN_CA_CERTS_PATH = '/etc/ssl/certs/ca-certificates.crt'
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
if sys.version_info >= (3, 11):
|
||||||
|
|
||||||
from importlib.resources import as_file, files
|
from importlib.resources import as_file, files
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
89
scripts/backup_VM.sh
Normal file
89
scripts/backup_VM.sh
Normal 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"
|
||||||
@@ -1,37 +1,97 @@
|
|||||||
#!/bin/bash
|
#!/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"
|
BACKUP_DIR="/home/debian/backup"
|
||||||
LOG_DIR="/home/debian/Gestion_sondes/Logs"
|
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"
|
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"
|
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
|
# Cible NAS via WireGuard / LAN
|
||||||
mkdir -p "$BACKUP_DIR"
|
NAS_HOST="192.168.1.250"
|
||||||
|
NAS_PORT="4422"
|
||||||
|
NAS_USER="Michel"
|
||||||
|
NAS_DIR="/volume1/backups/VPS_Ovh"
|
||||||
|
|
||||||
echo "🔷 Sauvegarde des bases MySQL..."
|
SSH_KEY="/home/debian/.ssh/id_ed25519"
|
||||||
if mysqldump -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" --all-databases > "$BACKUP_FILE"; then
|
SSH_OPTS="-i $SSH_KEY -p $NAS_PORT \
|
||||||
echo "✅ Sauvegarde locale terminée : $BACKUP_FILE"
|
-o BatchMode=yes -o PreferredAuthentications=publickey \
|
||||||
else
|
-o PasswordAuthentication=no -o PubkeyAuthentication=yes \
|
||||||
echo "❌ Erreur lors de la sauvegarde MySQL"
|
-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 \
|
||||||
exit 1
|
-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
|
fi
|
||||||
|
|
||||||
echo "🔷 Envoi de la sauvegarde vers le NAS..."
|
# 2) Dump MySQL
|
||||||
if rsync -av -e ssh "$BACKUP_FILE" "$NAS_HOST:$NAS_DIR/"; then
|
echo "🔷 Dump MySQL…"
|
||||||
echo "✅ Sauvegarde transférée sur le NAS : $NAS_HOST"
|
if [[ -f "$MYSQL_DEFAULTS" ]]; then
|
||||||
|
DUMP="mysqldump --defaults-extra-file=$MYSQL_DEFAULTS --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
|
||||||
else
|
else
|
||||||
echo "❌ Échec du transfert vers le NAS"
|
DUMP="mysqldump --all-databases --single-transaction --quick --lock-tables=false --routines --events --triggers"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔷 Nettoyage des sauvegardes locales de plus de 7 jours..."
|
# Baisse de priorité
|
||||||
find "$BACKUP_DIR" -type f -name "*.sql" -mtime +7 -exec rm -f {} \;
|
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"
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
?>
|
|
||||||
12
scripts/update_gestion_sondes.sh
Normal file
12
scripts/update_gestion_sondes.sh
Normal 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
|
||||||
Reference in New Issue
Block a user