Files
Gestion_sondes/app/tracker.py

364 lines
13 KiB
Python

# tracker_app.py
# -------------------------------------------------------------
# Streamlit — Gestion de la table MySQL Sondes.tracker
# -------------------------------------------------------------
# Schéma attendu : id (PK), address, 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"
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_PASSWORD"),
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}}$")
# 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
# NOTE : stockage en clair pour l'exemple. En prod, préférer un hash (bcrypt/argon2).
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)
# Formulaire centré
_, 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()
# --------------
# Fonctions SQL
# --------------
def fetch_trackers(where_lieu: str | None = None) -> pd.DataFrame:
query = (
f"SELECT {COL_ID}, {COL_ADDRESS}, {COL_LIEU}, {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_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) -> int:
sql = f"""
INSERT INTO {TABLE_NAME} ({COL_ADDRESS}, {COL_LIEU}, {COL_REPERE}, {COL_MES}, {COL_RESBITS})
VALUES (%s, %s, %s, %s, %s)
"""
with get_conn() as conn:
cur = conn.cursor()
cur.execute(sql, (
address,
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) -> None:
sql = f"""
UPDATE {TABLE_NAME}
SET {COL_ADDRESS}=%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, (
address,
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()
# --------------
# Utilitaires UI
# --------------
def rom_help() -> str:
"""Message d'aide sur le format des adresses ROM DS18B20."""
return (
"Format attendu : `{0x28,0xFF,0xAA,0xBB,0xCC,0xDD,0xEE,0x12}` "
"(8 octets en hex)."
"Le premier octet (famille) est souvent 0x28 pour DS18B20."
)
def is_valid_rom(address: str) -> bool:
"""Vérifie que l'adresse saisie correspond bien au format attendu."""
return bool(ROM_REGEX.match(address.strip()))
def res_label(bits: int) -> str:
"""Retourne un label lisible pour la résolution DS18B20."""
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="Tracker", page_icon="🌡️", layout="wide")
# 🔐 Exige la connexion avant d'afficher l'app
user = require_login()
st.title("🌡️ Gestion du parc sondes en 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 (MYSQL_HOST, MYSQL_DB, MYSQL_USER, MYSQL_PASSWORD, MYSQL_PORT, AUTH_USERS)")
# Barre latérale — Filtres & Actions
st.sidebar.header("Filtres & Actions")
# Info utilisateur connecté
st.sidebar.caption(f"Connecté en tant que **{user}**")
# 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)
# 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_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. 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,
new_mes if isinstance(new_mes, date) else None,
)
st.success(f"Sonde ajoutée (id={rid}).")
time.sleep(0.6)
st.rerun()
# Bouton de déconnexion (session Streamlit)
st.sidebar.divider()
if st.sidebar.button("Se déconnecter"):
for _k in list(st.session_state.keys()):
try:
del st.session_state[_k]
except Exception:
pass
st.success("Déconnecté.")
time.sleep(0.3)
st.rerun()
# 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. *resolution* et *date* sont non éditables.")
edited = st.data_editor(
df[[COL_ID, COL_ADDRESS, COL_LIEU, COL_REPERE, COL_MES, 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"),
COL_MES: st.column_config.DateColumn("Mise en service", format="YYYY-MM-DD"),
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étections
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_LIEU] != orig[COL_LIEU]) or
((str(row.get(COL_REPERE) or "").strip()) != (str(orig.get(COL_REPERE) or "").strip())) 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:
# Mise en service : convertir en date ou None
mes_val = row.get(COL_MES)
if pd.isna(mes_val):
mes_val = None
elif isinstance(mes_val, pd.Timestamp):
mes_val = mes_val.date()
elif isinstance(mes_val, str) and mes_val:
try:
mes_val = pd.to_datetime(mes_val).date()
except Exception:
mes_val = None
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),
mes_val,
)
)
# 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, mes in to_update:
update_tracker(rid, addr, lieu, rbits, repere, mes)
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)")
# 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..}."
"Si vos noms de colonnes diffèrent, ajustez les constantes en tête de fichier."
)