"""Pi Wi‑Fi and saved ESP-NOW bridge profiles.""" from __future__ import annotations from fastapi import APIRouter, Request from http_responses import J, html_response, plain, read_json, send_file from http_session import with_session import json import secrets from settings import get_settings from util.bridge_profiles import find_bridge_profile, normalise_bridges from util.bridge_runtime import ( active_bridge_profile_id, bridge_connected, bridge_serial_connected, bridge_ws_connected, connect_bridge_profile, connect_bridge_serial, connect_bridge_wifi, ) from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi router = APIRouter() def _bridge_transport(settings) -> str: mode = str(settings.get("bridge_transport") or "wifi").strip().lower() return mode if mode in ("wifi", "serial") else "wifi" def _bridges_payload(settings) -> dict: return { "ok": True, "wifi_interface": settings.get("wifi_interface") or "", "bridge_ws_url": settings.get("bridge_ws_url") or "", "bridge_connected": bridge_connected(), "bridge_wifi_connected": bridge_ws_connected(), "bridge_serial_connected": bridge_serial_connected(), "bridge_transport": _bridge_transport(settings), "active_bridge_id": active_bridge_profile_id(settings) or "", "bridge_serial_port": settings.get("bridge_serial_port") or "", "bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600), "bridges": normalise_bridges(settings.get("bridges")), } @router.get("/interfaces") async def wifi_interfaces(request: Request): _ = request if not nmcli_available(): return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503) return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200) @router.get("/scan") async def wifi_scan(request: Request): device = (request.query_params.get("device") or "").strip() if not device: return J({"error": "device query param required"}, 400) if not nmcli_available(): return J({"ok": False, "error": "nmcli not found"}, 503) try: networks = await scan_wifi(device) return J({"ok": True, "device": device, "networks": networks}, 200) except Exception as e: return J({"ok": False, "error": str(e)}, 500) @router.get("/bridges") async def get_bridges(request: Request): _ = request settings = get_settings() return J(_bridges_payload(settings), 200) @router.put("/bridges") async def put_bridges(request: Request): try: data = await read_json(request) settings = get_settings() if "wifi_interface" in data: settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip() if "bridge_transport" in data: mode = str(data.get("bridge_transport") or "").strip().lower() if mode in ("wifi", "serial"): settings["bridge_transport"] = mode if "bridge_ws_url" in data: settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip() if "bridge_serial_port" in data: settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip() if "bridge_serial_baudrate" in data: settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600) if "bridges" in data: settings["bridges"] = normalise_bridges(data.get("bridges")) settings.save() return J({"ok": True, "message": "Bridge profiles saved"}, 200) except Exception as e: return J({"ok": False, "error": str(e)}, 400) @router.delete("/bridges/{bridge_id}") async def delete_bridge_profile(request: Request, bridge_id): _ = request settings = get_settings() bid = str(bridge_id or "").strip() bridges = normalise_bridges(settings.get("bridges")) kept = [b for b in bridges if str(b.get("id") or "") != bid] if len(kept) == len(bridges): return J({"ok": False, "error": "Bridge profile not found"}, 404) settings["bridges"] = kept settings.save() payload = _bridges_payload(settings) payload["message"] = "Bridge profile deleted" return J(payload, 200) @router.post("/bridges/{bridge_id}/connect") async def connect_saved_bridge(request: Request, bridge_id): _ = request settings = get_settings() profile = find_bridge_profile(settings, bridge_id) if not profile: return J({"error": "Bridge profile not found"}, 404) try: ok, err = await connect_bridge_profile(profile, settings) if not ok: return J({"ok": False, "error": err or "Connect failed"}, 400) payload = _bridges_payload(settings) payload["message"] = f"Connected to {profile.get('label')}" return J(payload, 200) except Exception as e: return J({"ok": False, "error": str(e)}, 500) @router.post("/connect") async def wifi_connect_bridge(request: Request): """Join a bridge AP and open its WebSocket.""" try: data = await read_json(request) settings = get_settings() device = str(data.get("device") or settings.get("wifi_interface") or "").strip() ssid = str(data.get("ssid") or "").strip() password = str(data.get("password") or "") ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip() try: ws_port = int(data.get("ws_port") or 80) except (TypeError, ValueError): ws_port = 80 label = str(data.get("label") or ssid).strip() or ssid save_profile = bool(data.get("save_profile", True)) if not device: return J({"error": "Wi‑Fi interface (device) is required"}, 400) if not ssid: return J({"error": "ssid is required"}, 400) settings["wifi_interface"] = device bridges = normalise_bridges(settings.get("bridges")) profile_id = None if save_profile: profile_id = secrets.token_hex(6) bridges = [ b for b in bridges if not (b.get("transport") == "wifi" and b.get("ssid") == ssid) ] bridges.append( { "id": profile_id, "label": label, "transport": "wifi", "ssid": ssid, "password": password, "ap_ip": ap_ip, "ws_port": ws_port, } ) settings["bridges"] = bridges settings.save() profile = { "transport": "wifi", "ssid": ssid, "password": password, "ap_ip": ap_ip, "ws_port": ws_port, "wifi_interface": device, } ok, err = await connect_bridge_wifi(profile, settings) if not ok: return J({"ok": False, "error": err or "Connect failed"}, 400) payload = _bridges_payload(settings) payload["profile_id"] = profile_id payload["message"] = f"Connected to {ssid}" return J(payload, 200) except Exception as e: return J({"ok": False, "error": str(e)}, 500) @router.post("/serial/connect") async def serial_connect_bridge(request: Request): try: data = await read_json(request) port = str(data.get("port") or data.get("serial_port") or "").strip() save_profile = bool(data.get("save_profile", True)) label = str(data.get("label") or port).strip() or port try: baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600) except (TypeError, ValueError): baud = 921600 if not port: return J({"error": "port is required"}, 400) settings = get_settings() bridges = normalise_bridges(settings.get("bridges")) profile_id = None if save_profile: profile_id = secrets.token_hex(6) bridges = [ b for b in bridges if not (b.get("transport") == "serial" and b.get("serial_port") == port) ] bridges.append( { "id": profile_id, "label": label, "transport": "serial", "serial_port": port, "serial_baudrate": baud, } ) settings["bridges"] = bridges settings.save() profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud} ok, err = await connect_bridge_serial(profile, settings) if not ok: return J({"ok": False, "error": err}, 500) payload = _bridges_payload(settings) payload["profile_id"] = profile_id payload["message"] = f"Connected on {port}" return J(payload, 200) except Exception as e: return J({"ok": False, "error": str(e)}, 500)