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>
252 lines
8.7 KiB
Python
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()
|