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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user