refactor(api): migrate server to fastapi and uvicorn

Replace the Microdot-only entrypoint with a CombinedASGI app that
handles FastAPI routes (audio API, websocket, dev live-reload) while
delegating the rest to Microdot. Suppress noisy /__dev/ access logs
during live-reload polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 10:33:38 +12:00
parent cfdd6de291
commit 2382ef16a1
14 changed files with 1309 additions and 814 deletions

View File

@@ -1,85 +1,18 @@
import asyncio
import builtins
import json
import os
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict
import pytest
import requests
# Ensure imports resolve to the repo's `src/` + `lib/` code.
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
from microdot import Microdot, send_file # noqa: E402
from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
from api_server import ( # noqa: E402
DummyBridge,
bridge_sent_envelope,
device_body_from_envelope,
)
class DummyBridge:
def __init__(self):
self.sent: list[tuple[Any, Optional[str]]] = []
async def send(self, data: Any, addr: Optional[str] = None):
if isinstance(data, dict):
from util.bridge_envelope import ( # noqa: E402
BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
is_broadcast_mac,
normalize_mac_key,
)
from util.v1_wire import compact_envelope # noqa: E402
if data.get("v") == "1" and ("devices" in data or "dv" in data):
data = compact_envelope(data)
elif addr is not None:
s = str(addr).strip().lower()
if is_broadcast_mac(s):
mac_key = BROADCAST_MAC
else:
h = normalize_mac_key(s)
mac_key = format_mac_key(h) if h else None
if mac_key:
body = {k: v for k, v in data.items() if k != "v"}
data = build_devices_envelope({mac_key: body})
else:
data = json.dumps(data, separators=(",", ":"))
else:
data = json.dumps(data, separators=(",", ":"))
elif isinstance(data, (bytes, bytearray)):
data = bytes(data).decode(errors="ignore")
self.sent.append((data, addr))
return True
def _bridge_sent_envelope(bridge: DummyBridge, index: int) -> Dict[str, Any]:
data, _addr = bridge.sent[index]
if isinstance(data, dict):
return data
return json.loads(data)
def _device_body_from_envelope(envelope: Dict[str, Any], mac: str) -> Dict[str, Any]:
from util.bridge_envelope import format_mac_key, normalize_mac_key # noqa: E402
devs = envelope.get("dv") or envelope.get("devices") or {}
key = format_mac_key(normalize_mac_key(mac))
return devs[key]
def _json(resp: requests.Response) -> Dict[str, Any]:
def _json(resp) -> Dict[str, Any]:
# Many endpoints already set Content-Type; but be tolerant for now.
return resp.json() # pragma: no cover
@@ -91,7 +24,7 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
def _create_and_apply_profile(c, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
@@ -102,243 +35,8 @@ def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
return str(profile_id)
def _start_microdot_server(app: Microdot, host: str, port: int):
"""
Start Microdot server on a background thread.
Returns (thread, chosen_port).
"""
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host=host, port=port))
finally:
try:
loop.close()
except Exception:
pass
thread = threading.Thread(target=runner, daemon=True)
thread.start()
# Poll until the socket is bound and app.server is available.
chosen_port = None
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
chosen_port = sockets[0].getsockname()[1]
break
time.sleep(0.05)
if chosen_port is None:
raise RuntimeError("Microdot server failed to start in time")
return thread, chosen_port
@pytest.fixture(scope="function")
def server(monkeypatch, tmp_path_factory):
"""
Start the Microdot app in-process and return a test client.
"""
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
tmp_db_dir = tmp_root / "db"
tmp_settings_file = tmp_root / "settings.json"
# Be defensive: pytest runners can sometimes alter sys.path ordering.
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
# Patch Settings so endpoint tests never touch real `settings.json`.
import settings as settings_mod # noqa: E402
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
# Patch the Model db directory so endpoint CRUD is isolated.
import models.model as model_mod # noqa: E402
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
# Reset model singletons (controllers instantiate model classes at import time).
# Import the classes first so we can delete their `_instance` attribute if present.
import models.preset as models_preset # noqa: E402
import models.profile as models_profile # noqa: E402
import models.group as models_group # noqa: E402
import models.zone as models_zone # noqa: E402
import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402
import models.sequence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402
for cls in (
models_preset.Preset,
models_profile.Profile,
models_group.Group,
models_zone.Zone,
models_pallet.Palette,
models_scene.Scene,
models_pattern.Pattern,
models_sequence.Sequence,
models_device.Device,
):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
# Patch open() so pattern definitions work after we `chdir` into src/.
orig_open = builtins.open
def patched_open(file, *args, **kwargs):
if isinstance(file, str):
# Pattern controller loads definitions from a relative db/ path.
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
file = str(PROJECT_ROOT / "db" / "pattern.json")
return orig_open(file, *args, **kwargs)
monkeypatch.setattr(builtins, "open", patched_open)
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_bridge = DummyBridge()
try:
# Ensure controllers are imported fresh after our patching.
for mod_name in (
"controllers.preset",
"controllers.profile",
"controllers.group",
"controllers.sequence",
"controllers.zone",
"controllers.palette",
"controllers.scene",
"controllers.pattern",
"controllers.settings",
"controllers.device",
):
sys.modules.pop(mod_name, None)
# Import controllers after patching db/settings/model singletons.
import controllers.preset as preset_ctl # noqa: E402
import controllers.profile as profile_ctl # noqa: E402
import controllers.group as group_ctl # noqa: E402
import controllers.sequence as sequence_ctl # noqa: E402
import controllers.zone as zone_ctl # noqa: E402
import controllers.palette as palette_ctl # noqa: E402
import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport bridge used by /presets/send.
from models.transport import set_bridge # noqa: E402
set_bridge(dummy_bridge)
app = Microdot()
# Session secret key comes from settings (patched to tmp).
settings = settings_mod.Settings()
secret_key = settings.get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
Session(app, secret_key=secret_key)
# Mount model controllers under their public prefixes.
app.mount(preset_ctl.controller, "/presets")
app.mount(profile_ctl.controller, "/profiles")
app.mount(group_ctl.controller, "/groups")
app.mount(sequence_ctl.controller, "/sequences")
app.mount(zone_ctl.controller, "/zones")
app.mount(palette_ctl.controller, "/palettes")
app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings")
app.mount(device_ctl.controller, "/devices")
@app.route("/")
def index(request):
return send_file("templates/index.html")
@app.route("/settings")
def settings_page(request):
return send_file("templates/settings.html")
@app.route("/favicon.ico")
def favicon(request):
return "", 204
@app.route("/static/<path:path>")
def static_handler(request, path):
if ".." in path:
return "Not found", 404
return send_file("static/" + path)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
while True:
data = await ws.receive()
if not data:
break
try:
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_bridge.send(payload, addr=addr)
except Exception:
await dummy_bridge.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
client = requests.Session()
client.headers.update(
{
"User-Agent": "pytest/requests",
"Accept": "application/json",
}
)
yield {
"base_url": base_url,
"client": client,
"bridge": dummy_bridge,
"thread": thread,
"app": app,
}
finally:
# Stop server cleanly.
try:
app = locals().get("app")
if app is not None:
app.shutdown()
except Exception:
pass
# Give it a moment to close sockets.
time.sleep(0.1)
try:
thread = locals().get("thread")
if thread is not None:
thread.join(timeout=5)
except Exception:
pass
os.chdir(old_cwd)
def test_main_routes(server):
c: requests.Session = server["client"]
c = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/")
@@ -355,14 +53,12 @@ def test_main_routes(server):
assert resp.status_code == 200
assert "LED Controller" in resp.text
resp = c.get(f"{base_url}/ws")
# WebSocket endpoints should reject non-upgraded HTTP requests.
assert resp.status_code != 200
assert resp.status_code in {400, 401, 403, 404, 405, 426}
with c.websocket_connect("/ws") as ws:
ws.send_text('{"v":"1","select":["off"]}')
def test_settings_controller(server):
c: requests.Session = server["client"]
c = server["client"]
base_url: str = server["base_url"]
resp = c.get(f"{base_url}/settings")
@@ -418,7 +114,7 @@ def test_settings_controller(server):
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]
c = server["client"]
base_url: str = server["base_url"]
bridge: DummyBridge = server["bridge"]
@@ -594,7 +290,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
c = server["client"]
base_url: str = server["base_url"]
bridge: DummyBridge = server["bridge"]
@@ -713,9 +409,9 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
assert resp.json().get("message")
assert len(bridge.sent) >= 1
first = _bridge_sent_envelope(bridge, 0)
first = bridge_sent_envelope(bridge, 0)
assert first["v"] == "1"
first_body = _device_body_from_envelope(first, dev_id)
first_body = device_body_from_envelope(first, dev_id)
assert first_body["p"]["__identify"]["p"] == "blink"
assert first_body["p"]["__identify"]["d"] == 50
assert first_body["s"] == ["__identify"]
@@ -723,8 +419,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(bridge.sent) >= 2
second = _bridge_sent_envelope(bridge, 1)
second_body = _device_body_from_envelope(second, dev_id)
second = bridge_sent_envelope(bridge, 1)
second_body = device_body_from_envelope(second, dev_id)
assert second_body["s"] == ["off"]
resp = c.post(
@@ -868,3 +564,118 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
)
assert resp.status_code == 400
def test_audio_api(server):
c = server["client"]
base_url = server["base_url"]
resp = c.get(f"{base_url}/api/audio/status")
assert resp.status_code == 200
body = resp.json()
assert "status" in body
assert "audio_run" in body["status"]
resp = c.get(f"{base_url}/api/audio/devices")
assert resp.status_code == 200
assert "devices" in resp.json()
resp = c.put(
f"{base_url}/api/audio/device",
json={"device_select": "default", "device_override": ""},
)
assert resp.status_code == 200
assert resp.json().get("ok") is True
resp = c.post(f"{base_url}/api/audio/reset")
assert resp.status_code == 409
resp = c.post(f"{base_url}/api/audio/stop")
assert resp.status_code == 200
assert resp.json().get("ok") is True
def test_bridge_settings_api(server, monkeypatch):
c = server["client"]
base_url = server["base_url"]
import controllers.wifi_bridge as wifi_bridge_ctl # noqa: E402
monkeypatch.setattr(wifi_bridge_ctl, "nmcli_available", lambda: True)
monkeypatch.setattr(
wifi_bridge_ctl,
"list_wifi_interfaces",
lambda: [{"device": "wlan0", "type": "wifi", "state": "connected"}],
)
async def _fake_scan(device):
_ = device
return [{"ssid": "bridge-test", "signal": 80}]
monkeypatch.setattr(wifi_bridge_ctl, "scan_wifi", _fake_scan)
resp = c.get(f"{base_url}/settings/wifi/interfaces")
assert resp.status_code == 200
assert resp.json().get("ok") is True
assert resp.json()["interfaces"][0]["device"] == "wlan0"
resp = c.get(f"{base_url}/settings/wifi/scan", params={"device": "wlan0"})
assert resp.status_code == 200
assert resp.json()["networks"][0]["ssid"] == "bridge-test"
resp = c.get(f"{base_url}/settings/wifi/bridges")
assert resp.status_code == 200
payload = resp.json()
assert payload.get("ok") is True
assert "bridge_transport" in payload
assert "bridges" in payload
resp = c.put(
f"{base_url}/settings/wifi/bridges",
json={
"bridge_transport": "serial",
"bridge_serial_port": "/dev/ttyUSB0",
"bridges": [],
},
)
assert resp.status_code == 200
assert resp.json().get("ok") is True
resp = c.get(f"{base_url}/settings/wifi/bridges")
assert resp.json().get("bridge_transport") == "serial"
def test_group_identify(server, monkeypatch):
c = server["client"]
base_url = server["base_url"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl # noqa: E402
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
_create_and_apply_profile(c, base_url)
resp = c.post(f"{base_url}/groups", json={"name": "pytest-identify-group"})
assert resp.status_code == 201
groups_list = c.get(f"{base_url}/groups").json()
group_id = _find_id_by_field(groups_list, "name", "pytest-identify-group")
resp = c.post(
f"{base_url}/devices",
json={"name": "identify-dev", "address": "aabbccddeeff"},
)
assert resp.status_code == 201
dev_id = "aabbccddeeff"
resp = c.put(
f"{base_url}/groups/{group_id}",
json={"devices": [dev_id]},
)
assert resp.status_code == 200
bridge.sent.clear()
resp = c.post(f"{base_url}/groups/{group_id}/identify")
assert resp.status_code == 200
assert resp.json().get("sent", 0) >= 1
assert len(bridge.sent) >= 1