Files
led-controller/src/fastapi_app.py
Jimmy 2382ef16a1 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>
2026-06-08 10:33:38 +12:00

252 lines
8.7 KiB
Python

"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge."""
from __future__ import annotations
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse
from app_factory import (
AppRuntime,
audio_status_payload,
create_microdot_app,
live_reload_enabled,
)
from microdot_asgi import MicrodotASGI
from models.transport import get_current_bridge
_runtime: Optional[AppRuntime] = None
_microdot_app = None
_test_mode = False
class _SuppressDevAccessLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return "/__dev/" not in record.getMessage()
logging.getLogger("uvicorn.access").addFilter(_SuppressDevAccessLogFilter())
def _bridge():
return get_current_bridge()
@asynccontextmanager
async def _lifespan(app: FastAPI):
global _runtime
_runtime = AppRuntime()
await _runtime.startup(test_mode=_test_mode)
if live_reload_enabled() and not _test_mode:
print(
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when uvicorn reloads"
)
yield
await _runtime.shutdown()
def _create_fastapi() -> FastAPI:
api = FastAPI(title="LED Controller", lifespan=_lifespan)
@api.get("/__dev/build-id", response_class=PlainTextResponse)
async def dev_build_id_route():
from app_factory import dev_build_id as current_build_id
bid = current_build_id()
if not bid:
return PlainTextResponse("", status_code=404)
return PlainTextResponse(
bid,
headers={"Cache-Control": "no-store"},
)
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
async def dev_client_rev_route():
from app_factory import dev_client_revision
rev = dev_client_revision()
if not rev:
return PlainTextResponse("", status_code=404)
return PlainTextResponse(
rev,
headers={"Cache-Control": "no-store"},
)
@api.get("/api/audio/devices")
async def audio_devices():
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
try:
return {
"devices": _runtime.audio_detector.list_input_devices(),
"diagnostics": _runtime.audio_detector.diagnostics(),
}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@api.post("/api/audio/start")
async def audio_start(payload: dict | None = None):
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
body = payload if isinstance(payload, dict) else {}
device = body.get("device", None)
if device in ("", None):
device = None
device_select = str(body.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
try:
from util.pulse_audio_devices import resolve_capture_device
device = resolve_capture_device(device)
_runtime.audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(
enabled=True,
device=device,
device_override=str(body.get("device_override") or ""),
device_select=device_select,
)
return {"ok": True, "status": _runtime.audio_detector.status()}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@api.put("/api/audio/device")
async def audio_set_device(payload: dict | None = None):
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
body = payload if isinstance(payload, dict) else {}
device_select = str(body.get("device_select") or "").strip()
device_override = str(body.get("device_override") or "").strip()
raw = device_override if device_override else device_select
device = raw if raw else None
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state()
write_audio_run_state(
enabled=bool(prev.get("enabled")),
device=device if raw else None,
device_override=device_override,
device_select=device_select,
)
return {"ok": True, "audio_run": read_audio_run_state()}
@api.post("/api/audio/stop")
async def audio_stop():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
_runtime.audio_detector.stop()
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False)
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/reset")
async def audio_reset():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
ok = _runtime.audio_detector.reset_tracking()
if not ok:
return JSONResponse(
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/anchor-bar")
async def audio_anchor_bar():
if _runtime is None:
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
ok = _runtime.audio_detector.anchor_bar_phase()
if not ok:
return JSONResponse(
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.get("/api/audio/status")
async def audio_status():
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
@api.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
await websocket.accept()
bridge = _bridge()
try:
while True:
data = await websocket.receive()
if data.get("type") == "websocket.disconnect":
break
if "bytes" in data and data["bytes"] is not None:
await bridge.send(bytes(data["bytes"]))
continue
text = data.get("text")
if text is None:
continue
try:
parsed = json.loads(text)
addr = parsed.pop("to", None)
await bridge.send(parsed, addr=addr)
except json.JSONDecodeError:
pass
except Exception:
try:
await websocket.send_text(json.dumps({"error": "Send failed"}))
except Exception:
pass
except WebSocketDisconnect:
pass
except Exception:
pass
return api
class CombinedASGI:
"""Route FastAPI-only paths first; delegate the rest to Microdot."""
_FASTAPI_PREFIXES = ("/api/", "/__dev/")
def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI):
self.fastapi_app = fastapi_app
self.microdot_asgi = microdot_asgi
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
stype = scope.get("type")
if stype == "lifespan":
await self.fastapi_app(scope, receive, send)
return
if stype == "websocket":
if scope.get("path") == "/ws":
await self.fastapi_app(scope, receive, send)
return
await send({"type": "websocket.close", "code": 1000})
return
if stype == "http":
path = scope.get("path") or ""
if path.startswith(self._FASTAPI_PREFIXES):
await self.fastapi_app(scope, receive, send)
return
await self.microdot_asgi(scope, receive, send)
def create_application(*, test_mode: bool = False) -> CombinedASGI:
global _microdot_app, _test_mode
_test_mode = test_mode
_microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled())
fastapi_app = _create_fastapi()
return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app))
app = create_application()