Init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""HTTP and WebSocket API routes."""
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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()
|
||||
|
||||
ALLOWED_ACTIONS = {
|
||||
"page.next",
|
||||
"page.prev",
|
||||
"energy.mode",
|
||||
"security.alert",
|
||||
"timeline.pause",
|
||||
"timeline.resume",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def admin_index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "admin" / "index.html")
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
|
||||
@router.get("/wall-app", include_in_schema=False)
|
||||
async def wall_app() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "output" / "index.html")
|
||||
|
||||
|
||||
@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")
|
||||
await hub.broadcast(commit, "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}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def initial_state_payload() -> dict:
|
||||
apply_at_ms = epoch_ms() + settings.initial_state_delay_ms
|
||||
state = scenes.state().model_copy(update={"apply_at_ms": apply_at_ms})
|
||||
return {
|
||||
"state": state.model_dump(mode="json"),
|
||||
"server_time_ms": epoch_ms(),
|
||||
"apply_at_ms": apply_at_ms,
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/ws/admin")
|
||||
async def admin_ws(websocket: WebSocket) -> None:
|
||||
await hub.connect("admin", websocket)
|
||||
try:
|
||||
await hub.send(websocket, CommandEnvelope(type=CommandType.STATE, payload=initial_state_payload()))
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
if raw.get("type") == CommandType.CLOCK_PING:
|
||||
await hub.send(
|
||||
websocket,
|
||||
CommandEnvelope(
|
||||
type=CommandType.CLOCK_PONG,
|
||||
payload={
|
||||
"client_send_ms": raw.get("client_send_ms"),
|
||||
"server_time_ms": epoch_ms(),
|
||||
},
|
||||
),
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
await hub.disconnect("admin", websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/output/{tile_id}")
|
||||
async def output_ws(websocket: WebSocket, tile_id: str) -> None:
|
||||
try:
|
||||
tiles.get(tile_id)
|
||||
except KeyError:
|
||||
await websocket.close(code=1008)
|
||||
return
|
||||
|
||||
node_id: str | None = None
|
||||
await hub.connect("outputs", websocket)
|
||||
try:
|
||||
await hub.send(websocket, CommandEnvelope(type=CommandType.STATE, payload=initial_state_payload()))
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
message_type = raw.get("type")
|
||||
|
||||
if message_type == CommandType.HELLO:
|
||||
node_id = str(raw.get("node_id") or f"{tile_id}-anonymous")
|
||||
node = sync.connect_node(
|
||||
node_id=node_id,
|
||||
tile_id=tile_id,
|
||||
app_version=raw.get("app_version"),
|
||||
user_agent=raw.get("user_agent"),
|
||||
)
|
||||
await hub.broadcast(
|
||||
CommandEnvelope(
|
||||
type=CommandType.HEARTBEAT,
|
||||
payload=node.model_dump(mode="json"),
|
||||
),
|
||||
"admin",
|
||||
)
|
||||
|
||||
elif message_type == CommandType.CLOCK_PING:
|
||||
await hub.send(
|
||||
websocket,
|
||||
CommandEnvelope(
|
||||
type=CommandType.CLOCK_PONG,
|
||||
payload={
|
||||
"node_id": raw.get("node_id") or node_id,
|
||||
"client_send_ms": raw.get("client_send_ms"),
|
||||
"server_time_ms": epoch_ms(),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
elif message_type == CommandType.ACK:
|
||||
node_id = str(raw.get("node_id") or node_id or "")
|
||||
sync.clock_quality(node_id, raw.get("clock_offset_ms"), raw.get("rtt_ms"))
|
||||
record = sync.ack(raw)
|
||||
await hub.broadcast(
|
||||
CommandEnvelope(
|
||||
type=CommandType.ACK,
|
||||
payload={**raw, "record": record.model_dump(mode="json") if record else None},
|
||||
),
|
||||
"admin",
|
||||
)
|
||||
|
||||
elif message_type == CommandType.TELEMETRY:
|
||||
if raw.get("node_id") and raw.get("tile_id"):
|
||||
sync.telemetry(
|
||||
PerformanceSample(
|
||||
node_id=raw["node_id"],
|
||||
tile_id=raw["tile_id"],
|
||||
fps=float(raw.get("fps") or 0),
|
||||
frame_time_ms=float(raw.get("frame_time_ms") or 0),
|
||||
dropped_frames=int(raw.get("dropped_frames") or 0),
|
||||
)
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
sync.disconnect_node(node_id)
|
||||
await hub.disconnect("outputs", websocket)
|
||||
|
||||
Reference in New Issue
Block a user