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")
|
||||
@@ -39,6 +39,7 @@ class StorageConfig(BaseSettings):
|
||||
value_sources_file: str = "data/value_sources.json"
|
||||
automations_file: str = "data/automations.json"
|
||||
scene_presets_file: str = "data/scene_presets.json"
|
||||
sync_clocks_file: str = "data/sync_clocks.json"
|
||||
|
||||
|
||||
class MQTTConfig(BaseSettings):
|
||||
|
||||
@@ -567,6 +567,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
@@ -651,6 +652,10 @@ class StaticColorStripStream(ColorStripStream):
|
||||
self._rebuild_colors()
|
||||
logger.info("StaticColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
self._clock = clock
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
"""Background thread: compute animated colors at target fps when animation is active.
|
||||
|
||||
@@ -666,14 +671,22 @@ class StaticColorStripStream(ColorStripStream):
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
anim = self._animation
|
||||
if anim and anim.get("enabled"):
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
speed = clock.speed
|
||||
t = clock.get_time()
|
||||
else:
|
||||
speed = float(anim.get("speed", 1.0))
|
||||
t = wall_start
|
||||
atype = anim.get("type", "breathing")
|
||||
t = loop_start
|
||||
n = self._led_count
|
||||
|
||||
if n != _pool_n:
|
||||
@@ -748,7 +761,7 @@ class StaticColorStripStream(ColorStripStream):
|
||||
except Exception as e:
|
||||
logger.error(f"StaticColorStripStream animation error: {e}")
|
||||
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
||||
time.sleep(max(sleep_target - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
@@ -773,6 +786,7 @@ class ColorCycleColorStripStream(ColorStripStream):
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
@@ -850,6 +864,10 @@ class ColorCycleColorStripStream(ColorStripStream):
|
||||
self._rebuild_colors()
|
||||
logger.info("ColorCycleColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
self._clock = clock
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
"""Background thread: interpolate between colors at target fps.
|
||||
|
||||
@@ -862,11 +880,20 @@ class ColorCycleColorStripStream(ColorStripStream):
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
color_list = self._color_list
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
speed = clock.speed
|
||||
t = clock.get_time()
|
||||
else:
|
||||
speed = self._cycle_speed
|
||||
t = wall_start
|
||||
n = self._led_count
|
||||
num = len(color_list)
|
||||
if num >= 2:
|
||||
@@ -879,7 +906,7 @@ class ColorCycleColorStripStream(ColorStripStream):
|
||||
_use_a = not _use_a
|
||||
|
||||
# 0.05 factor → one full cycle every 20s at speed=1.0
|
||||
cycle_pos = (speed * loop_start * 0.05) % 1.0
|
||||
cycle_pos = (speed * t * 0.05) % 1.0
|
||||
seg = cycle_pos * num
|
||||
idx = int(seg) % num
|
||||
t_i = seg - int(seg)
|
||||
@@ -894,7 +921,7 @@ class ColorCycleColorStripStream(ColorStripStream):
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"ColorCycleColorStripStream animation error: {e}")
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal ColorCycleColorStripStream loop error: {e}", exc_info=True)
|
||||
@@ -919,6 +946,7 @@ class GradientColorStripStream(ColorStripStream):
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
@@ -1002,6 +1030,10 @@ class GradientColorStripStream(ColorStripStream):
|
||||
self._rebuild_colors()
|
||||
logger.info("GradientColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
self._clock = clock
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
"""Background thread: apply animation effects at target fps when animation is active.
|
||||
|
||||
@@ -1022,14 +1054,22 @@ class GradientColorStripStream(ColorStripStream):
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
anim = self._animation
|
||||
if anim and anim.get("enabled"):
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
speed = clock.speed
|
||||
t = clock.get_time()
|
||||
else:
|
||||
speed = float(anim.get("speed", 1.0))
|
||||
t = wall_start
|
||||
atype = anim.get("type", "breathing")
|
||||
t = loop_start
|
||||
n = self._led_count
|
||||
stops = self._stops
|
||||
colors = None
|
||||
@@ -1147,7 +1187,7 @@ class GradientColorStripStream(ColorStripStream):
|
||||
except Exception as e:
|
||||
logger.error(f"GradientColorStripStream animation error: {e}")
|
||||
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
|
||||
time.sleep(max(sleep_target - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
|
||||
@@ -58,21 +58,46 @@ class ColorStripStreamManager:
|
||||
keyed by ``{css_id}:{consumer_id}``.
|
||||
"""
|
||||
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None):
|
||||
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None):
|
||||
"""
|
||||
Args:
|
||||
color_strip_store: ColorStripStore for resolving source configs
|
||||
live_stream_manager: LiveStreamManager for acquiring picture streams
|
||||
audio_capture_manager: AudioCaptureManager for audio-reactive sources
|
||||
audio_source_store: AudioSourceStore for resolving audio source chains
|
||||
sync_clock_manager: SyncClockManager for acquiring clock runtimes
|
||||
"""
|
||||
self._color_strip_store = color_strip_store
|
||||
self._live_stream_manager = live_stream_manager
|
||||
self._audio_capture_manager = audio_capture_manager
|
||||
self._audio_source_store = audio_source_store
|
||||
self._audio_template_store = audio_template_store
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._streams: Dict[str, _ColorStripEntry] = {}
|
||||
|
||||
def _inject_clock(self, css_stream, source) -> None:
|
||||
"""Inject a SyncClockRuntime into the stream if source has clock_id."""
|
||||
clock_id = getattr(source, "clock_id", None)
|
||||
if clock_id and self._sync_clock_manager and hasattr(css_stream, "set_clock"):
|
||||
try:
|
||||
clock_rt = self._sync_clock_manager.acquire(clock_id)
|
||||
css_stream.set_clock(clock_rt)
|
||||
logger.debug(f"Injected clock {clock_id} into stream for {source.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not inject clock {clock_id}: {e}")
|
||||
|
||||
def _release_clock(self, source_id: str, stream) -> None:
|
||||
"""Release the clock runtime acquired for a stream."""
|
||||
if not self._sync_clock_manager:
|
||||
return
|
||||
try:
|
||||
source = self._color_strip_store.get_source(source_id)
|
||||
clock_id = getattr(source, "clock_id", None)
|
||||
if clock_id:
|
||||
self._sync_clock_manager.release(clock_id)
|
||||
except Exception:
|
||||
pass # source may have been deleted already
|
||||
|
||||
def _resolve_key(self, css_id: str, consumer_id: str) -> str:
|
||||
"""Resolve internal registry key for a (css_id, consumer_id) pair.
|
||||
|
||||
@@ -123,6 +148,8 @@ class ColorStripStreamManager:
|
||||
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
|
||||
)
|
||||
css_stream = stream_cls(source)
|
||||
# Inject sync clock runtime if source references a clock
|
||||
self._inject_clock(css_stream, source)
|
||||
css_stream.start()
|
||||
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
|
||||
self._streams[key] = _ColorStripEntry(
|
||||
@@ -196,6 +223,10 @@ class ColorStripStreamManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping color strip stream {key}: {e}")
|
||||
|
||||
# Release clock runtime if acquired
|
||||
source_id = key.split(":")[0] if ":" in key else key
|
||||
self._release_clock(source_id, entry.stream)
|
||||
|
||||
picture_source_id = entry.picture_source_id
|
||||
del self._streams[key]
|
||||
logger.info(f"Removed color strip stream {key}")
|
||||
@@ -227,6 +258,25 @@ class ColorStripStreamManager:
|
||||
for key in matching_keys:
|
||||
entry = self._streams[key]
|
||||
entry.stream.update_source(new_source)
|
||||
# Hot-swap clock if clock_id changed
|
||||
if hasattr(entry.stream, "set_clock") and self._sync_clock_manager:
|
||||
new_clock_id = getattr(new_source, "clock_id", None)
|
||||
old_clock = getattr(entry.stream, "_clock", None)
|
||||
if new_clock_id:
|
||||
try:
|
||||
clock_rt = self._sync_clock_manager.acquire(new_clock_id)
|
||||
entry.stream.set_clock(clock_rt)
|
||||
# Release old clock if different
|
||||
if old_clock:
|
||||
# Find the old clock_id (best-effort)
|
||||
source_id = key.split(":")[0] if ":" in key else key
|
||||
self._release_clock(source_id, entry.stream)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}")
|
||||
elif old_clock:
|
||||
entry.stream.set_clock(None)
|
||||
source_id = key.split(":")[0] if ":" in key else key
|
||||
self._release_clock(source_id, entry.stream)
|
||||
|
||||
# Track picture_source_id change for future reference counting
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
|
||||
@@ -182,6 +182,8 @@ class EffectColorStripStream(ColorStripStream):
|
||||
self._running = False
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._fps = 30
|
||||
self._clock = None # optional SyncClockRuntime
|
||||
self._effective_speed = 1.0 # resolved speed (from clock or source)
|
||||
self._noise = _ValueNoise1D(seed=42)
|
||||
# Fire state — allocated lazily in render loop
|
||||
self._heat: Optional[np.ndarray] = None
|
||||
@@ -268,6 +270,10 @@ class EffectColorStripStream(ColorStripStream):
|
||||
self._led_count = prev_led_count
|
||||
logger.info("EffectColorStripStream params updated in-place")
|
||||
|
||||
def set_clock(self, clock) -> None:
|
||||
"""Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
|
||||
self._clock = clock
|
||||
|
||||
# ── Main animation loop ──────────────────────────────────────────
|
||||
|
||||
def _animate_loop(self) -> None:
|
||||
@@ -287,9 +293,22 @@ class EffectColorStripStream(ColorStripStream):
|
||||
try:
|
||||
with high_resolution_timer():
|
||||
while self._running:
|
||||
loop_start = time.perf_counter()
|
||||
wall_start = time.perf_counter()
|
||||
frame_time = 1.0 / self._fps
|
||||
try:
|
||||
# Resolve animation time and speed from clock or local
|
||||
clock = self._clock
|
||||
if clock:
|
||||
if not clock.is_running:
|
||||
# Clock paused — output frozen, sleep and skip render
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
anim_time = clock.get_time()
|
||||
self._effective_speed = clock.speed
|
||||
else:
|
||||
anim_time = wall_start
|
||||
self._effective_speed = self._speed
|
||||
|
||||
n = self._led_count
|
||||
if n != _pool_n:
|
||||
_pool_n = n
|
||||
@@ -310,14 +329,14 @@ class EffectColorStripStream(ColorStripStream):
|
||||
_use_a = not _use_a
|
||||
|
||||
render_fn = renderers.get(self._effect_type, self._render_fire)
|
||||
render_fn(buf, n, loop_start)
|
||||
render_fn(buf, n, anim_time)
|
||||
|
||||
with self._colors_lock:
|
||||
self._colors = buf
|
||||
except Exception as e:
|
||||
logger.error(f"EffectColorStripStream render error: {e}")
|
||||
|
||||
elapsed = time.perf_counter() - loop_start
|
||||
elapsed = time.perf_counter() - wall_start
|
||||
time.sleep(max(frame_time - elapsed, 0.001))
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True)
|
||||
@@ -332,7 +351,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
A 1-D heat array cools, diffuses upward, and receives random sparks
|
||||
at the bottom. Heat values are mapped to the palette LUT.
|
||||
"""
|
||||
speed = self._speed
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
lut = self._palette_lut
|
||||
|
||||
@@ -378,7 +397,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Bright meteor head with exponential-decay trail."""
|
||||
speed = self._speed
|
||||
speed = self._effective_speed
|
||||
intensity = self._intensity
|
||||
color = self._color
|
||||
mirror = self._mirror
|
||||
@@ -443,7 +462,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Overlapping sine waves creating colorful plasma patterns."""
|
||||
speed = self._speed
|
||||
speed = self._effective_speed
|
||||
scale = self._scale
|
||||
lut = self._palette_lut
|
||||
|
||||
@@ -470,7 +489,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Smooth scrolling fractal noise mapped to a color palette."""
|
||||
speed = self._speed
|
||||
speed = self._effective_speed
|
||||
scale = self._scale
|
||||
lut = self._palette_lut
|
||||
|
||||
@@ -488,7 +507,7 @@ class EffectColorStripStream(ColorStripStream):
|
||||
|
||||
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
|
||||
"""Layered noise bands simulating aurora borealis."""
|
||||
speed = self._speed
|
||||
speed = self._effective_speed
|
||||
scale = self._scale
|
||||
intensity = self._intensity
|
||||
lut = self._palette_lut
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProcessorManager:
|
||||
Targets are registered for processing via polymorphic TargetProcessor subclasses.
|
||||
"""
|
||||
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None):
|
||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None):
|
||||
"""Initialize processor manager."""
|
||||
self._devices: Dict[str, DeviceState] = {}
|
||||
self._processors: Dict[str, TargetProcessor] = {}
|
||||
@@ -89,12 +89,14 @@ class ProcessorManager:
|
||||
picture_source_store, capture_template_store, pp_template_store
|
||||
)
|
||||
self._audio_capture_manager = AudioCaptureManager()
|
||||
self._sync_clock_manager = sync_clock_manager
|
||||
self._color_strip_stream_manager = ColorStripStreamManager(
|
||||
color_strip_store=color_strip_store,
|
||||
live_stream_manager=self._live_stream_manager,
|
||||
audio_capture_manager=self._audio_capture_manager,
|
||||
audio_source_store=audio_source_store,
|
||||
audio_template_store=audio_template_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
)
|
||||
self._value_stream_manager = ValueStreamManager(
|
||||
value_source_store=value_source_store,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Ref-counted pool of SyncClockRuntime instances.
|
||||
|
||||
Runtimes are created lazily when a stream first acquires a clock and
|
||||
destroyed when the last consumer releases it.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime
|
||||
from wled_controller.storage.sync_clock_store import SyncClockStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SyncClockManager:
|
||||
"""Manages SyncClockRuntime instances with reference counting."""
|
||||
|
||||
def __init__(self, store: SyncClockStore) -> None:
|
||||
self._store = store
|
||||
self._runtimes: Dict[str, SyncClockRuntime] = {}
|
||||
self._ref_counts: Dict[str, int] = {}
|
||||
|
||||
# ── Acquire / release (used by stream manager) ────────────────
|
||||
|
||||
def acquire(self, clock_id: str) -> SyncClockRuntime:
|
||||
"""Get or create a runtime for *clock_id* (ref-counted)."""
|
||||
if clock_id in self._runtimes:
|
||||
self._ref_counts[clock_id] += 1
|
||||
logger.debug(f"SyncClock {clock_id} ref++ → {self._ref_counts[clock_id]}")
|
||||
return self._runtimes[clock_id]
|
||||
|
||||
clock_cfg = self._store.get_clock(clock_id) # raises ValueError if missing
|
||||
rt = SyncClockRuntime(speed=clock_cfg.speed)
|
||||
self._runtimes[clock_id] = rt
|
||||
self._ref_counts[clock_id] = 1
|
||||
logger.info(f"SyncClock runtime created: {clock_id} (speed={clock_cfg.speed})")
|
||||
return rt
|
||||
|
||||
def release(self, clock_id: str) -> None:
|
||||
"""Decrement ref count; destroy runtime when it reaches zero."""
|
||||
if clock_id not in self._ref_counts:
|
||||
return
|
||||
self._ref_counts[clock_id] -= 1
|
||||
logger.debug(f"SyncClock {clock_id} ref-- → {self._ref_counts[clock_id]}")
|
||||
if self._ref_counts[clock_id] <= 0:
|
||||
del self._runtimes[clock_id]
|
||||
del self._ref_counts[clock_id]
|
||||
logger.info(f"SyncClock runtime destroyed: {clock_id}")
|
||||
|
||||
def release_all_for(self, clock_id: str) -> None:
|
||||
"""Force-release all references to *clock_id* (used on delete)."""
|
||||
self._runtimes.pop(clock_id, None)
|
||||
self._ref_counts.pop(clock_id, None)
|
||||
|
||||
def release_all(self) -> None:
|
||||
"""Destroy all runtimes (shutdown)."""
|
||||
self._runtimes.clear()
|
||||
self._ref_counts.clear()
|
||||
|
||||
# ── Lookup (no ref counting) ──────────────────────────────────
|
||||
|
||||
def get_runtime(self, clock_id: str) -> Optional[SyncClockRuntime]:
|
||||
"""Return an existing runtime or *None* (does not create one)."""
|
||||
return self._runtimes.get(clock_id)
|
||||
|
||||
def _ensure_runtime(self, clock_id: str) -> SyncClockRuntime:
|
||||
"""Return existing runtime or create a zero-ref one for API control."""
|
||||
rt = self._runtimes.get(clock_id)
|
||||
if rt:
|
||||
return rt
|
||||
clock_cfg = self._store.get_clock(clock_id)
|
||||
rt = SyncClockRuntime(speed=clock_cfg.speed)
|
||||
self._runtimes[clock_id] = rt
|
||||
self._ref_counts[clock_id] = 0
|
||||
logger.info(f"SyncClock runtime created (API): {clock_id} (speed={clock_cfg.speed})")
|
||||
return rt
|
||||
|
||||
# ── Delegated control ─────────────────────────────────────────
|
||||
|
||||
def update_speed(self, clock_id: str, speed: float) -> None:
|
||||
rt = self._ensure_runtime(clock_id)
|
||||
rt.speed = speed
|
||||
logger.info(f"SyncClock {clock_id} speed → {speed}")
|
||||
|
||||
def pause(self, clock_id: str) -> None:
|
||||
rt = self._ensure_runtime(clock_id)
|
||||
rt.pause()
|
||||
logger.info(f"SyncClock {clock_id} paused")
|
||||
|
||||
def resume(self, clock_id: str) -> None:
|
||||
rt = self._ensure_runtime(clock_id)
|
||||
rt.resume()
|
||||
logger.info(f"SyncClock {clock_id} resumed")
|
||||
|
||||
def reset(self, clock_id: str) -> None:
|
||||
rt = self._ensure_runtime(clock_id)
|
||||
rt.reset()
|
||||
logger.info(f"SyncClock {clock_id} reset")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Thread-safe synchronization clock runtime.
|
||||
|
||||
Provides a pause-aware elapsed-time counter and a shared speed multiplier
|
||||
that animation streams read every frame.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class SyncClockRuntime:
|
||||
"""In-memory clock instance used by animation streams for synchronized timing.
|
||||
|
||||
``get_time()`` returns pause-aware **real** elapsed seconds (NOT
|
||||
speed-scaled). Streams combine this with ``speed`` themselves so that
|
||||
per-frame physics effects (fire cooling, sparkle density) also scale
|
||||
with clock speed uniformly.
|
||||
"""
|
||||
|
||||
__slots__ = ("_speed", "_running", "_epoch", "_offset", "_lock")
|
||||
|
||||
def __init__(self, speed: float = 1.0) -> None:
|
||||
self._speed: float = speed
|
||||
self._running: bool = True
|
||||
self._epoch: float = time.perf_counter()
|
||||
self._offset: float = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ── Speed ──────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def speed(self) -> float:
|
||||
"""Current speed multiplier (lock-free read; CPython float is atomic)."""
|
||||
return self._speed
|
||||
|
||||
@speed.setter
|
||||
def speed(self, value: float) -> None:
|
||||
self._speed = value
|
||||
|
||||
# ── Time ───────────────────────────────────────────────────────
|
||||
|
||||
def get_time(self) -> float:
|
||||
"""Pause-aware elapsed seconds since creation/last reset.
|
||||
|
||||
Returns *real* (wall-clock) elapsed time, not speed-scaled.
|
||||
"""
|
||||
if not self._running:
|
||||
return self._offset
|
||||
return self._offset + (time.perf_counter() - self._epoch)
|
||||
|
||||
# ── Control ────────────────────────────────────────────────────
|
||||
|
||||
def pause(self) -> None:
|
||||
with self._lock:
|
||||
if self._running:
|
||||
self._offset += time.perf_counter() - self._epoch
|
||||
self._running = False
|
||||
|
||||
def resume(self) -> None:
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
self._epoch = time.perf_counter()
|
||||
self._running = True
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._offset = 0.0
|
||||
self._epoch = time.perf_counter()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
@@ -29,6 +29,8 @@ import wled_controller.core.audio # noqa: F401 — trigger engine auto-registra
|
||||
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.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.core.mqtt.mqtt_service import MQTTService
|
||||
from wled_controller.core.devices.mqtt_client import set_mqtt_service
|
||||
@@ -56,6 +58,8 @@ audio_template_store = AudioTemplateStore(config.storage.audio_templates_file)
|
||||
value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
||||
automation_store = AutomationStore(config.storage.automations_file)
|
||||
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
|
||||
sync_clock_store = SyncClockStore(config.storage.sync_clocks_file)
|
||||
sync_clock_manager = SyncClockManager(sync_clock_store)
|
||||
|
||||
processor_manager = ProcessorManager(
|
||||
picture_source_store=picture_source_store,
|
||||
@@ -67,6 +71,7 @@ processor_manager = ProcessorManager(
|
||||
audio_source_store=audio_source_store,
|
||||
value_source_store=value_source_store,
|
||||
audio_template_store=audio_template_store,
|
||||
sync_clock_manager=sync_clock_manager,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,6 +140,8 @@ async def lifespan(app: FastAPI):
|
||||
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,
|
||||
)
|
||||
|
||||
# Register devices in processor manager for health monitoring
|
||||
|
||||
@@ -107,7 +107,7 @@ import {
|
||||
// Layer 5: color-strip sources
|
||||
import {
|
||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
|
||||
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, updateEffectPreview,
|
||||
colorCycleAddColor, colorCycleRemoveColor,
|
||||
compositeAddLayer, compositeRemoveLayer,
|
||||
mappedAddZone, mappedRemoveZone,
|
||||
@@ -363,6 +363,7 @@ Object.assign(window, {
|
||||
deleteColorStrip,
|
||||
onCSSTypeChange,
|
||||
onEffectTypeChange,
|
||||
onCSSClockChange,
|
||||
onAnimationTypeChange,
|
||||
updateEffectPreview,
|
||||
colorCycleAddColor,
|
||||
|
||||
@@ -184,6 +184,9 @@ export function set_audioTemplateNameManuallyEdited(v) { _audioTemplateNameManua
|
||||
// Value sources
|
||||
export let _cachedValueSources = [];
|
||||
|
||||
// Sync clocks
|
||||
export let _cachedSyncClocks = [];
|
||||
|
||||
// Automations
|
||||
export let _automationsCache = null;
|
||||
|
||||
@@ -234,6 +237,12 @@ export const valueSourcesCache = new DataCache({
|
||||
});
|
||||
valueSourcesCache.subscribe(v => { _cachedValueSources = v; });
|
||||
|
||||
export const syncClocksCache = new DataCache({
|
||||
endpoint: '/sync-clocks',
|
||||
extractData: json => json.clocks || [],
|
||||
});
|
||||
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
|
||||
|
||||
export const filtersCache = new DataCache({
|
||||
endpoint: '/filters',
|
||||
extractData: json => json.filters || [],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { _cachedSyncClocks } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
|
||||
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
|
||||
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
|
||||
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY,
|
||||
ICON_LINK, ICON_SPARKLES, ICON_FAST_FORWARD, ICON_ACTIVITY, ICON_CLOCK,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
|
||||
@@ -58,6 +59,7 @@ class CSSEditorModal extends Modal {
|
||||
audio_mirror: document.getElementById('css-editor-audio-mirror').checked,
|
||||
api_input_fallback_color: document.getElementById('css-editor-api-input-fallback-color').value,
|
||||
api_input_timeout: document.getElementById('css-editor-api-input-timeout').value,
|
||||
clock_id: document.getElementById('css-editor-clock').value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -114,6 +116,11 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-led-count-group').style.display =
|
||||
(type === 'composite' || type === 'mapped' || type === 'audio' || type === 'api_input') ? 'none' : '';
|
||||
|
||||
// Sync clock — shown for animated types (static, gradient, color_cycle, effect)
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
document.getElementById('css-editor-clock-group').style.display = clockTypes.includes(type) ? '' : 'none';
|
||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||
|
||||
if (type === 'audio') {
|
||||
_loadAudioSources();
|
||||
} else if (type === 'composite') {
|
||||
@@ -125,6 +132,27 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
}
|
||||
|
||||
function _populateClockDropdown(selectedId) {
|
||||
const sel = document.getElementById('css-editor-clock');
|
||||
const prev = selectedId !== undefined ? selectedId : sel.value;
|
||||
sel.innerHTML = `<option value="">${t('common.none')}</option>` +
|
||||
_cachedSyncClocks.map(c => `<option value="${c.id}">${escapeHtml(c.name)} (${c.speed}x)</option>`).join('');
|
||||
sel.value = prev || '';
|
||||
}
|
||||
|
||||
export function onCSSClockChange() {
|
||||
// When a clock is selected, hide speed sliders (speed comes from clock)
|
||||
const clockId = document.getElementById('css-editor-clock').value;
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
if (type === 'effect') {
|
||||
document.getElementById('css-editor-effect-speed-group').style.display = clockId ? 'none' : '';
|
||||
} else if (type === 'color_cycle') {
|
||||
document.getElementById('css-editor-cycle-speed-group').style.display = clockId ? 'none' : '';
|
||||
} else if (type === 'static' || type === 'gradient') {
|
||||
document.getElementById('css-editor-animation-speed-group').style.display = clockId ? 'none' : '';
|
||||
}
|
||||
}
|
||||
|
||||
function _getAnimationPayload() {
|
||||
const type = document.getElementById('css-editor-animation-type').value;
|
||||
return {
|
||||
@@ -589,10 +617,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
const isAudio = source.source_type === 'audio';
|
||||
const isApiInput = source.source_type === 'api_input';
|
||||
|
||||
// Clock crosslink badge (replaces speed badge when clock is assigned)
|
||||
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
||||
const clockBadge = clockObj
|
||||
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
|
||||
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
|
||||
|
||||
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
|
||||
const animBadge = anim
|
||||
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
|
||||
+ `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`
|
||||
+ (source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.animation.speed')}">${ICON_FAST_FORWARD} ${(anim.speed || 1.0).toFixed(1)}×</span>`)
|
||||
: '';
|
||||
|
||||
let propsHtml;
|
||||
@@ -604,6 +638,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||
${animBadge}
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isColorCycle) {
|
||||
const colors = source.colors || [];
|
||||
@@ -612,8 +647,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
).join('');
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${swatches}</span>
|
||||
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
|
||||
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">${ICON_FAST_FORWARD} ${(source.cycle_speed || 1.0).toFixed(1)}×</span>`}
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isGradient) {
|
||||
const stops = source.stops || [];
|
||||
@@ -636,6 +672,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||
${animBadge}
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isEffect) {
|
||||
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
|
||||
@@ -643,8 +680,9 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
|
||||
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
|
||||
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>
|
||||
${source.clock_id ? '' : `<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">${ICON_FAST_FORWARD} ${(source.speed || 1.0).toFixed(1)}×</span>`}
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||
${clockBadge}
|
||||
`;
|
||||
} else if (isComposite) {
|
||||
const layerCount = (source.layers || []).length;
|
||||
@@ -762,6 +800,12 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-type').value = sourceType;
|
||||
onCSSTypeChange();
|
||||
|
||||
// Set clock dropdown value (must be after onCSSTypeChange populates it)
|
||||
if (css.clock_id) {
|
||||
_populateClockDropdown(css.clock_id);
|
||||
onCSSClockChange();
|
||||
}
|
||||
|
||||
if (sourceType === 'static') {
|
||||
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
||||
_loadAnimationState(css.animation);
|
||||
@@ -1040,6 +1084,13 @@ export async function saveCSSEditor() {
|
||||
if (!cssId) payload.source_type = 'picture';
|
||||
}
|
||||
|
||||
// Attach clock_id for animated types
|
||||
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect'];
|
||||
if (clockTypes.includes(sourceType)) {
|
||||
const clockVal = document.getElementById('css-editor-clock').value;
|
||||
payload.clock_id = clockVal || null;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssId) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||
_cachedAudioSources,
|
||||
_cachedValueSources,
|
||||
_cachedSyncClocks,
|
||||
_cachedAudioTemplates,
|
||||
availableAudioEngines, setAvailableAudioEngines,
|
||||
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
_sourcesLoading, set_sourcesLoading,
|
||||
apiKey,
|
||||
streamsCache, ppTemplatesCache, captureTemplatesCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, filtersCache,
|
||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -38,10 +39,11 @@ import { openDisplayPicker, formatDisplayLabel } from './displays.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateSubTabHash } from './tabs.js';
|
||||
import { createValueSourceCard } from './value-sources.js';
|
||||
import { createSyncClockCard } from './sync-clocks.js';
|
||||
import {
|
||||
getEngineIcon, getPictureSourceIcon, getAudioSourceIcon,
|
||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
|
||||
} from '../core/icons.js';
|
||||
@@ -57,6 +59,7 @@ const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.grou
|
||||
const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' });
|
||||
const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' });
|
||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
|
||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
|
||||
|
||||
// Re-render picture sources when language changes
|
||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||
@@ -1111,6 +1114,7 @@ export async function loadPictureSources() {
|
||||
captureTemplatesCache.fetch(),
|
||||
audioSourcesCache.fetch(),
|
||||
valueSourcesCache.fetch(),
|
||||
syncClocksCache.fetch(),
|
||||
audioTemplatesCache.fetch(),
|
||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||
]);
|
||||
@@ -1144,6 +1148,7 @@ const _streamSectionMap = {
|
||||
processed: [csProcStreams, csProcTemplates],
|
||||
audio: [csAudioMulti, csAudioMono],
|
||||
value: [csValueSources],
|
||||
sync: [csSyncClocks],
|
||||
};
|
||||
|
||||
export function expandAllStreamSections() {
|
||||
@@ -1292,6 +1297,7 @@ function renderPictureSourcesList(streams) {
|
||||
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
|
||||
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
|
||||
];
|
||||
|
||||
const tabBar = `<div class="stream-tab-bar">${tabs.map(tab =>
|
||||
@@ -1389,6 +1395,7 @@ function renderPictureSourcesList(streams) {
|
||||
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
|
||||
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||
|
||||
if (csRawStreams.isMounted()) {
|
||||
// Incremental update: reconcile cards in-place
|
||||
@@ -1405,6 +1412,7 @@ function renderPictureSourcesList(streams) {
|
||||
csAudioTemplates.reconcile(audioTemplateItems);
|
||||
csStaticStreams.reconcile(staticItems);
|
||||
csValueSources.reconcile(valueItems);
|
||||
csSyncClocks.reconcile(syncClockItems);
|
||||
} else {
|
||||
// First render: build full HTML
|
||||
const panels = tabs.map(tab => {
|
||||
@@ -1413,12 +1421,13 @@ function renderPictureSourcesList(streams) {
|
||||
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
|
||||
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
|
||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
|
||||
else panelContent = csStaticStreams.render(staticItems);
|
||||
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = tabBar + panels;
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources]);
|
||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
221
server/src/wled_controller/static/js/features/sync-clocks.js
Normal file
221
server/src/wled_controller/static/js/features/sync-clocks.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Sync Clocks — CRUD, runtime controls, cards.
|
||||
*/
|
||||
|
||||
import { _cachedSyncClocks, syncClocksCache } from '../core/state.js';
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
class SyncClockModal extends Modal {
|
||||
constructor() { super('sync-clock-modal'); }
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('sync-clock-name').value,
|
||||
speed: document.getElementById('sync-clock-speed').value,
|
||||
description: document.getElementById('sync-clock-description').value,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const syncClockModal = new SyncClockModal();
|
||||
|
||||
// ── Show / Close ──
|
||||
|
||||
export async function showSyncClockModal(editData) {
|
||||
const isEdit = !!editData;
|
||||
const titleKey = isEdit ? 'sync_clock.edit' : 'sync_clock.add';
|
||||
document.getElementById('sync-clock-modal-title').innerHTML = `${ICON_CLOCK} ${t(titleKey)}`;
|
||||
document.getElementById('sync-clock-id').value = isEdit ? editData.id : '';
|
||||
document.getElementById('sync-clock-error').style.display = 'none';
|
||||
|
||||
if (isEdit) {
|
||||
document.getElementById('sync-clock-name').value = editData.name || '';
|
||||
document.getElementById('sync-clock-speed').value = editData.speed ?? 1.0;
|
||||
document.getElementById('sync-clock-speed-display').textContent = editData.speed ?? 1.0;
|
||||
document.getElementById('sync-clock-description').value = editData.description || '';
|
||||
} else {
|
||||
document.getElementById('sync-clock-name').value = '';
|
||||
document.getElementById('sync-clock-speed').value = 1.0;
|
||||
document.getElementById('sync-clock-speed-display').textContent = '1';
|
||||
document.getElementById('sync-clock-description').value = '';
|
||||
}
|
||||
|
||||
syncClockModal.open();
|
||||
syncClockModal.snapshot();
|
||||
}
|
||||
|
||||
export async function closeSyncClockModal() {
|
||||
await syncClockModal.close();
|
||||
}
|
||||
|
||||
// ── Save ──
|
||||
|
||||
export async function saveSyncClock() {
|
||||
const id = document.getElementById('sync-clock-id').value;
|
||||
const name = document.getElementById('sync-clock-name').value.trim();
|
||||
const speed = parseFloat(document.getElementById('sync-clock-speed').value);
|
||||
const description = document.getElementById('sync-clock-description').value.trim() || null;
|
||||
|
||||
if (!name) {
|
||||
syncClockModal.showError(t('sync_clock.error.name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, speed, description };
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const url = id ? `/sync-clocks/${id}` : '/sync-clocks';
|
||||
const resp = await fetchWithAuth(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t(id ? 'sync_clock.updated' : 'sync_clock.created'), 'success');
|
||||
syncClockModal.forceClose();
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
syncClockModal.showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edit / Clone / Delete ──
|
||||
|
||||
export async function editSyncClock(clockId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
await showSyncClockModal(data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function cloneSyncClock(clockId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`);
|
||||
if (!resp.ok) throw new Error(t('sync_clock.error.load'));
|
||||
const data = await resp.json();
|
||||
delete data.id;
|
||||
data.name = data.name + ' (copy)';
|
||||
await showSyncClockModal(data);
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSyncClock(clockId) {
|
||||
const confirmed = await showConfirm(t('sync_clock.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||
}
|
||||
showToast(t('sync_clock.deleted'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Runtime controls ──
|
||||
|
||||
export async function pauseSyncClock(clockId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
showToast(t('sync_clock.paused'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeSyncClock(clockId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
showToast(t('sync_clock.resumed'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetSyncClock(clockId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
showToast(t('sync_clock.reset_done'), 'success');
|
||||
await loadPictureSources();
|
||||
} catch (e) {
|
||||
if (e.isAuth) return;
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card rendering ──
|
||||
|
||||
export function createSyncClockCard(clock) {
|
||||
const statusIcon = clock.is_running ? ICON_START : ICON_PAUSE;
|
||||
const statusLabel = clock.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
|
||||
const toggleAction = clock.is_running
|
||||
? `pauseSyncClock('${clock.id}')`
|
||||
: `resumeSyncClock('${clock.id}')`;
|
||||
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
|
||||
|
||||
return wrapCard({
|
||||
type: 'template-card',
|
||||
dataAttr: 'data-id',
|
||||
id: clock.id,
|
||||
removeOnclick: `deleteSyncClock('${clock.id}')`,
|
||||
removeTitle: t('common.delete'),
|
||||
content: `
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">${ICON_CLOCK} ${escapeHtml(clock.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
|
||||
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
|
||||
</div>
|
||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); resetSyncClock('${clock.id}')" title="${t('sync_clock.action.reset')}">${ICON_CLOCK}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneSyncClock('${clock.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editSyncClock('${clock.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Expose to global scope for inline onclick handlers ──
|
||||
|
||||
window.showSyncClockModal = showSyncClockModal;
|
||||
window.closeSyncClockModal = closeSyncClockModal;
|
||||
window.saveSyncClock = saveSyncClock;
|
||||
window.editSyncClock = editSyncClock;
|
||||
window.cloneSyncClock = cloneSyncClock;
|
||||
window.deleteSyncClock = deleteSyncClock;
|
||||
window.pauseSyncClock = pauseSyncClock;
|
||||
window.resumeSyncClock = resumeSyncClock;
|
||||
window.resetSyncClock = resetSyncClock;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
kcWebSockets,
|
||||
ledPreviewWebSockets,
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
streamsCache, audioSourcesCache, syncClocksCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -475,15 +476,17 @@ export async function loadTargetsTab() {
|
||||
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, CSS sources, picture sources, pattern templates, and value sources in parallel
|
||||
const [devicesResp, targetsResp, cssResp, psResp, patResp, valueSrcArr, asResp] = await Promise.all([
|
||||
// Fetch devices, targets, CSS sources, pattern templates in parallel;
|
||||
// use DataCache for picture sources, audio sources, value sources, sync clocks
|
||||
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||
fetchWithAuth('/devices'),
|
||||
fetchWithAuth('/picture-targets'),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
streamsCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/audio-sources').catch(() => null),
|
||||
audioSourcesCache.fetch().catch(() => []),
|
||||
syncClocksCache.fetch().catch(() => []),
|
||||
]);
|
||||
|
||||
const devicesData = await devicesResp.json();
|
||||
@@ -499,10 +502,7 @@ export async function loadTargetsTab() {
|
||||
}
|
||||
|
||||
let pictureSourceMap = {};
|
||||
if (psResp && psResp.ok) {
|
||||
const psData = await psResp.json();
|
||||
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
|
||||
}
|
||||
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
|
||||
|
||||
let patternTemplates = [];
|
||||
let patternTemplateMap = {};
|
||||
@@ -516,10 +516,7 @@ export async function loadTargetsTab() {
|
||||
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
|
||||
|
||||
let audioSourceMap = {};
|
||||
if (asResp && asResp.ok) {
|
||||
const asData = await asResp.json();
|
||||
(asData.sources || []).forEach(s => { audioSourceMap[s.id] = s; });
|
||||
}
|
||||
asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
|
||||
|
||||
// Fetch all device states, target states, and target metrics in batch
|
||||
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"common.clone": "Clone",
|
||||
"common.none": "None",
|
||||
"section.filter.placeholder": "Filter...",
|
||||
"section.filter.reset": "Clear filter",
|
||||
"section.expand_all": "Expand all sections",
|
||||
@@ -950,6 +951,7 @@
|
||||
"audio_template.error.required": "Please fill in all required fields",
|
||||
"audio_template.error.delete": "Failed to delete audio template",
|
||||
"streams.group.value": "Value Sources",
|
||||
"streams.group.sync": "Sync Clocks",
|
||||
"value_source.group.title": "Value Sources",
|
||||
"value_source.add": "Add Value Source",
|
||||
"value_source.edit": "Edit Value Source",
|
||||
@@ -1137,5 +1139,32 @@
|
||||
"theme.switched.dark": "Switched to dark theme",
|
||||
"theme.switched.light": "Switched to light theme",
|
||||
"accent.color.updated": "Accent color updated",
|
||||
"search.footer": "↑↓ navigate · Enter select · Esc close"
|
||||
"search.footer": "↑↓ navigate · Enter select · Esc close",
|
||||
"sync_clock.group.title": "Sync Clocks",
|
||||
"sync_clock.add": "Add Sync Clock",
|
||||
"sync_clock.edit": "Edit Sync Clock",
|
||||
"sync_clock.name": "Name:",
|
||||
"sync_clock.name.placeholder": "Main Animation Clock",
|
||||
"sync_clock.name.hint": "A descriptive name for this synchronization clock",
|
||||
"sync_clock.speed": "Speed:",
|
||||
"sync_clock.speed.hint": "Speed multiplier shared by all linked sources. 1.0 = normal speed.",
|
||||
"sync_clock.description": "Description (optional):",
|
||||
"sync_clock.description.placeholder": "Optional description",
|
||||
"sync_clock.description.hint": "Optional notes about this clock's purpose",
|
||||
"sync_clock.status.running": "Running",
|
||||
"sync_clock.status.paused": "Paused",
|
||||
"sync_clock.action.pause": "Pause",
|
||||
"sync_clock.action.resume": "Resume",
|
||||
"sync_clock.action.reset": "Reset",
|
||||
"sync_clock.error.name_required": "Clock name is required",
|
||||
"sync_clock.error.load": "Failed to load sync clock",
|
||||
"sync_clock.created": "Sync clock created",
|
||||
"sync_clock.updated": "Sync clock updated",
|
||||
"sync_clock.deleted": "Sync clock deleted",
|
||||
"sync_clock.paused": "Clock paused",
|
||||
"sync_clock.resumed": "Clock resumed",
|
||||
"sync_clock.reset_done": "Clock reset to zero",
|
||||
"sync_clock.delete.confirm": "Delete this sync clock? Sources using it will revert to their own speed.",
|
||||
"color_strip.clock": "Sync Clock:",
|
||||
"color_strip.clock.hint": "Link to a sync clock for synchronized animation. When set, speed comes from the clock."
|
||||
}
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"common.delete": "Удалить",
|
||||
"common.edit": "Редактировать",
|
||||
"common.clone": "Клонировать",
|
||||
"common.none": "Нет",
|
||||
"section.filter.placeholder": "Фильтр...",
|
||||
"section.filter.reset": "Очистить фильтр",
|
||||
"section.expand_all": "Развернуть все секции",
|
||||
@@ -950,6 +951,7 @@
|
||||
"audio_template.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
|
||||
"streams.group.value": "Источники значений",
|
||||
"streams.group.sync": "Часы синхронизации",
|
||||
"value_source.group.title": "Источники значений",
|
||||
"value_source.add": "Добавить источник значений",
|
||||
"value_source.edit": "Редактировать источник значений",
|
||||
@@ -1137,5 +1139,32 @@
|
||||
"theme.switched.dark": "Переключено на тёмную тему",
|
||||
"theme.switched.light": "Переключено на светлую тему",
|
||||
"accent.color.updated": "Цвет акцента обновлён",
|
||||
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть"
|
||||
"search.footer": "↑↓ навигация · Enter выбор · Esc закрыть",
|
||||
"sync_clock.group.title": "Часы синхронизации",
|
||||
"sync_clock.add": "Добавить часы",
|
||||
"sync_clock.edit": "Редактировать часы",
|
||||
"sync_clock.name": "Название:",
|
||||
"sync_clock.name.placeholder": "Основные часы анимации",
|
||||
"sync_clock.name.hint": "Описательное название для этих часов синхронизации",
|
||||
"sync_clock.speed": "Скорость:",
|
||||
"sync_clock.speed.hint": "Множитель скорости, общий для всех привязанных источников. 1.0 = нормальная скорость.",
|
||||
"sync_clock.description": "Описание (необязательно):",
|
||||
"sync_clock.description.placeholder": "Необязательное описание",
|
||||
"sync_clock.description.hint": "Необязательные заметки о назначении этих часов",
|
||||
"sync_clock.status.running": "Работает",
|
||||
"sync_clock.status.paused": "Приостановлено",
|
||||
"sync_clock.action.pause": "Приостановить",
|
||||
"sync_clock.action.resume": "Возобновить",
|
||||
"sync_clock.action.reset": "Сбросить",
|
||||
"sync_clock.error.name_required": "Название часов обязательно",
|
||||
"sync_clock.error.load": "Не удалось загрузить часы синхронизации",
|
||||
"sync_clock.created": "Часы синхронизации созданы",
|
||||
"sync_clock.updated": "Часы синхронизации обновлены",
|
||||
"sync_clock.deleted": "Часы синхронизации удалены",
|
||||
"sync_clock.paused": "Часы приостановлены",
|
||||
"sync_clock.resumed": "Часы возобновлены",
|
||||
"sync_clock.reset_done": "Часы сброшены на ноль",
|
||||
"sync_clock.delete.confirm": "Удалить эти часы синхронизации? Источники, использующие их, вернутся к собственной скорости.",
|
||||
"color_strip.clock": "Часы синхронизации:",
|
||||
"color_strip.clock.hint": "Привязка к часам синхронизации для синхронной анимации. При установке скорость берётся из часов."
|
||||
}
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"common.delete": "删除",
|
||||
"common.edit": "编辑",
|
||||
"common.clone": "克隆",
|
||||
"common.none": "无",
|
||||
"section.filter.placeholder": "筛选...",
|
||||
"section.filter.reset": "清除筛选",
|
||||
"section.expand_all": "全部展开",
|
||||
@@ -950,6 +951,7 @@
|
||||
"audio_template.error.required": "请填写所有必填项",
|
||||
"audio_template.error.delete": "删除音频模板失败",
|
||||
"streams.group.value": "值源",
|
||||
"streams.group.sync": "同步时钟",
|
||||
"value_source.group.title": "值源",
|
||||
"value_source.add": "添加值源",
|
||||
"value_source.edit": "编辑值源",
|
||||
@@ -1137,5 +1139,32 @@
|
||||
"theme.switched.dark": "已切换到深色主题",
|
||||
"theme.switched.light": "已切换到浅色主题",
|
||||
"accent.color.updated": "强调色已更新",
|
||||
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭"
|
||||
"search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭",
|
||||
"sync_clock.group.title": "同步时钟",
|
||||
"sync_clock.add": "添加同步时钟",
|
||||
"sync_clock.edit": "编辑同步时钟",
|
||||
"sync_clock.name": "名称:",
|
||||
"sync_clock.name.placeholder": "主动画时钟",
|
||||
"sync_clock.name.hint": "此同步时钟的描述性名称",
|
||||
"sync_clock.speed": "速度:",
|
||||
"sync_clock.speed.hint": "所有关联源共享的速度倍率。1.0 = 正常速度。",
|
||||
"sync_clock.description": "描述(可选):",
|
||||
"sync_clock.description.placeholder": "可选描述",
|
||||
"sync_clock.description.hint": "关于此时钟用途的可选备注",
|
||||
"sync_clock.status.running": "运行中",
|
||||
"sync_clock.status.paused": "已暂停",
|
||||
"sync_clock.action.pause": "暂停",
|
||||
"sync_clock.action.resume": "恢复",
|
||||
"sync_clock.action.reset": "重置",
|
||||
"sync_clock.error.name_required": "时钟名称为必填项",
|
||||
"sync_clock.error.load": "加载同步时钟失败",
|
||||
"sync_clock.created": "同步时钟已创建",
|
||||
"sync_clock.updated": "同步时钟已更新",
|
||||
"sync_clock.deleted": "同步时钟已删除",
|
||||
"sync_clock.paused": "时钟已暂停",
|
||||
"sync_clock.resumed": "时钟已恢复",
|
||||
"sync_clock.reset_done": "时钟已重置为零",
|
||||
"sync_clock.delete.confirm": "删除此同步时钟?使用它的源将恢复为各自的速度。",
|
||||
"color_strip.clock": "同步时钟:",
|
||||
"color_strip.clock.hint": "关联同步时钟以实现同步动画。设置后,速度将来自时钟。"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Navigation: network-first with offline fallback
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'ledgrab-v6';
|
||||
const CACHE_NAME = 'ledgrab-v7';
|
||||
|
||||
// Only pre-cache static assets (no auth required).
|
||||
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page.
|
||||
|
||||
@@ -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}")
|
||||
@@ -169,6 +169,7 @@
|
||||
{% include 'modals/test-audio-template.html' %}
|
||||
{% include 'modals/value-source-editor.html' %}
|
||||
{% include 'modals/test-value-source.html' %}
|
||||
{% include 'modals/sync-clock-editor.html' %}
|
||||
{% include 'modals/settings.html' %}
|
||||
|
||||
{% include 'partials/tutorial-overlay.html' %}
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<div id="color-cycle-colors-list"></div>
|
||||
<button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div id="css-editor-cycle-speed-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-cycle-speed">
|
||||
<span data-i18n="color_strip.color_cycle.speed">Speed:</span>
|
||||
@@ -226,7 +226,7 @@
|
||||
<div id="css-editor-effect-preview" class="effect-palette-preview"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div id="css-editor-effect-speed-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-effect-speed">
|
||||
<span data-i18n="color_strip.effect.speed">Speed:</span>
|
||||
@@ -492,7 +492,7 @@
|
||||
</select>
|
||||
<small id="css-editor-animation-type-desc" class="field-desc"></small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div id="css-editor-animation-speed-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-animation-speed">
|
||||
<span data-i18n="color_strip.animation.speed">Speed:</span>
|
||||
@@ -508,6 +508,18 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Sync Clock (shown for animated types: static, gradient, color_cycle, effect) -->
|
||||
<div id="css-editor-clock-group" class="form-group" style="display:none">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-clock" data-i18n="color_strip.clock">Sync Clock:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="color_strip.clock.hint">Optionally link to a sync clock to synchronize animation timing and speed with other sources</small>
|
||||
<select id="css-editor-clock" onchange="onCSSClockChange()">
|
||||
<option value="" data-i18n="common.none">None</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="css-editor-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- Sync Clock Editor Modal -->
|
||||
<div id="sync-clock-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="sync-clock-modal-title">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="sync-clock-modal-title" data-i18n="sync_clock.add">Add Sync Clock</h2>
|
||||
<button class="modal-close-btn" onclick="closeSyncClockModal()" data-i18n-aria-label="aria.close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="sync-clock-form" onsubmit="return false;">
|
||||
<input type="hidden" id="sync-clock-id">
|
||||
|
||||
<div id="sync-clock-error" class="error-message" style="display: none;"></div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="sync-clock-name" data-i18n="sync_clock.name">Name:</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="sync_clock.name.hint">A descriptive name for this synchronization clock</small>
|
||||
<input type="text" id="sync-clock-name" data-i18n-placeholder="sync_clock.name.placeholder" placeholder="Main Clock" required>
|
||||
</div>
|
||||
|
||||
<!-- Speed -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="sync-clock-speed"><span data-i18n="sync_clock.speed">Speed:</span> <span id="sync-clock-speed-display">1.0</span>x</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="sync_clock.speed.hint">Animation speed multiplier for all linked sources (0.1x slow - 10x fast)</small>
|
||||
<input type="range" id="sync-clock-speed" min="0.1" max="10" step="0.1" value="1.0"
|
||||
oninput="document.getElementById('sync-clock-speed-display').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="sync-clock-description" data-i18n="sync_clock.description">Description (optional):</label>
|
||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||
</div>
|
||||
<small class="input-hint" style="display:none" data-i18n="sync_clock.description.hint">Optional notes about this clock's purpose</small>
|
||||
<textarea id="sync-clock-description" rows="2" data-i18n-placeholder="sync_clock.description.placeholder" placeholder=""></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeSyncClockModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveSyncClock()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user