Add sync clock entity for synchronized animation timing
Introduces Synchronization Clocks — shared, controllable time bases that CSS sources can optionally reference for synchronized animation. Backend: - New SyncClock dataclass, JSON store, Pydantic schemas, REST API - Runtime clock with thread-safe pause/resume/reset and speed control - Ref-counted runtime pool with eager creation for API control - clock_id field on all ColorStripSource types - Stream integration: clock time/speed replaces source-local values - Paused clock skips rendering (saves CPU + stops frame pushes) - Included in backup/restore via STORE_MAP Frontend: - Sync Clocks tab in Streams section with cards and controls - Clock dropdown in CSS editor (hidden speed slider when clock set) - Clock crosslink badge on CSS source cards (replaces speed badge) - Targets tab uses DataCache for picture/audio sources and sync clocks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ from .routes.value_sources import router as value_sources_router
|
||||
from .routes.automations import router as automations_router
|
||||
from .routes.scene_presets import router as scene_presets_router
|
||||
from .routes.webhooks import router as webhooks_router
|
||||
from .routes.sync_clocks import router as sync_clocks_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -34,5 +35,6 @@ router.include_router(picture_targets_router)
|
||||
router.include_router(automations_router)
|
||||
router.include_router(scene_presets_router)
|
||||
router.include_router(webhooks_router)
|
||||
router.include_router(sync_clocks_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -13,8 +13,10 @@ from wled_controller.storage.audio_template_store import AudioTemplateStore
|
||||
from wled_controller.storage.value_source_store import ValueSourceStore
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.backup.auto_backup import AutoBackupEngine
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
|
||||
# Global instances (initialized in main.py)
|
||||
_auto_backup_engine: AutoBackupEngine | None = None
|
||||
@@ -32,6 +34,8 @@ _processor_manager: ProcessorManager | None = None
|
||||
_automation_store: AutomationStore | None = None
|
||||
_scene_preset_store: ScenePresetStore | None = None
|
||||
_automation_engine: AutomationEngine | None = None
|
||||
_sync_clock_store: SyncClockStore | None = None
|
||||
_sync_clock_manager: SyncClockManager | None = None
|
||||
|
||||
|
||||
def get_device_store() -> DeviceStore:
|
||||
@@ -139,6 +143,20 @@ def get_auto_backup_engine() -> AutoBackupEngine:
|
||||
return _auto_backup_engine
|
||||
|
||||
|
||||
def get_sync_clock_store() -> SyncClockStore:
|
||||
"""Get sync clock store dependency."""
|
||||
if _sync_clock_store is None:
|
||||
raise RuntimeError("Sync clock store not initialized")
|
||||
return _sync_clock_store
|
||||
|
||||
|
||||
def get_sync_clock_manager() -> SyncClockManager:
|
||||
"""Get sync clock manager dependency."""
|
||||
if _sync_clock_manager is None:
|
||||
raise RuntimeError("Sync clock manager not initialized")
|
||||
return _sync_clock_manager
|
||||
|
||||
|
||||
def init_dependencies(
|
||||
device_store: DeviceStore,
|
||||
template_store: TemplateStore,
|
||||
@@ -155,12 +173,15 @@ def init_dependencies(
|
||||
scene_preset_store: ScenePresetStore | None = None,
|
||||
automation_engine: AutomationEngine | None = None,
|
||||
auto_backup_engine: AutoBackupEngine | None = None,
|
||||
sync_clock_store: SyncClockStore | None = None,
|
||||
sync_clock_manager: SyncClockManager | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
global _device_store, _template_store, _processor_manager
|
||||
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||
global _color_strip_store, _audio_source_store, _audio_template_store
|
||||
global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine
|
||||
global _sync_clock_store, _sync_clock_manager
|
||||
_device_store = device_store
|
||||
_template_store = template_store
|
||||
_processor_manager = processor_manager
|
||||
@@ -176,3 +197,5 @@ def init_dependencies(
|
||||
_scene_preset_store = scene_preset_store
|
||||
_automation_engine = automation_engine
|
||||
_auto_backup_engine = auto_backup_engine
|
||||
_sync_clock_store = sync_clock_store
|
||||
_sync_clock_manager = sync_clock_manager
|
||||
|
||||
@@ -82,6 +82,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
scale=getattr(source, "scale", None),
|
||||
mirror=getattr(source, "mirror", None),
|
||||
description=source.description,
|
||||
clock_id=source.clock_id,
|
||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||
animation=getattr(source, "animation", None),
|
||||
layers=getattr(source, "layers", None),
|
||||
@@ -177,6 +178,7 @@ async def create_color_strip_source(
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
)
|
||||
return _css_to_response(source)
|
||||
|
||||
@@ -254,6 +256,7 @@ async def update_color_strip_source(
|
||||
color_peak=data.color_peak,
|
||||
fallback_color=data.fallback_color,
|
||||
timeout=data.timeout,
|
||||
clock_id=data.clock_id,
|
||||
)
|
||||
|
||||
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||
|
||||
184
server/src/wled_controller/api/routes/sync_clocks.py
Normal file
184
server/src/wled_controller/api/routes/sync_clocks.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Sync clock routes: CRUD + runtime control for synchronization clocks."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
get_color_strip_store,
|
||||
get_sync_clock_manager,
|
||||
get_sync_clock_store,
|
||||
)
|
||||
from wled_controller.api.schemas.sync_clocks import (
|
||||
SyncClockCreate,
|
||||
SyncClockListResponse,
|
||||
SyncClockResponse,
|
||||
SyncClockUpdate,
|
||||
)
|
||||
from wled_controller.storage.sync_clock import SyncClock
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.storage.color_strip_store import ColorStripStore
|
||||
from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
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,
|
||||
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,
|
||||
)
|
||||
return _to_response(clock, manager)
|
||||
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,
|
||||
)
|
||||
# Hot-update runtime speed
|
||||
if data.speed is not None:
|
||||
manager.update_speed(clock_id, clock.speed)
|
||||
return _to_response(clock, manager)
|
||||
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),
|
||||
manager: SyncClockManager = Depends(get_sync_clock_manager),
|
||||
):
|
||||
"""Delete a synchronization clock (fails if referenced by CSS sources)."""
|
||||
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}'"
|
||||
)
|
||||
manager.release_all_for(clock_id)
|
||||
store.delete_clock(clock_id)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return _to_response(clock, manager)
|
||||
@@ -271,6 +271,7 @@ STORE_MAP = {
|
||||
"audio_sources": "audio_sources_file",
|
||||
"audio_templates": "audio_templates_file",
|
||||
"value_sources": "value_sources_file",
|
||||
"sync_clocks": "sync_clocks_file",
|
||||
"automations": "automations_file",
|
||||
"scene_presets": "scene_presets_file",
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ class ColorStripSourceCreate(BaseModel):
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)")
|
||||
timeout: Optional[float] = Field(None, description="Seconds before reverting to fallback (api_input type)", ge=0.0, le=300.0)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
|
||||
|
||||
class ColorStripSourceUpdate(BaseModel):
|
||||
@@ -134,6 +136,8 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)", ge=0.0, le=300.0)
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
|
||||
|
||||
class ColorStripSourceResponse(BaseModel):
|
||||
@@ -181,6 +185,8 @@ class ColorStripSourceResponse(BaseModel):
|
||||
# api_input-type fields
|
||||
fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)")
|
||||
timeout: Optional[float] = Field(None, description="Timeout before fallback (api_input type)")
|
||||
# sync clock
|
||||
clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation")
|
||||
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
42
server/src/wled_controller/api/schemas/sync_clocks.py
Normal file
42
server/src/wled_controller/api/schemas/sync_clocks.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Sync clock schemas (CRUD + control)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SyncClockCreate(BaseModel):
|
||||
"""Request to create a synchronization clock."""
|
||||
|
||||
name: str = Field(description="Clock name", min_length=1, max_length=100)
|
||||
speed: float = Field(default=1.0, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class SyncClockUpdate(BaseModel):
|
||||
"""Request to update a synchronization clock."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Clock name", min_length=1, max_length=100)
|
||||
speed: Optional[float] = Field(None, description="Speed multiplier (0.1–10.0)", ge=0.1, le=10.0)
|
||||
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||
|
||||
|
||||
class SyncClockResponse(BaseModel):
|
||||
"""Synchronization clock response."""
|
||||
|
||||
id: str = Field(description="Clock ID")
|
||||
name: str = Field(description="Clock name")
|
||||
speed: float = Field(description="Speed multiplier")
|
||||
description: Optional[str] = Field(None, description="Description")
|
||||
is_running: bool = Field(True, description="Whether clock is currently running")
|
||||
elapsed_time: float = Field(0.0, description="Current elapsed time in seconds")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
|
||||
|
||||
class SyncClockListResponse(BaseModel):
|
||||
"""List of synchronization clocks."""
|
||||
|
||||
clocks: List[SyncClockResponse] = Field(description="List of sync clocks")
|
||||
count: int = Field(description="Number of clocks")
|
||||
Reference in New Issue
Block a user