25c613c5cb
- entity CRUD via fire_entity_event choke point (name resolved/sanitized; deletes pass name explicitly) - auth: failures + WS session establishment (no tokens logged); per-IP audit-record throttle - device: online/offline (health), discovered/lost (zeroconf), ADB connect/disconnect - capture/system: target start-stop, scenes, playlists, automations, backup/restore, update, restart, calibration, settings - security hardening: sanitize_display strips control/NUL/ANSI/newlines from untrusted strings; malformed-IPv6 origin guard - 129 instrumentation tests (incl. secret-leak, log-injection, throttle, best-effort) + autouse throttle-reset fixture
234 lines
8.0 KiB
Python
234 lines
8.0 KiB
Python
"""Sync clock routes: CRUD + runtime control for synchronization clocks."""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from ledgrab.api.auth import AuthRequired
|
|
from ledgrab.api.dependencies import (
|
|
fire_entity_event,
|
|
get_color_strip_store,
|
|
get_sync_clock_manager,
|
|
get_sync_clock_store,
|
|
get_value_source_store,
|
|
)
|
|
from ledgrab.api.schemas.sync_clocks import (
|
|
SyncClockCreate,
|
|
SyncClockListResponse,
|
|
SyncClockResponse,
|
|
SyncClockUpdate,
|
|
)
|
|
from ledgrab.storage.sync_clock import SyncClock
|
|
from ledgrab.storage.sync_clock_store import SyncClockStore
|
|
from ledgrab.storage.color_strip_store import ColorStripStore
|
|
from ledgrab.storage.value_source_store import ValueSourceStore
|
|
from ledgrab.core.processing.sync_clock_manager import SyncClockManager
|
|
from ledgrab.utils import get_logger
|
|
from ledgrab.storage.base_store import EntityNotFoundError
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockResponse:
|
|
"""Convert a SyncClock to a SyncClockResponse (with runtime state)."""
|
|
rt = manager.get_runtime(clock.id)
|
|
return SyncClockResponse(
|
|
id=clock.id,
|
|
name=clock.name,
|
|
speed=rt.speed if rt else clock.speed,
|
|
description=clock.description,
|
|
tags=clock.tags,
|
|
icon=getattr(clock, "icon", "") or "",
|
|
icon_color=getattr(clock, "icon_color", "") or "",
|
|
is_running=rt.is_running if rt else True,
|
|
elapsed_time=rt.get_time() if rt else 0.0,
|
|
created_at=clock.created_at,
|
|
updated_at=clock.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("/api/v1/sync-clocks", response_model=SyncClockListResponse, tags=["Sync Clocks"])
|
|
async def list_sync_clocks(
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""List all synchronization clocks."""
|
|
clocks = store.get_all_clocks()
|
|
return SyncClockListResponse(
|
|
clocks=[_to_response(c, manager) for c in clocks],
|
|
count=len(clocks),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/sync-clocks", response_model=SyncClockResponse, status_code=201, tags=["Sync Clocks"]
|
|
)
|
|
async def create_sync_clock(
|
|
data: SyncClockCreate,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Create a new synchronization clock."""
|
|
try:
|
|
clock = store.create_clock(
|
|
name=data.name,
|
|
speed=data.speed,
|
|
description=data.description,
|
|
tags=data.tags,
|
|
icon=data.icon,
|
|
icon_color=data.icon_color,
|
|
)
|
|
fire_entity_event("sync_clock", "created", clock.id)
|
|
return _to_response(clock, manager)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.get(
|
|
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
|
|
)
|
|
async def get_sync_clock(
|
|
clock_id: str,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Get a synchronization clock by ID."""
|
|
try:
|
|
clock = store.get_clock(clock_id)
|
|
return _to_response(clock, manager)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.put(
|
|
"/api/v1/sync-clocks/{clock_id}", response_model=SyncClockResponse, tags=["Sync Clocks"]
|
|
)
|
|
async def update_sync_clock(
|
|
clock_id: str,
|
|
data: SyncClockUpdate,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Update a synchronization clock. Speed changes are hot-applied to running streams."""
|
|
try:
|
|
clock = store.update_clock(
|
|
clock_id=clock_id,
|
|
name=data.name,
|
|
speed=data.speed,
|
|
description=data.description,
|
|
tags=data.tags,
|
|
icon=data.icon,
|
|
icon_color=data.icon_color,
|
|
)
|
|
# Hot-update runtime speed
|
|
if data.speed is not None:
|
|
manager.update_speed(clock_id, clock.speed)
|
|
fire_entity_event("sync_clock", "updated", clock_id)
|
|
return _to_response(clock, manager)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
@router.delete("/api/v1/sync-clocks/{clock_id}", status_code=204, tags=["Sync Clocks"])
|
|
async def delete_sync_clock(
|
|
clock_id: str,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
css_store: ColorStripStore = Depends(get_color_strip_store),
|
|
vs_store: ValueSourceStore = Depends(get_value_source_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Delete a synchronization clock (fails if referenced by CSS or value sources)."""
|
|
_entity_name: str | None = None
|
|
try:
|
|
_entity_name = store.get_clock(clock_id).name
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
# Check references
|
|
for source in css_store.get_all_sources():
|
|
if getattr(source, "clock_id", None) == clock_id:
|
|
raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'")
|
|
for vs in vs_store.get_all_sources():
|
|
if getattr(vs, "clock_id", None) == clock_id:
|
|
raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'")
|
|
manager.release_all_for(clock_id)
|
|
store.delete_clock(clock_id)
|
|
fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name)
|
|
except EntityNotFoundError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
# ── Runtime control ──────────────────────────────────────────────────
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/sync-clocks/{clock_id}/pause", response_model=SyncClockResponse, tags=["Sync Clocks"]
|
|
)
|
|
async def pause_sync_clock(
|
|
clock_id: str,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Pause a synchronization clock — all linked animations freeze."""
|
|
try:
|
|
clock = store.get_clock(clock_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
manager.pause(clock_id)
|
|
fire_entity_event("sync_clock", "updated", clock_id)
|
|
return _to_response(clock, manager)
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/sync-clocks/{clock_id}/resume", response_model=SyncClockResponse, tags=["Sync Clocks"]
|
|
)
|
|
async def resume_sync_clock(
|
|
clock_id: str,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Resume a paused synchronization clock."""
|
|
try:
|
|
clock = store.get_clock(clock_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
manager.resume(clock_id)
|
|
fire_entity_event("sync_clock", "updated", clock_id)
|
|
return _to_response(clock, manager)
|
|
|
|
|
|
@router.post(
|
|
"/api/v1/sync-clocks/{clock_id}/reset", response_model=SyncClockResponse, tags=["Sync Clocks"]
|
|
)
|
|
async def reset_sync_clock(
|
|
clock_id: str,
|
|
_auth: AuthRequired,
|
|
store: SyncClockStore = Depends(get_sync_clock_store),
|
|
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
|
):
|
|
"""Reset a synchronization clock to t=0 — all linked animations restart."""
|
|
try:
|
|
clock = store.get_clock(clock_id)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
manager.reset(clock_id)
|
|
fire_entity_event("sync_clock", "updated", clock_id)
|
|
return _to_response(clock, manager)
|