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:
@@ -34,6 +34,7 @@ class ColorStripSource:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
clock_id: Optional[str] = None # optional SyncClock reference
|
||||
|
||||
@property
|
||||
def sharable(self) -> bool:
|
||||
@@ -53,6 +54,7 @@ class ColorStripSource:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"clock_id": self.clock_id,
|
||||
# Subclass fields default to None for forward compat
|
||||
"picture_source_id": None,
|
||||
"fps": None,
|
||||
@@ -91,6 +93,8 @@ class ColorStripSource:
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
|
||||
clock_id: str | None = data.get("clock_id")
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
@@ -122,7 +126,7 @@ class ColorStripSource:
|
||||
return StaticColorStripSource(
|
||||
id=sid, name=name, source_type="static",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
color=color,
|
||||
clock_id=clock_id, color=color,
|
||||
led_count=data.get("led_count") or 0,
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
@@ -133,7 +137,7 @@ class ColorStripSource:
|
||||
return GradientColorStripSource(
|
||||
id=sid, name=name, source_type="gradient",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
stops=stops,
|
||||
clock_id=clock_id, stops=stops,
|
||||
led_count=data.get("led_count") or 0,
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
@@ -144,7 +148,7 @@ class ColorStripSource:
|
||||
return ColorCycleColorStripSource(
|
||||
id=sid, name=name, source_type="color_cycle",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
colors=colors,
|
||||
clock_id=clock_id, colors=colors,
|
||||
cycle_speed=float(data.get("cycle_speed") or 1.0),
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
@@ -153,7 +157,7 @@ class ColorStripSource:
|
||||
return CompositeColorStripSource(
|
||||
id=sid, name=name, source_type="composite",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
layers=data.get("layers") or [],
|
||||
clock_id=clock_id, layers=data.get("layers") or [],
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
@@ -161,7 +165,7 @@ class ColorStripSource:
|
||||
return MappedColorStripSource(
|
||||
id=sid, name=name, source_type="mapped",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
zones=data.get("zones") or [],
|
||||
clock_id=clock_id, zones=data.get("zones") or [],
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
@@ -173,7 +177,7 @@ class ColorStripSource:
|
||||
return AudioColorStripSource(
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||
clock_id=clock_id, visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||
smoothing=float(data.get("smoothing") or 0.3),
|
||||
@@ -193,7 +197,7 @@ class ColorStripSource:
|
||||
return EffectColorStripSource(
|
||||
id=sid, name=name, source_type="effect",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
effect_type=data.get("effect_type") or "fire",
|
||||
clock_id=clock_id, effect_type=data.get("effect_type") or "fire",
|
||||
speed=float(data.get("speed") or 1.0),
|
||||
led_count=data.get("led_count") or 0,
|
||||
palette=data.get("palette") or "fire",
|
||||
@@ -212,7 +216,7 @@ class ColorStripSource:
|
||||
return ApiInputColorStripSource(
|
||||
id=sid, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
led_count=data.get("led_count") or 0,
|
||||
clock_id=clock_id, led_count=data.get("led_count") or 0,
|
||||
fallback_color=fallback_color,
|
||||
timeout=float(data.get("timeout") or 5.0),
|
||||
)
|
||||
@@ -221,7 +225,7 @@ class ColorStripSource:
|
||||
return PictureColorStripSource(
|
||||
id=sid, name=name, source_type=source_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
picture_source_id=data.get("picture_source_id") or "",
|
||||
clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "",
|
||||
fps=data.get("fps") or 30,
|
||||
brightness=data["brightness"] if data.get("brightness") is not None else 1.0,
|
||||
saturation=data["saturation"] if data.get("saturation") is not None else 1.0,
|
||||
|
||||
@@ -121,6 +121,7 @@ class ColorStripStore:
|
||||
color_peak: Optional[list] = None,
|
||||
fallback_color: Optional[list] = None,
|
||||
timeout: Optional[float] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Create a new color strip source.
|
||||
|
||||
@@ -146,6 +147,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
color=rgb,
|
||||
led_count=led_count,
|
||||
animation=animation,
|
||||
@@ -158,6 +160,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
stops=stops if isinstance(stops, list) else [
|
||||
{"position": 0.0, "color": [255, 0, 0]},
|
||||
{"position": 1.0, "color": [0, 0, 255]},
|
||||
@@ -177,6 +180,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors,
|
||||
cycle_speed=float(cycle_speed) if cycle_speed else 1.0,
|
||||
led_count=led_count,
|
||||
@@ -190,6 +194,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
effect_type=effect_type or "fire",
|
||||
speed=float(speed) if speed else 1.0,
|
||||
led_count=led_count,
|
||||
@@ -209,6 +214,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
visualization_mode=visualization_mode or "spectrum",
|
||||
audio_source_id=audio_source_id or "",
|
||||
sensitivity=float(sensitivity) if sensitivity else 1.0,
|
||||
@@ -227,6 +233,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
layers=layers if isinstance(layers, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
@@ -238,6 +245,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
zones=zones if isinstance(zones, list) else [],
|
||||
led_count=led_count,
|
||||
)
|
||||
@@ -250,6 +258,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
led_count=led_count,
|
||||
fallback_color=fb,
|
||||
timeout=float(timeout) if timeout is not None else 5.0,
|
||||
@@ -264,6 +273,7 @@ class ColorStripStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
clock_id=clock_id,
|
||||
picture_source_id=picture_source_id,
|
||||
fps=fps,
|
||||
brightness=brightness,
|
||||
@@ -316,6 +326,7 @@ class ColorStripStore:
|
||||
color_peak: Optional[list] = None,
|
||||
fallback_color: Optional[list] = None,
|
||||
timeout: Optional[float] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Update an existing color strip source.
|
||||
|
||||
@@ -336,6 +347,9 @@ class ColorStripStore:
|
||||
if description is not None:
|
||||
source.description = description
|
||||
|
||||
if clock_id is not None:
|
||||
source.clock_id = clock_id if clock_id else None
|
||||
|
||||
if isinstance(source, PictureColorStripSource):
|
||||
if picture_source_id is not None:
|
||||
source.picture_source_id = picture_source_id
|
||||
|
||||
43
server/src/wled_controller/storage/sync_clock.py
Normal file
43
server/src/wled_controller/storage/sync_clock.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Synchronization clock data model.
|
||||
|
||||
A SyncClock provides a shared, controllable time base for animation-based
|
||||
color strip sources. Multiple CSS sources referencing the same clock
|
||||
animate in sync and share speed / pause / resume / reset controls.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncClock:
|
||||
"""Persistent configuration for a synchronization clock."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
speed: float # animation speed multiplier (0.1–10.0)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"speed": self.speed,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "SyncClock":
|
||||
return SyncClock(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
speed=float(data.get("speed", 1.0)),
|
||||
description=data.get("description"),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
)
|
||||
146
server/src/wled_controller/storage/sync_clock_store.py
Normal file
146
server/src/wled_controller/storage/sync_clock_store.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Synchronization clock storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.storage.sync_clock import SyncClock
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SyncClockStore:
|
||||
"""Persistent storage for synchronization clocks."""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._clocks: Dict[str, SyncClock] = {}
|
||||
self._load()
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info("Sync clock store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
clocks_data = data.get("sync_clocks", {})
|
||||
loaded = 0
|
||||
for clock_id, clock_dict in clocks_data.items():
|
||||
try:
|
||||
clock = SyncClock.from_dict(clock_dict)
|
||||
self._clocks[clock_id] = clock
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load sync clock {clock_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} sync clocks from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load sync clocks from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Sync clock store initialized with {len(self._clocks)} clocks")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"sync_clocks": {
|
||||
cid: clock.to_dict()
|
||||
for cid, clock in self._clocks.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save sync clocks to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_all_clocks(self) -> List[SyncClock]:
|
||||
return list(self._clocks.values())
|
||||
|
||||
def get_clock(self, clock_id: str) -> SyncClock:
|
||||
if clock_id not in self._clocks:
|
||||
raise ValueError(f"Sync clock not found: {clock_id}")
|
||||
return self._clocks[clock_id]
|
||||
|
||||
def create_clock(
|
||||
self,
|
||||
name: str,
|
||||
speed: float = 1.0,
|
||||
description: Optional[str] = None,
|
||||
) -> SyncClock:
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
|
||||
for clock in self._clocks.values():
|
||||
if clock.name == name:
|
||||
raise ValueError(f"Sync clock with name '{name}' already exists")
|
||||
|
||||
cid = f"sc_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
|
||||
clock = SyncClock(
|
||||
id=cid,
|
||||
name=name,
|
||||
speed=max(0.1, min(10.0, speed)),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
)
|
||||
|
||||
self._clocks[cid] = clock
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created sync clock: {name} ({cid}, speed={clock.speed})")
|
||||
return clock
|
||||
|
||||
def update_clock(
|
||||
self,
|
||||
clock_id: str,
|
||||
name: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> SyncClock:
|
||||
if clock_id not in self._clocks:
|
||||
raise ValueError(f"Sync clock not found: {clock_id}")
|
||||
|
||||
clock = self._clocks[clock_id]
|
||||
|
||||
if name is not None:
|
||||
for other in self._clocks.values():
|
||||
if other.id != clock_id and other.name == name:
|
||||
raise ValueError(f"Sync clock with name '{name}' already exists")
|
||||
clock.name = name
|
||||
|
||||
if speed is not None:
|
||||
clock.speed = max(0.1, min(10.0, speed))
|
||||
|
||||
if description is not None:
|
||||
clock.description = description
|
||||
|
||||
clock.updated_at = datetime.utcnow()
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated sync clock: {clock_id}")
|
||||
return clock
|
||||
|
||||
def delete_clock(self, clock_id: str) -> None:
|
||||
if clock_id not in self._clocks:
|
||||
raise ValueError(f"Sync clock not found: {clock_id}")
|
||||
|
||||
del self._clocks[clock_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted sync clock: {clock_id}")
|
||||
Reference in New Issue
Block a user