"""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()