from pathlib import Path from fastapi import APIRouter, HTTPException from fastapi.responses import FileResponse, RedirectResponse from led_platform.core.clock import epoch_ms from led_platform.domain import CommandEnvelope, CommandType, PerformanceSample from led_platform.runtime import hub, scenes, settings, sync, tiles ROOT = Path(__file__).resolve().parents[2] WEB_DIR = ROOT / "led_platform" / "web" STATIC_DIR = WEB_DIR / "static" router = APIRouter() HTML_HEADERS = { "Cache-Control": "no-store", "Clear-Site-Data": '"cache"', } ALLOWED_ACTIONS = { "page.next", "page.prev", "energy.mode", "security.alert", "timeline.pause", "timeline.resume", "motion.mode", "scenario.focus", } @router.get("/", include_in_schema=False) async def admin_index() -> FileResponse: return FileResponse(STATIC_DIR / "admin" / "index.html", headers=HTML_HEADERS) @router.get("/output/{tile_id}", include_in_schema=False) async def output_index(tile_id: str) -> FileResponse: try: tiles.get(tile_id) except KeyError as exc: raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS) @router.get("/wall-app", include_in_schema=False) async def wall_app() -> FileResponse: return FileResponse(STATIC_DIR / "output" / "index.html", headers=HTML_HEADERS) @router.get("/favicon.ico", include_in_schema=False) async def favicon() -> RedirectResponse: return RedirectResponse("/static/favicon.svg") @router.get("/healthz") async def healthz() -> dict: sync_status = sync.status() return { "ok": True, "server_time_ms": epoch_ms(), "clients": await hub.counts(), "outputs": len(sync_status["nodes"]), "pending_commands": len(sync_status["commands"]), } @router.get("/api/bootstrap/{tile_id}") async def bootstrap(tile_id: str) -> dict: try: tile = tiles.get(tile_id) except KeyError as exc: raise HTTPException(status_code=404, detail=f"Unknown output tile: {tile_id}") from exc return { "server_time_ms": epoch_ms(), "target_fps": settings.target_fps, "tile": tile.model_dump(mode="json"), "tiles": [item.model_dump(mode="json") for item in tiles.all()], "state": scenes.state().model_dump(mode="json"), "scenes": [scene.model_dump(mode="json") for scene in scenes.list()], } @router.get("/api/scenes") async def list_scenes() -> dict: return { "state": scenes.state().model_dump(mode="json"), "scenes": [scene.model_dump(mode="json") for scene in scenes.list()], } @router.post("/api/scenes/{scene_id}/switch") async def switch_scene(scene_id: str) -> dict: try: scene = scenes.get(scene_id) except KeyError as exc: raise HTTPException(status_code=404, detail=f"Unknown scene: {scene_id}") from exc apply_at_ms = epoch_ms() + settings.switch_prepare_delay_ms state = scenes.switch(scene.id, apply_at_ms) payload = { "state": state.model_dump(mode="json"), "scene": scene.model_dump(mode="json"), "server_time_ms": epoch_ms(), "apply_at_ms": apply_at_ms, } prepare = CommandEnvelope(type=CommandType.PREPARE_SCENE, payload=payload) commit = CommandEnvelope( type=CommandType.COMMIT_SCENE, command_id=prepare.command_id, payload=payload, ) sync.register_command(commit, payload) await hub.broadcast(prepare, "admin", "outputs") return payload @router.post("/api/actions") async def run_action(payload: dict) -> dict: action = payload.get("action") if not isinstance(action, str) or action not in ALLOWED_ACTIONS: raise HTTPException(status_code=400, detail=f"Unsupported action: {action!r}") apply_at_ms = epoch_ms() + settings.action_prepare_delay_ms state = scenes.action(action, apply_at_ms) command_payload = { "target": payload.get("target") or "wall", "action": action, "args": payload.get("args") or {}, "state": state.model_dump(mode="json"), "server_time_ms": epoch_ms(), "apply_at_ms": apply_at_ms, } message = CommandEnvelope(type=CommandType.COMPONENT_ACTION, payload=command_payload) sync.register_command(message, command_payload) await hub.broadcast(message, "admin", "outputs") return command_payload @router.get("/api/tiles") async def list_tiles() -> dict: return { "wall": {"width": settings.wall_width, "height": settings.wall_height}, "tiles": [tile.model_dump(mode="json") for tile in tiles.all()], } @router.get("/api/sync/status") async def sync_status() -> dict: return sync.status() @router.post("/api/telemetry") async def telemetry(sample: PerformanceSample) -> dict: sync.telemetry(sample) return {"ok": True}