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:
251
src/fastapi_app.py
Normal file
251
src/fastapi_app.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user