Files
dp/led_platform/api/http.py
T
2026-07-17 02:10:08 +08:00

158 lines
4.8 KiB
Python

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}