# 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 hashlib import sha256 # (non utilisé directement mais laissé si besoin d'évolution) 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_DB = os.getenv("MYSQL_DB", "Sondes") 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" 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("

🔒 Accès restreint

", unsafe_allow_html=True) 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).\n" "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.""" import re ROM_REGEX = re.compile(r"^\{(?:0x[0-9A-Fa-f]{2},){7}0x[0-9A-Fa-f]{2}\}$") return bool(ROM_REGEX.match(address.strip())) def res_label(bits: int) -> str: """Retourne un label lisible pour la résolution DS18B20.""" 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}, } 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) # 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_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)") # 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, ajustez les constantes en tête de fichier." )