refactor(api): complete fastapi migration and related features

Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 22:55:28 +12:00
parent cb9758b97b
commit ace5770b3a
73 changed files with 4540 additions and 4487 deletions

View File

@@ -2,10 +2,14 @@
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 microdot import Microdot
from settings import get_settings
from util.bridge_profiles import find_bridge_profile, normalise_bridges
@@ -20,7 +24,7 @@ from util.bridge_runtime import (
)
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
controller = Microdot()
router = APIRouter()
def _bridge_transport(settings) -> str:
@@ -44,55 +48,39 @@ def _bridges_payload(settings) -> dict:
}
@controller.get("/interfaces")
async def wifi_interfaces(request):
@router.get("/interfaces")
async def wifi_interfaces(request: 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"},
)
return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503)
return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200)
@controller.get("/scan")
async def wifi_scan(request):
device = (request.args.get("device") or "").strip()
@router.get("/scan")
async def wifi_scan(request: Request):
device = (request.query_params.get("device") or "").strip()
if not device:
return json.dumps({"error": "device query param required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "device query param required"}, 400)
if not nmcli_available():
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": "nmcli not found"}, 503)
try:
networks = await scan_wifi(device)
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
"Content-Type": "application/json",
}
return J({"ok": True, "device": device, "networks": networks}, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.get("/bridges")
async def get_bridges(request):
@router.get("/bridges")
async def get_bridges(request: Request):
_ = request
settings = get_settings()
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
return J(_bridges_payload(settings), 200)
@controller.put("/bridges")
async def put_bridges(request):
@router.put("/bridges")
async def put_bridges(request: Request):
try:
data = request.json or {}
data = await read_json(request)
settings = get_settings()
if "wifi_interface" in data:
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
@@ -109,62 +97,50 @@ async def put_bridges(request):
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",
}
return J({"ok": True, "message": "Bridge profiles saved"}, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 400, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 400)
@controller.delete("/bridges/<bridge_id>")
async def delete_bridge_profile(request, bridge_id):
@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 json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
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 json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
@controller.post("/bridges/<bridge_id>/connect")
async def connect_saved_bridge(request, bridge_id):
@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 json.dumps({"error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
return J({"error": "Bridge profile not found"}, 404)
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",
}
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.post("/connect")
async def wifi_connect_bridge(request):
@router.post("/connect")
async def wifi_connect_bridge(request: Request):
"""Join a bridge AP and open its WebSocket."""
try:
data = request.json or {}
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()
@@ -177,13 +153,9 @@ async def wifi_connect_bridge(request):
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",
}
return J({"error": "WiFi interface (device) is required"}, 400)
if not ssid:
return json.dumps({"error": "ssid is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "ssid is required"}, 400)
settings["wifi_interface"] = device
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
@@ -217,23 +189,19 @@ async def wifi_connect_bridge(request):
}
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",
}
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 json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.post("/serial/connect")
async def serial_connect_bridge(request):
@router.post("/serial/connect")
async def serial_connect_bridge(request: Request):
try:
data = request.json or {}
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
@@ -242,9 +210,7 @@ async def serial_connect_bridge(request):
except (TypeError, ValueError):
baud = 921600
if not port:
return json.dumps({"error": "port is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "port is required"}, 400)
settings = get_settings()
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
@@ -269,14 +235,10 @@ async def serial_connect_bridge(request):
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",
}
return J({"ok": False, "error": err}, 500)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)