feat(bridge): add wifi/serial bridge runtime and UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-28 00:38:21 +12:00
parent 2cf019079e
commit 78dc8ffc77
92 changed files with 5679 additions and 1790 deletions

View File

@@ -0,0 +1,282 @@
"""Pi WiFi and saved ESP-NOW bridge profiles."""
from __future__ import annotations
import json
import secrets
from microdot import Microdot
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
controller = Microdot()
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")),
}
@controller.get("/interfaces")
async def wifi_interfaces(request):
_ = request
if not nmcli_available():
return (
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
503,
{"Content-Type": "application/json"},
)
return (
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
200,
{"Content-Type": "application/json"},
)
@controller.get("/scan")
async def wifi_scan(request):
device = (request.args.get("device") or "").strip()
if not device:
return json.dumps({"error": "device query param required"}), 400, {
"Content-Type": "application/json",
}
if not nmcli_available():
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
"Content-Type": "application/json",
}
try:
networks = await scan_wifi(device)
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
"Content-Type": "application/json",
}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.get("/bridges")
async def get_bridges(request):
_ = request
settings = get_settings()
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
@controller.put("/bridges")
async def put_bridges(request):
try:
data = request.json or {}
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 json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
"Content-Type": "application/json",
}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 400, {
"Content-Type": "application/json",
}
@controller.delete("/bridges/<bridge_id>")
async def delete_bridge_profile(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 json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
settings["bridges"] = kept
settings.save()
payload = _bridges_payload(settings)
payload["message"] = "Bridge profile deleted"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
@controller.post("/bridges/<bridge_id>/connect")
async def connect_saved_bridge(request, bridge_id):
_ = request
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return json.dumps({"error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
try:
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.post("/connect")
async def wifi_connect_bridge(request):
"""Join a bridge AP and open its WebSocket."""
try:
data = request.json or {}
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 json.dumps({"error": "WiFi interface (device) is required"}), 400, {
"Content-Type": "application/json",
}
if not ssid:
return json.dumps({"error": "ssid is required"}), 400, {
"Content-Type": "application/json",
}
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 json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected to {ssid}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
@controller.post("/serial/connect")
async def serial_connect_bridge(request):
try:
data = request.json or {}
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 json.dumps({"error": "port is required"}), 400, {
"Content-Type": "application/json",
}
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 json.dumps({"ok": False, "error": err}), 500, {
"Content-Type": "application/json",
}
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}