Files
ledgrab/server/src/ledgrab/api/routes/sync_clocks.py
T
alexei.dolgolyov 25c613c5cb feat(activity-log): phase 3 - event instrumentation (4 categories)
- 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
2026-06-09 19:20:57 +03:00

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)