Admin WiFi : scan réseaux, connexion, mots de passe mémorisés

- Module backend/wifi.py : scan nmcli, connexion, mots de passe persistés dans data/wifi_passwords.json
- Gère la locale FR (oui/non), déduplique les AP multiples, prend le meilleur signal
- Onglet WiFi dans le menu admin avec statut, scan, clavier virtuel AZERTY complet
- Réseaux enregistrés avec bouton connecter/oublier et affichage temporaire du mdp
- API: /api/wifi/status, /api/wifi/scan, /api/wifi/connect, /api/wifi/saved, /api/wifi/password/{ssid}

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:29:12 +02:00
parent 4e431507fc
commit 8dabba65bb
5 changed files with 647 additions and 2 deletions

203
backend/wifi.py Normal file
View File

@@ -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)