diff --git a/backend/main.py b/backend/main.py index 96854e3..10a8a12 100644 --- a/backend/main.py +++ b/backend/main.py @@ -38,6 +38,7 @@ from backend.evenements import ( ) from backend.evenements import DOSSIER_EVENEMENTS from backend.eclairage import analyser_frame as analyser_eclairage, dernier_resultat as dernier_eclairage +from backend.wifi import wifi_status, wifi_scan, wifi_connect, wifi_saved_list, wifi_forget, wifi_get_password logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s") log = logging.getLogger("photobooth") @@ -1402,6 +1403,48 @@ async def api_quitter_navigateur(): return {"succes": True} +# --- API WiFi --- + +@app.get("/api/wifi/status") +async def api_wifi_status(): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, wifi_status) + + +@app.get("/api/wifi/scan") +async def api_wifi_scan(): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, wifi_scan) + + +@app.post("/api/wifi/connect") +async def api_wifi_connect(donnees: dict): + ssid = donnees.get("ssid", "") + password = donnees.get("password") + if not ssid: + return JSONResponse({"erreur": "SSID requis"}, status_code=400) + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, wifi_connect, ssid, password) + + +@app.get("/api/wifi/saved") +async def api_wifi_saved(): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, wifi_saved_list) + + +@app.delete("/api/wifi/saved/{ssid}") +async def api_wifi_forget(ssid: str): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, wifi_forget, ssid) + + +@app.get("/api/wifi/password/{ssid}") +async def api_wifi_password(ssid: str): + mdp = wifi_get_password(ssid) + return {"ssid": ssid, "password": mdp} + + # --- WebSocket --- @app.websocket("/ws") diff --git a/backend/wifi.py b/backend/wifi.py new file mode 100644 index 0000000..1906ae8 --- /dev/null +++ b/backend/wifi.py @@ -0,0 +1,203 @@ +"""Gestion WiFi via nmcli — scan, connexion, réseaux enregistrés.""" + +import json +import logging +import subprocess +from pathlib import Path + +log = logging.getLogger("photobooth.wifi") + +WIFI_PASSWORDS_FILE = Path(__file__).parent.parent / "data" / "wifi_passwords.json" + + +def _nmcli_split(line): + """Split nmcli -t output, gère les ':' échappés par '\\'.""" + parts = [] + cur = [] + i = 0 + while i < len(line): + if line[i] == '\\' and i + 1 < len(line): + cur.append(line[i + 1]) + i += 2 + elif line[i] == ':': + parts.append(''.join(cur)) + cur = [] + i += 1 + else: + cur.append(line[i]) + i += 1 + parts.append(''.join(cur)) + return parts + + +def _charger_mdp() -> dict: + if WIFI_PASSWORDS_FILE.exists(): + try: + return json.loads(WIFI_PASSWORDS_FILE.read_text()) + except Exception: + return {} + return {} + + +def _sauvegarder_mdp(data: dict): + WIFI_PASSWORDS_FILE.parent.mkdir(parents=True, exist_ok=True) + WIFI_PASSWORDS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + + +def wifi_status() -> dict: + result = {"connecte": False, "ssid": None, "signal": None, "ip": None, "interface": None} + try: + r = subprocess.run( + ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"], + capture_output=True, text=True, timeout=10, + ) + for line in r.stdout.strip().split("\n"): + p = _nmcli_split(line) + if len(p) >= 4 and p[1] == "wifi" and p[2] == "connected": + result.update(connecte=True, interface=p[0], ssid=p[3]) + break + + if result["connecte"] and result["interface"]: + r2 = subprocess.run( + ["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", result["interface"]], + capture_output=True, text=True, timeout=5, + ) + for line in r2.stdout.strip().split("\n"): + if "IP4.ADDRESS" in line: + result["ip"] = line.split(":", 1)[1].strip().split("/")[0] + break + + r3 = subprocess.run( + ["nmcli", "-t", "-f", "ACTIVE,SIGNAL", "dev", "wifi", "list"], + capture_output=True, text=True, timeout=5, + ) + for line in r3.stdout.strip().split("\n"): + p = _nmcli_split(line) + if len(p) >= 2 and p[0] in ("yes", "oui") and p[1].isdigit(): + result["signal"] = int(p[1]) + break + except Exception as e: + log.warning(f"wifi_status: {e}") + return result + + +def wifi_scan() -> list: + saved_ssids = {n["ssid"] for n in wifi_saved_list()} + networks = [] + try: + r = subprocess.run( + ["nmcli", "-t", "-f", "SSID,SIGNAL,SECURITY,ACTIVE", "dev", "wifi", "list", "--rescan", "yes"], + capture_output=True, text=True, timeout=20, + ) + merged = {} + for line in r.stdout.strip().split("\n"): + if not line.strip(): + continue + p = _nmcli_split(line) + if len(p) < 4: + continue + ssid = p[0] + if not ssid: + continue + sig = int(p[1]) if p[1].isdigit() else 0 + is_active = p[3] in ("yes", "oui") + if ssid in merged: + if sig > merged[ssid]["signal"]: + merged[ssid]["signal"] = sig + if is_active: + merged[ssid]["actif"] = True + else: + merged[ssid] = { + "ssid": ssid, + "signal": sig, + "securise": bool(p[2] and p[2] not in ("--", "")), + "securite": p[2] if p[2] not in ("--", "") else "", + "actif": is_active, + "enregistre": ssid in saved_ssids, + } + networks = list(merged.values()) + networks.sort(key=lambda n: (-n["actif"], -n["signal"])) + except Exception as e: + log.warning(f"wifi_scan: {e}") + return networks + + +def wifi_connect(ssid: str, password: str | None = None) -> dict: + mdp = _charger_mdp() + + if not password and ssid in mdp: + password = mdp[ssid] + + # Réseau enregistré sans nouveau mot de passe → activer la connexion existante + if not password: + try: + r = subprocess.run( + ["nmcli", "connection", "up", ssid], + capture_output=True, text=True, timeout=30, + ) + if r.returncode == 0: + return {"succes": True, "message": f"Connecté à {ssid}"} + except Exception: + pass + + if password: + try: + # Supprimer l'ancienne connexion pour éviter les doublons + subprocess.run(["nmcli", "connection", "delete", ssid], + capture_output=True, text=True, timeout=5) + r = subprocess.run( + ["nmcli", "dev", "wifi", "connect", ssid, "password", password], + capture_output=True, text=True, timeout=30, + ) + if r.returncode == 0: + mdp[ssid] = password + _sauvegarder_mdp(mdp) + return {"succes": True, "message": f"Connecté à {ssid}"} + err = r.stderr.strip() or r.stdout.strip() + if "secret" in err.lower() or "no suitable" in err.lower(): + return {"succes": False, "message": "Mot de passe incorrect"} + return {"succes": False, "message": err} + except subprocess.TimeoutExpired: + return {"succes": False, "message": "Timeout — le réseau ne répond pas"} + except Exception as e: + return {"succes": False, "message": str(e)} + + return {"succes": False, "message": "Mot de passe requis"} + + +def wifi_saved_list() -> list: + saved = [] + mdp = _charger_mdp() + try: + r = subprocess.run( + ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], + capture_output=True, text=True, timeout=5, + ) + for line in r.stdout.strip().split("\n"): + p = _nmcli_split(line) + if len(p) >= 2 and p[1] == "802-11-wireless": + saved.append({"ssid": p[0], "mdp_connu": p[0] in mdp}) + except Exception as e: + log.warning(f"wifi_saved_list: {e}") + return saved + + +def wifi_forget(ssid: str) -> dict: + mdp = _charger_mdp() + try: + r = subprocess.run( + ["nmcli", "connection", "delete", ssid], + capture_output=True, text=True, timeout=10, + ) + if ssid in mdp: + del mdp[ssid] + _sauvegarder_mdp(mdp) + if r.returncode == 0: + return {"succes": True} + return {"succes": False, "message": r.stderr.strip()} + except Exception as e: + return {"succes": False, "message": str(e)} + + +def wifi_get_password(ssid: str) -> str | None: + return _charger_mdp().get(ssid) diff --git a/frontend/css/style.css b/frontend/css/style.css index e35e2f0..495d503 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -2036,6 +2036,94 @@ h3 { to { opacity: 1; transform: translateY(0); } } +/* === WiFi === */ +.wifi-statut { + display: flex; + align-items: center; + gap: 1rem; + background: rgba(255,255,255,.04); + border-radius: var(--rayon); + padding: 1rem 1.2rem; +} +.wifi-statut-icon { font-size: 2rem; } +.wifi-statut-info { flex: 1; } +.wifi-ssid { font-size: 1.1rem; font-weight: 600; } +.wifi-detail { font-size: .8rem; color: var(--texte-secondaire); margin-top: .15rem; } + +.wifi-signal-bars { + display: flex; + align-items: flex-end; + gap: 3px; + height: 24px; +} +.wifi-signal-bars .bar { + width: 5px; + border-radius: 2px; + background: rgba(255,255,255,.15); + transition: background .3s; +} +.wifi-signal-bars .bar.actif { background: var(--succes); } +.wifi-signal-bars .bar:nth-child(1) { height: 6px; } +.wifi-signal-bars .bar:nth-child(2) { height: 11px; } +.wifi-signal-bars .bar:nth-child(3) { height: 16px; } +.wifi-signal-bars .bar:nth-child(4) { height: 22px; } + +.wifi-liste { display: flex; flex-direction: column; gap: .4rem; } + +.wifi-reseau { + display: flex; + align-items: center; + gap: .8rem; + padding: .7rem 1rem; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.06); + border-radius: 10px; + cursor: pointer; + transition: background .2s, border-color .2s; +} +.wifi-reseau:active { background: rgba(255,255,255,.08); } +.wifi-reseau.wifi-actif { + border-color: var(--succes); + background: rgba(76,175,80,.08); +} +.wifi-reseau-ssid { flex: 1; font-size: .95rem; font-weight: 500; } +.wifi-reseau-meta { font-size: .75rem; color: var(--texte-secondaire); } +.wifi-reseau-icons { display: flex; align-items: center; gap: .5rem; font-size: .9rem; } +.wifi-badge-connecte { + font-size: .7rem; + padding: .15rem .5rem; + border-radius: 6px; + background: rgba(76,175,80,.2); + color: var(--succes); + font-weight: 600; +} +.wifi-badge-enregistre { + font-size: .7rem; + padding: .15rem .5rem; + border-radius: 6px; + background: rgba(255,255,255,.08); + color: var(--texte-secondaire); +} +.wifi-enregistre-item { + display: flex; + align-items: center; + gap: .8rem; + padding: .6rem 1rem; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.06); + border-radius: 10px; +} +.wifi-enregistre-item .wifi-reseau-ssid { flex: 1; } +.wifi-enregistre-mdp { + font-family: monospace; + font-size: .8rem; + color: var(--texte-secondaire); + background: rgba(255,255,255,.05); + padding: .2rem .5rem; + border-radius: 4px; + cursor: pointer; +} + /* Responsive tactile */ @media (max-width: 800px) { .accueil-contenu h1 { font-size: 2.5rem; } diff --git a/frontend/index.html b/frontend/index.html index ee95f8f..58bff7a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,7 +7,7 @@ - +
@@ -374,6 +374,7 @@ + @@ -691,6 +692,112 @@ + +