diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..2a3e748 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,138 @@ +# Codebase Review Report + +_Generated 2026-03-09_ + +--- + +## 1. Bugs (Critical) + +### Thread Safety / Race Conditions + +| Issue | Location | Description | +|-------|----------|-------------| +| **Dict mutation during iteration** | `composite_stream.py:121`, `mapped_stream.py:102` | `update_source()` calls `_sub_streams.clear()` from the API thread while `_processing_loop` iterates the dict on a background thread. **Will crash with `RuntimeError: dictionary changed size during iteration`.** | +| **Clock ref-count corruption** | `color_strip_stream_manager.py:286-304` | On clock hot-swap, `_release_clock` reads the *new* clock_id from the store (already updated), so it releases the newly acquired clock instead of the old one. Leaks the old runtime, destroys the new one. | +| **SyncClockRuntime race** | `sync_clock_runtime.py:42-49` | `get_time()` reads `_running`, `_offset`, `_epoch` without `_lock`, while `pause()`/`resume()`/`reset()` modify them under `_lock`. Compound read can double-count elapsed time. | +| **SyncClockManager unprotected dicts** | `sync_clock_manager.py:26-54` | `_runtimes` and `_ref_counts` are plain dicts mutated from both the async event loop and background threads with no lock. | + +### Silent Failures + +| Issue | Location | Description | +|-------|----------|-------------| +| **Crashed streams go undetected** | `mapped_stream.py:214`, `composite_stream.py` | When the processing loop dies, `get_latest_colors()` permanently returns stale data. The target keeps sending frozen colors to LEDs with no indicator anything is wrong. | +| **Crash doesn't fire state_change event** | `wled_target_processor.py:900` | Fatal exception path sets `_is_running = False` without firing `state_change` event (only `stop()` fires it). Dashboard doesn't learn about crashes via WebSocket. | +| **WebSocket broadcast client mismatch** | `kc_target_processor.py:481-485` | `zip(self._ws_clients, results)` pairs results with the live list, but clients can be removed between scheduling `gather` and collecting results, causing wrong clients to be dropped. | + +### Security + +| Issue | Location | Description | +|-------|----------|-------------| +| **Incomplete path traversal guard** | `auto_backup.py` | Filename validation uses string checks (`".." in filename`) instead of `Path.resolve().is_relative_to()`. | + +--- + +## 2. Performance + +### High Impact (Hot Path) + +| Issue | Location | Impact | +|-------|----------|--------| +| **Per-frame `np.array()` from list** | `ddp_client.py:195` | Allocates a new numpy array from a Python list every frame. Should use pre-allocated buffer. | +| **Triple FFT for mono audio** | `analysis.py:168-174` | When audio is mono (common for system loopback), runs 3 identical FFTs. 2x wasted CPU. | +| **`frame_time = 1.0/fps` in every loop iteration** | 8 stream files | Recomputed every frame despite `_fps` only changing on consumer subscribe. Should be cached. | +| **4x deque traversals per frame for metrics** | `kc_target_processor.py:413-416` | Full traversal of metrics deques every frame to compute avg/min/max. | +| **3x spectrum `.copy()` per audio chunk** | `analysis.py:195-201` | ~258 array allocations/sec for read-only consumers. Could use non-writable views. | + +### Medium Impact + +| Issue | Location | +|-------|----------| +| `getattr` + dict lookup per composite layer per frame | `composite_stream.py:299-304` | +| Unconditional `self.*=` attribute writes every frame in audio stream | `audio_stream.py:255-261` | +| `JSON.parse(localStorage)` on every collapsed-section call | `dashboard.js` `_getCollapsedSections` | +| Effect/composite/mapped streams hardcoded to 30 FPS | `effect_stream.py`, `composite_stream.py:37`, `mapped_stream.py:33` | +| Double `querySelectorAll` on card reconcile | `card-sections.js:229-232` | +| Module import inside per-second sampling function | `metrics_history.py:21,35` | +| `datetime.utcnow()` twice per frame | `kc_target_processor.py:420,464` | +| Redundant `bytes()` copy of bytes slice | `ddp_client.py:222` | +| Unnecessary `.copy()` of temp interp result | `audio_stream.py:331,342` | +| Multiple intermediate numpy allocs for luminance | `value_stream.py:486-494` | + +--- + +## 3. Code Quality + +### Architecture + +| Issue | Description | +|-------|-------------| +| **12 store classes with duplicated boilerplate** | All JSON stores repeat the same load/save/CRUD pattern with no base class. A `BaseJsonStore[T]` would eliminate ~60% of each store file. | +| **`DeviceStore.save()` uses unsafe temp file** | Fixed-path temp file instead of `atomic_write_json` used by all other stores. | +| **`scene_activator.py` accesses `ProcessorManager._processors` directly** | Lines 33, 68, 90, 110 — bypasses public API, breaks encapsulation. | +| **Route code directly mutates `ProcessorManager` internals** | `devices.py` accesses `manager._devices` and `manager._color_strip_stream_manager` in 13+ places. | +| **`color-strips.js` is 1900+ lines** | Handles 11 CSS source types, gradient editor, composite layers, mapped zones, card rendering, overlay control — should be split. | +| **No `DataCache` for color strip sources** | Every other entity uses `DataCache`. CSS sources are fetched with raw `fetchWithAuth` in 5+ places with no deduplication. | + +### Consistency / Hygiene + +| Issue | Location | +|-------|----------| +| `Dict[str, any]` (lowercase `any`) — invalid type annotation | `template_store.py:138,187`, `audio_template_store.py:126,155` | +| `datetime.utcnow()` deprecated — 88 call sites in 42 files | Project-wide | +| `_icon` SVG helper duplicated verbatim in 3 JS files | `color-strips.js:293`, `automations.js:41`, `kc-targets.js:49` | +| `hexToRgbArray` private to one file, pattern inlined elsewhere | `color-strips.js:471` vs line 1403 | +| Hardcoded English fallback in `showToast` | `color-strips.js:1593` | +| `ColorStripStore.create_source` silently creates wrong type for unknown `source_type` | `color_strip_store.py:92-332` | +| `update_source` clock_id clearing uses undocumented empty-string sentinel | `color_strip_store.py:394-395` | +| `DeviceStore._load` lacks per-item error isolation (unlike all other stores) | `device_store.py:122-138` | +| No unit tests | Zero test files. Highest-risk: `CalibrationConfig`/`PixelMapper` geometry, DDP packets, automation conditions. | + +--- + +## 4. Features & Suggestions + +### High Impact / Low Effort + +| Suggestion | Details | +|------------|---------| +| **Auto-restart crashed processing loops** | Add backoff-based restart when `_processing_loop` dies. Currently crashes are permanent until manual intervention. | +| **Fire `state_change` on crash** | Add `finally` block in `_processing_loop` to notify the dashboard immediately. | +| **`POST /system/auto-backup/trigger`** | ~5 lines of Python. Manual backup trigger before risky config changes. | +| **`is_healthy` property on streams** | Let target processors detect when their color source has died. | +| **Rotate webhook token endpoint** | `POST /automations/{id}/rotate-webhook-token` — regenerate without recreating automation. | +| **"Start All" targets button** | "Stop All" exists but "Start All" (the more common operation after restart) is missing. | +| **Include auto-backup settings in backup** | Currently lost on restore. | +| **Distinguish "crashed" vs "stopped" in dashboard** | `metrics.last_error` is already populated — just surface it. | + +### High Impact / Moderate Effort + +| Suggestion | Details | +|------------|---------| +| **Home Assistant MQTT discovery** | Publish auto-discovery payloads so devices appear in HA automatically. MQTT infra already exists. | +| **Device health WebSocket events** | Eliminates 5-30s poll latency for online/offline detection. | +| **`GET /system/store-errors`** | Surface startup deserialization failures to the user. Currently only in logs. | +| **Scene snapshot should capture device brightness** | `software_brightness` is not saved/restored by scenes. | +| **Exponential backoff on events WebSocket reconnect** | Currently fixed 3s retry, generates constant logs during outages. | +| **CSS source import/export** | Share individual sources without full config backup. | +| **Per-target error ring buffer via API** | `GET /targets/{id}/logs` for remote debugging. | +| **DDP socket reconnection** | UDP socket invalidated on network changes; no reconnect path exists. | +| **Adalight serial reconnection** | COM port disconnect crashes the target permanently. | +| **MQTT-controlled brightness and scene activation** | Direct command handler without requiring API key management. | + +### Nice-to-Have + +| Suggestion | Details | +|------------|---------| +| Configurable metrics history window (currently hardcoded 120 samples / 2 min) | | +| Replace `window.prompt()` API key entry with proper modal | | +| Pattern template live preview (SVG/Canvas) | | +| Keyboard shortcuts for start/stop targets and scene activation | | +| FPS chart auto-scaling y-axis (`Math.max(target*1.15, maxSeen*1.1)`) | | +| WLED native preset target type (send `{"ps": id}` instead of pixels) | | +| Configurable DDP max packet size per device | | +| `GET /system/active-streams` unified runtime snapshot | | +| OpenMetrics / Prometheus endpoint for Grafana integration | | +| Configurable health check intervals (currently hardcoded 10s/60s) | | +| Configurable backup directory path | | +| `GET /system/logs?tail=100&level=ERROR` for in-app log viewing | | +| Device card "currently streaming" badge | | diff --git a/TODO.md b/TODO.md index 7a23438..ef119b6 100644 --- a/TODO.md +++ b/TODO.md @@ -51,11 +51,51 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case - [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting +## Code Health (from review 2026-03-09) + +### Bugs + +- [x] `P1` **Thread safety: dict mutation during iteration** — composite_stream.py / mapped_stream.py `_sub_streams.clear()` crashes processing loop +- [x] `P1` **Thread safety: SyncClockRuntime.get_time() race** — compound read without lock causes time double-counting +- [x] `P1` **Thread safety: SyncClockManager unprotected dicts** — `_runtimes`/`_ref_counts` mutated from multiple threads without lock +- [x] `P1` **Clock ref-count corruption on hot-swap** — `_release_clock` reads new clock_id from store instead of old one +- [x] `P1` **Path traversal guard** — `auto_backup.py` uses string checks instead of `Path.resolve().is_relative_to()` +- [x] `P2` **Crash doesn't fire state_change event** — fatal exception path in `wled_target_processor.py` doesn't notify dashboard +- [x] `P2` **WS broadcast client mismatch** — `kc_target_processor.py` `zip(clients, results)` can pair wrong clients after concurrent removal + +### Performance + +- [x] `P1` **Triple FFT for mono audio** — `analysis.py` runs 3 identical FFTs when audio is mono (2x wasted CPU) +- [x] `P2` **Per-frame np.array() from list** — `ddp_client.py:195` allocates new numpy array every frame +- [x] `P2` **frame_time recomputed every loop iteration** — `1.0/fps` in 8 stream files, should be cached +- [x] `P2` **Effect/composite/mapped streams hardcoded to 30 FPS** — ignores target FPS, bottlenecks 60 FPS targets +- [x] `P3` **Spectrum .copy() per audio chunk** — `analysis.py` ~258 array allocations/sec for read-only consumers + +### Code Quality + +- [x] `P2` **12 store classes with duplicated boilerplate** — no base class; `BaseJsonStore[T]` would eliminate ~60% +- [x] `P2` **DeviceStore.save() uses unsafe temp file** — fixed-path `.tmp` instead of `atomic_write_json` +- [x] `P2` **Route code directly mutates ProcessorManager internals** — `devices.py` accesses `manager._devices` in 13+ places +- [x] `P2` **scene_activator.py accesses ProcessorManager._processors directly** — bypasses public API +- [x] `P3` **datetime.utcnow() deprecated** — 88 call sites in 42 files, should use `datetime.now(timezone.utc)` +- [x] `P3` **color-strips.js 1900+ lines** — should be split into separate modules +- [x] `P3` **No DataCache for color strip sources** — fetched with raw fetchWithAuth in 5+ places + +### Features + +- [ ] `P1` **Auto-restart crashed processing loops** — add backoff-based restart when `_processing_loop` dies +- [ ] `P1` **"Start All" targets button** — "Stop All" exists but "Start All" is missing +- [ ] `P2` **Manual backup trigger endpoint** — `POST /system/auto-backup/trigger` (~5 lines) +- [ ] `P2` **Scene snapshot should capture device brightness** — `software_brightness` not saved/restored +- [ ] `P2` **Device health WebSocket events** — eliminate 5-30s poll latency for online/offline detection +- [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated +- [ ] `P3` **Home Assistant MQTT discovery** — publish auto-discovery payloads; MQTT infra already exists +- [ ] `P3` **CSS source import/export** — share individual sources without full config backup +- [ ] `P3` **Exponential backoff on events WS reconnect** — currently fixed 3s retry + ## UX -- [ ] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag - - Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration - - Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming") +- [x] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag - [x] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default - [x] `P1` **Review protocol badge on LED target cards** — Review and improve the protocol badge display on LED target cards diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index 0a37cf0..bad4bf7 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -43,6 +43,7 @@ def _to_response(source: AudioSource) -> AudioSourceResponse: audio_source_id=getattr(source, "audio_source_id", None), channel=getattr(source, "channel", None), description=source.description, + tags=getattr(source, 'tags', []), created_at=source.created_at, updated_at=source.updated_at, ) @@ -81,6 +82,7 @@ async def create_audio_source( channel=data.channel, description=data.description, audio_template_id=data.audio_template_id, + tags=data.tags, ) return _to_response(source) except ValueError as e: @@ -119,6 +121,7 @@ async def update_audio_source( channel=data.channel, description=data.description, audio_template_id=data.audio_template_id, + tags=data.tags, ) return _to_response(source) except ValueError as e: diff --git a/server/src/wled_controller/api/routes/audio_templates.py b/server/src/wled_controller/api/routes/audio_templates.py index 143ac29..aba8bc0 100644 --- a/server/src/wled_controller/api/routes/audio_templates.py +++ b/server/src/wled_controller/api/routes/audio_templates.py @@ -41,7 +41,8 @@ async def list_audio_templates( responses = [ AudioTemplateResponse( id=t.id, name=t.name, engine_type=t.engine_type, - engine_config=t.engine_config, created_at=t.created_at, + engine_config=t.engine_config, tags=getattr(t, 'tags', []), + created_at=t.created_at, updated_at=t.updated_at, description=t.description, ) for t in templates @@ -63,10 +64,12 @@ async def create_audio_template( template = store.create_template( name=data.name, engine_type=data.engine_type, engine_config=data.engine_config, description=data.description, + tags=data.tags, ) return AudioTemplateResponse( id=template.id, name=template.name, engine_type=template.engine_type, - engine_config=template.engine_config, created_at=template.created_at, + engine_config=template.engine_config, tags=getattr(template, 'tags', []), + created_at=template.created_at, updated_at=template.updated_at, description=template.description, ) except ValueError as e: @@ -89,7 +92,8 @@ async def get_audio_template( raise HTTPException(status_code=404, detail=f"Audio template {template_id} not found") return AudioTemplateResponse( id=t.id, name=t.name, engine_type=t.engine_type, - engine_config=t.engine_config, created_at=t.created_at, + engine_config=t.engine_config, tags=getattr(t, 'tags', []), + created_at=t.created_at, updated_at=t.updated_at, description=t.description, ) @@ -106,11 +110,12 @@ async def update_audio_template( t = store.update_template( template_id=template_id, name=data.name, engine_type=data.engine_type, engine_config=data.engine_config, - description=data.description, + description=data.description, tags=data.tags, ) return AudioTemplateResponse( id=t.id, name=t.name, engine_type=t.engine_type, - engine_config=t.engine_config, created_at=t.created_at, + engine_config=t.engine_config, tags=getattr(t, 'tags', []), + created_at=t.created_at, updated_at=t.updated_at, description=t.description, ) except ValueError as e: diff --git a/server/src/wled_controller/api/routes/automations.py b/server/src/wled_controller/api/routes/automations.py index 1a1d332..0fd2e11 100644 --- a/server/src/wled_controller/api/routes/automations.py +++ b/server/src/wled_controller/api/routes/automations.py @@ -107,6 +107,7 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque is_active=state["is_active"], last_activated_at=state.get("last_activated_at"), last_deactivated_at=state.get("last_deactivated_at"), + tags=getattr(automation, 'tags', []), created_at=automation.created_at, updated_at=automation.updated_at, ) @@ -167,6 +168,7 @@ async def create_automation( scene_preset_id=data.scene_preset_id, deactivation_mode=data.deactivation_mode, deactivation_scene_preset_id=data.deactivation_scene_preset_id, + tags=data.tags, ) if automation.enabled: @@ -256,6 +258,7 @@ async def update_automation( condition_logic=data.condition_logic, conditions=conditions, deactivation_mode=data.deactivation_mode, + tags=data.tags, ) if data.scene_preset_id is not None: update_kwargs["scene_preset_id"] = data.scene_preset_id diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 600b031..317c204 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -100,6 +100,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe app_filter_list=getattr(source, "app_filter_list", None), os_listener=getattr(source, "os_listener", None), overlay_active=overlay_active, + tags=getattr(source, 'tags', []), created_at=source.created_at, updated_at=source.updated_at, ) @@ -190,6 +191,7 @@ async def create_color_strip_source( app_filter_mode=data.app_filter_mode, app_filter_list=data.app_filter_list, os_listener=data.os_listener, + tags=data.tags, ) return _css_to_response(source) @@ -273,11 +275,12 @@ async def update_color_strip_source( app_filter_mode=data.app_filter_mode, app_filter_list=data.app_filter_list, os_listener=data.os_listener, + tags=data.tags, ) # Hot-reload running stream (no restart needed for in-place param changes) try: - manager._color_strip_stream_manager.update_source(source_id, source) + manager.color_strip_stream_manager.update_source(source_id, source) except Exception as e: logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}") @@ -354,7 +357,7 @@ async def test_css_calibration( """ try: # Validate device exists in manager - if body.device_id not in manager._devices: + if not manager.has_device(body.device_id): raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found") # Validate edge names and colors @@ -500,7 +503,7 @@ async def push_colors( if colors_array.ndim != 2 or colors_array.shape[1] != 3: raise HTTPException(status_code=400, detail="Colors must be an array of [R,G,B] triplets") - streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id) + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) for stream in streams: if hasattr(stream, "push_colors"): stream.push_colors(colors_array) @@ -537,7 +540,7 @@ async def notify_source( app_name = body.app if body else None color_override = body.color if body else None - streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id) + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) accepted = 0 for stream in streams: if hasattr(stream, "fire"): @@ -624,7 +627,7 @@ async def css_api_input_ws( continue # Push to all running streams - streams = manager._color_strip_stream_manager.get_streams_by_source_id(source_id) + streams = manager.color_strip_stream_manager.get_streams_by_source_id(source_id) for stream in streams: if hasattr(stream, "push_colors"): stream.push_colors(colors_array) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 3865a51..214aa87 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -52,6 +52,7 @@ def _device_to_response(device) -> DeviceResponse: rgbw=device.rgbw, zone_mode=device.zone_mode, capabilities=sorted(get_device_capabilities(device.device_type)), + tags=getattr(device, 'tags', []), created_at=device.created_at, updated_at=device.updated_at, ) @@ -126,6 +127,7 @@ async def create_device( send_latency_ms=device_data.send_latency_ms or 0, rgbw=device_data.rgbw or False, zone_mode=device_data.zone_mode or "combined", + tags=device_data.tags, ) # WS devices: auto-set URL to ws://{device_id} @@ -308,6 +310,7 @@ async def update_device( send_latency_ms=update_data.send_latency_ms, rgbw=update_data.rgbw, zone_mode=update_data.zone_mode, + tags=update_data.tags, ) # Sync connection info in processor manager @@ -322,11 +325,12 @@ async def update_device( pass # Sync auto_shutdown and zone_mode in runtime state - if device_id in manager._devices: + ds = manager.find_device_state(device_id) + if ds: if update_data.auto_shutdown is not None: - manager._devices[device_id].auto_shutdown = update_data.auto_shutdown + ds.auto_shutdown = update_data.auto_shutdown if update_data.zone_mode is not None: - manager._devices[device_id].zone_mode = update_data.zone_mode + ds.zone_mode = update_data.zone_mode return _device_to_response(device) @@ -420,7 +424,7 @@ async def get_device_brightness( raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") # Return cached hardware brightness if available (updated by SET endpoint) - ds = manager._devices.get(device_id) + ds = manager.find_device_state(device_id) if ds and ds.hardware_brightness is not None: return {"brightness": ds.hardware_brightness} @@ -465,13 +469,15 @@ async def set_device_brightness( except NotImplementedError: # Provider has no hardware brightness; use software brightness device.software_brightness = bri - device.updated_at = __import__("datetime").datetime.utcnow() + from datetime import datetime, timezone + device.updated_at = datetime.now(timezone.utc) store.save() - if device_id in manager._devices: - manager._devices[device_id].software_brightness = bri + ds = manager.find_device_state(device_id) + if ds: + ds.software_brightness = bri # Update cached hardware brightness - ds = manager._devices.get(device_id) + ds = manager.find_device_state(device_id) if ds: ds.hardware_brightness = bri @@ -499,7 +505,7 @@ async def get_device_power( try: # Serial devices: use tracked state (no hardware query available) - ds = manager._devices.get(device_id) + ds = manager.find_device_state(device_id) if device.device_type in ("adalight", "ambiled") and ds: return {"on": ds.power_on} @@ -532,10 +538,10 @@ async def set_device_power( try: # For serial devices, use the cached idle client to avoid port conflicts - ds = manager._devices.get(device_id) + ds = manager.find_device_state(device_id) if device.device_type in ("adalight", "ambiled") and ds: if not on: - await manager._send_clear_pixels(device_id) + await manager.send_clear_pixels(device_id) ds.power_on = on else: provider = get_provider(device.device_type) diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index 7ce58c7..cb4b2aa 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -105,6 +105,7 @@ def _target_to_response(target) -> OutputTargetResponse: adaptive_fps=target.adaptive_fps, protocol=target.protocol, description=target.description, + tags=getattr(target, 'tags', []), created_at=target.created_at, updated_at=target.updated_at, @@ -117,6 +118,7 @@ def _target_to_response(target) -> OutputTargetResponse: picture_source_id=target.picture_source_id, key_colors_settings=_kc_settings_to_schema(target.settings), description=target.description, + tags=getattr(target, 'tags', []), created_at=target.created_at, updated_at=target.updated_at, @@ -127,6 +129,7 @@ def _target_to_response(target) -> OutputTargetResponse: name=target.name, target_type=target.target_type, description=target.description, + tags=getattr(target, 'tags', []), created_at=target.created_at, updated_at=target.updated_at, @@ -169,6 +172,7 @@ async def create_target( picture_source_id=data.picture_source_id, key_colors_settings=kc_settings, description=data.description, + tags=data.tags, ) # Register in processor manager @@ -287,6 +291,7 @@ async def update_target( protocol=data.protocol, key_colors_settings=kc_settings, description=data.description, + tags=data.tags, ) # Detect KC brightness VS change (inside key_colors_settings) @@ -461,11 +466,11 @@ async def get_target_colors( r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}", ) - from datetime import datetime + from datetime import datetime, timezone return KeyColorsResponse( target_id=target_id, colors=colors, - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/pattern_templates.py b/server/src/wled_controller/api/routes/pattern_templates.py index a578cf1..3d46b7c 100644 --- a/server/src/wled_controller/api/routes/pattern_templates.py +++ b/server/src/wled_controller/api/routes/pattern_templates.py @@ -36,6 +36,7 @@ def _pat_template_to_response(t) -> PatternTemplateResponse: created_at=t.created_at, updated_at=t.updated_at, description=t.description, + tags=getattr(t, 'tags', []), ) @@ -70,6 +71,7 @@ async def create_pattern_template( name=data.name, rectangles=rectangles, description=data.description, + tags=data.tags, ) return _pat_template_to_response(template) except ValueError as e: @@ -113,6 +115,7 @@ async def update_pattern_template( name=data.name, rectangles=rectangles, description=data.description, + tags=data.tags, ) return _pat_template_to_response(template) except ValueError as e: diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index 8f972e6..03fa619 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -60,6 +60,7 @@ def _stream_to_response(s) -> PictureSourceResponse: created_at=s.created_at, updated_at=s.updated_at, description=s.description, + tags=getattr(s, 'tags', []), ) @@ -196,6 +197,7 @@ async def create_picture_source( postprocessing_template_id=data.postprocessing_template_id, image_source=data.image_source, description=data.description, + tags=data.tags, ) return _stream_to_response(stream) except HTTPException: @@ -240,6 +242,7 @@ async def update_picture_source( postprocessing_template_id=data.postprocessing_template_id, image_source=data.image_source, description=data.description, + tags=data.tags, ) return _stream_to_response(stream) except ValueError as e: diff --git a/server/src/wled_controller/api/routes/postprocessing.py b/server/src/wled_controller/api/routes/postprocessing.py index 257f526..9fbc36c 100644 --- a/server/src/wled_controller/api/routes/postprocessing.py +++ b/server/src/wled_controller/api/routes/postprocessing.py @@ -50,6 +50,7 @@ def _pp_template_to_response(t) -> PostprocessingTemplateResponse: created_at=t.created_at, updated_at=t.updated_at, description=t.description, + tags=getattr(t, 'tags', []), ) @@ -81,6 +82,7 @@ async def create_pp_template( name=data.name, filters=filters, description=data.description, + tags=data.tags, ) return _pp_template_to_response(template) except ValueError as e: @@ -119,6 +121,7 @@ async def update_pp_template( name=data.name, filters=filters, description=data.description, + tags=data.tags, ) return _pp_template_to_response(template) except ValueError as e: diff --git a/server/src/wled_controller/api/routes/scene_presets.py b/server/src/wled_controller/api/routes/scene_presets.py index 87867ed..9764e78 100644 --- a/server/src/wled_controller/api/routes/scene_presets.py +++ b/server/src/wled_controller/api/routes/scene_presets.py @@ -1,7 +1,7 @@ """Scene preset API routes — CRUD, capture, activate, recapture.""" import uuid -from datetime import datetime +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException @@ -45,6 +45,7 @@ def _preset_to_response(preset: ScenePreset) -> ScenePresetResponse: "fps": t.fps, } for t in preset.targets], order=preset.order, + tags=getattr(preset, 'tags', []), created_at=preset.created_at, updated_at=preset.updated_at, ) @@ -69,13 +70,14 @@ async def create_scene_preset( target_ids = set(data.target_ids) if data.target_ids is not None else None targets = capture_current_snapshot(target_store, manager, target_ids) - now = datetime.utcnow() + now = datetime.now(timezone.utc) preset = ScenePreset( id=f"scene_{uuid.uuid4().hex[:8]}", name=data.name, description=data.description, targets=targets, order=store.count(), + tags=data.tags if data.tags is not None else [], created_at=now, updated_at=now, ) @@ -169,6 +171,7 @@ async def update_scene_preset( description=data.description, order=data.order, targets=new_targets, + tags=data.tags, ) except ValueError as e: raise HTTPException(status_code=404 if "not found" in str(e).lower() else 400, detail=str(e)) diff --git a/server/src/wled_controller/api/routes/sync_clocks.py b/server/src/wled_controller/api/routes/sync_clocks.py index 010f567..d7ed877 100644 --- a/server/src/wled_controller/api/routes/sync_clocks.py +++ b/server/src/wled_controller/api/routes/sync_clocks.py @@ -33,6 +33,7 @@ def _to_response(clock: SyncClock, manager: SyncClockManager) -> SyncClockRespon name=clock.name, speed=rt.speed if rt else clock.speed, description=clock.description, + tags=getattr(clock, 'tags', []), is_running=rt.is_running if rt else True, elapsed_time=rt.get_time() if rt else 0.0, created_at=clock.created_at, @@ -67,6 +68,7 @@ async def create_sync_clock( name=data.name, speed=data.speed, description=data.description, + tags=data.tags, ) return _to_response(clock, manager) except ValueError as e: @@ -103,6 +105,7 @@ async def update_sync_clock( name=data.name, speed=data.speed, description=data.description, + tags=data.tags, ) # Hot-update runtime speed if data.speed is not None: diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 5607dc3..4e9c963 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -7,7 +7,7 @@ import platform import subprocess import sys import threading -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -18,7 +18,23 @@ from pydantic import BaseModel from wled_controller import __version__ from wled_controller.api.auth import AuthRequired -from wled_controller.api.dependencies import get_auto_backup_engine, get_processor_manager +from wled_controller.api.dependencies import ( + get_auto_backup_engine, + get_audio_source_store, + get_audio_template_store, + get_automation_store, + get_color_strip_store, + get_device_store, + get_output_target_store, + get_pattern_template_store, + get_picture_source_store, + get_pp_template_store, + get_processor_manager, + get_scene_preset_store, + get_sync_clock_store, + get_template_store, + get_value_source_store, +) from wled_controller.api.schemas.system import ( AutoBackupSettings, AutoBackupStatusResponse, @@ -104,7 +120,7 @@ async def health_check(): return HealthResponse( status="healthy", - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), version=__version__, ) @@ -124,6 +140,39 @@ async def get_version(): ) +@router.get("/api/v1/tags", tags=["Tags"]) +async def list_all_tags(_: AuthRequired): + """Get all tags used across all entities.""" + all_tags: set[str] = set() + store_getters = [ + get_device_store, get_output_target_store, get_color_strip_store, + get_picture_source_store, get_audio_source_store, get_value_source_store, + get_sync_clock_store, get_automation_store, get_scene_preset_store, + get_template_store, get_audio_template_store, get_pp_template_store, + get_pattern_template_store, + ] + for getter in store_getters: + try: + store = getter() + except RuntimeError: + continue + # Each store has a different "get all" method name + items = None + for method_name in ( + "get_all_devices", "get_all_targets", "get_all_sources", + "get_all_streams", "get_all_clocks", "get_all_automations", + "get_all_presets", "get_all_templates", + ): + fn = getattr(store, method_name, None) + if fn is not None: + items = fn() + break + if items: + for item in items: + all_tags.update(getattr(item, 'tags', [])) + return {"tags": sorted(all_tags)} + + @router.get("/api/v1/config/displays", response_model=DisplayListResponse, tags=["Config"]) async def get_displays( _: AuthRequired, @@ -238,7 +287,7 @@ def get_system_performance(_: AuthRequired): ram_total_mb=round(mem.total / 1024 / 1024, 1), ram_percent=mem.percent, gpu=gpu, - timestamp=datetime.utcnow(), + timestamp=datetime.now(timezone.utc), ) @@ -318,14 +367,14 @@ def backup_config(_: AuthRequired): "format": "ledgrab-backup", "format_version": 1, "app_version": __version__, - "created_at": datetime.utcnow().isoformat() + "Z", + "created_at": datetime.now(timezone.utc).isoformat() + "Z", "store_count": len(stores), }, "stores": stores, } content = json.dumps(backup, indent=2, ensure_ascii=False) - timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H%M%S") + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") filename = f"ledgrab-backup-{timestamp}.json" return StreamingResponse( diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index fd51010..4fbdc76 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -62,7 +62,7 @@ async def list_templates( name=t.name, engine_type=t.engine_type, engine_config=t.engine_config, - + tags=getattr(t, 'tags', []), created_at=t.created_at, updated_at=t.updated_at, description=t.description, @@ -93,6 +93,7 @@ async def create_template( engine_type=template_data.engine_type, engine_config=template_data.engine_config, description=template_data.description, + tags=template_data.tags, ) return TemplateResponse( @@ -100,7 +101,7 @@ async def create_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, - + tags=getattr(template, 'tags', []), created_at=template.created_at, updated_at=template.updated_at, description=template.description, @@ -130,6 +131,7 @@ async def get_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, + tags=getattr(template, 'tags', []), created_at=template.created_at, updated_at=template.updated_at, description=template.description, @@ -151,6 +153,7 @@ async def update_template( engine_type=update_data.engine_type, engine_config=update_data.engine_config, description=update_data.description, + tags=update_data.tags, ) return TemplateResponse( @@ -158,7 +161,7 @@ async def update_template( name=template.name, engine_type=template.engine_type, engine_config=template.engine_config, - + tags=getattr(template, 'tags', []), created_at=template.created_at, updated_at=template.updated_at, description=template.description, diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index 097e61f..69a0f62 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -51,6 +51,7 @@ def _to_response(source: ValueSource) -> ValueSourceResponse: picture_source_id=d.get("picture_source_id"), scene_behavior=d.get("scene_behavior"), description=d.get("description"), + tags=d.get("tags", []), created_at=source.created_at, updated_at=source.updated_at, ) @@ -97,6 +98,7 @@ async def create_value_source( picture_source_id=data.picture_source_id, scene_behavior=data.scene_behavior, auto_gain=data.auto_gain, + tags=data.tags, ) return _to_response(source) except ValueError as e: @@ -144,6 +146,7 @@ async def update_value_source( picture_source_id=data.picture_source_id, scene_behavior=data.scene_behavior, auto_gain=data.auto_gain, + tags=data.tags, ) # Hot-reload running value streams pm.update_value_source(source_id) diff --git a/server/src/wled_controller/api/schemas/audio_sources.py b/server/src/wled_controller/api/schemas/audio_sources.py index ffaa857..f18ce90 100644 --- a/server/src/wled_controller/api/schemas/audio_sources.py +++ b/server/src/wled_controller/api/schemas/audio_sources.py @@ -19,6 +19,7 @@ class AudioSourceCreate(BaseModel): audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class AudioSourceUpdate(BaseModel): @@ -31,6 +32,7 @@ class AudioSourceUpdate(BaseModel): audio_source_id: Optional[str] = Field(None, description="Parent multichannel audio source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: Optional[List[str]] = None class AudioSourceResponse(BaseModel): @@ -45,6 +47,7 @@ class AudioSourceResponse(BaseModel): audio_source_id: Optional[str] = Field(None, description="Parent multichannel source ID") channel: Optional[str] = Field(None, description="Channel: mono|left|right") description: Optional[str] = Field(None, description="Description") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/audio_templates.py b/server/src/wled_controller/api/schemas/audio_templates.py index 225e711..a31e261 100644 --- a/server/src/wled_controller/api/schemas/audio_templates.py +++ b/server/src/wled_controller/api/schemas/audio_templates.py @@ -13,6 +13,7 @@ class AudioTemplateCreate(BaseModel): engine_type: str = Field(description="Audio engine type (e.g., 'wasapi', 'sounddevice')", min_length=1) engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class AudioTemplateUpdate(BaseModel): @@ -22,6 +23,7 @@ class AudioTemplateUpdate(BaseModel): engine_type: Optional[str] = Field(None, description="Audio engine type") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: Optional[List[str]] = None class AudioTemplateResponse(BaseModel): @@ -31,6 +33,7 @@ class AudioTemplateResponse(BaseModel): name: str = Field(description="Template name") engine_type: str = Field(description="Engine type identifier") engine_config: Dict = Field(description="Engine-specific configuration") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") diff --git a/server/src/wled_controller/api/schemas/automations.py b/server/src/wled_controller/api/schemas/automations.py index edd53d5..5c47e51 100644 --- a/server/src/wled_controller/api/schemas/automations.py +++ b/server/src/wled_controller/api/schemas/automations.py @@ -39,6 +39,7 @@ class AutomationCreate(BaseModel): scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") deactivation_mode: str = Field(default="none", description="'none', 'revert', or 'fallback_scene'") deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") + tags: List[str] = Field(default_factory=list, description="User-defined tags") class AutomationUpdate(BaseModel): @@ -51,6 +52,7 @@ class AutomationUpdate(BaseModel): scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") deactivation_mode: Optional[str] = Field(None, description="'none', 'revert', or 'fallback_scene'") deactivation_scene_preset_id: Optional[str] = Field(None, description="Scene preset for fallback deactivation") + tags: Optional[List[str]] = None class AutomationResponse(BaseModel): @@ -64,6 +66,7 @@ class AutomationResponse(BaseModel): scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate") deactivation_mode: str = Field(default="none", description="Deactivation behavior") deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset") + tags: List[str] = Field(default_factory=list, description="User-defined tags") webhook_url: Optional[str] = Field(None, description="Webhook URL for the first webhook condition (if any)") is_active: bool = Field(default=False, description="Whether the automation is currently active") last_activated_at: Optional[datetime] = Field(None, description="Last time this automation was activated") diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index bef7c34..f522a49 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -97,6 +97,7 @@ class ColorStripSourceCreate(BaseModel): os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") # sync clock clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + tags: List[str] = Field(default_factory=list, description="User-defined tags") class ColorStripSourceUpdate(BaseModel): @@ -150,6 +151,7 @@ class ColorStripSourceUpdate(BaseModel): os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") # sync clock clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + tags: Optional[List[str]] = None class ColorStripSourceResponse(BaseModel): @@ -205,6 +207,7 @@ class ColorStripSourceResponse(BaseModel): os_listener: Optional[bool] = Field(None, description="Whether to listen for OS notifications") # sync clock clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") + tags: List[str] = Field(default_factory=list, description="User-defined tags") 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") diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 19acce7..90c22ba 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -18,6 +18,7 @@ class DeviceCreate(BaseModel): send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)") rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") + tags: List[str] = Field(default_factory=list, description="User-defined tags") class DeviceUpdate(BaseModel): @@ -32,6 +33,7 @@ class DeviceUpdate(BaseModel): send_latency_ms: Optional[int] = Field(None, ge=0, le=5000, description="Simulated send latency in ms (mock devices)") rgbw: Optional[bool] = Field(None, description="RGBW mode (mock devices)") zone_mode: Optional[str] = Field(None, description="OpenRGB zone mode: combined or separate") + tags: Optional[List[str]] = None class CalibrationLineSchema(BaseModel): @@ -125,6 +127,7 @@ class DeviceResponse(BaseModel): rgbw: bool = Field(default=False, description="RGBW mode (mock devices)") zone_mode: str = Field(default="combined", description="OpenRGB zone mode: combined or separate") capabilities: List[str] = Field(default_factory=list, description="Device type capabilities") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/output_targets.py b/server/src/wled_controller/api/schemas/output_targets.py index 18ad182..de37aaf 100644 --- a/server/src/wled_controller/api/schemas/output_targets.py +++ b/server/src/wled_controller/api/schemas/output_targets.py @@ -65,6 +65,7 @@ class OutputTargetCreate(BaseModel): picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class OutputTargetUpdate(BaseModel): @@ -85,6 +86,7 @@ class OutputTargetUpdate(BaseModel): picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: Optional[List[str]] = None class OutputTargetResponse(BaseModel): @@ -107,6 +109,7 @@ class OutputTargetResponse(BaseModel): picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") description: Optional[str] = Field(None, description="Description") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/pattern_templates.py b/server/src/wled_controller/api/schemas/pattern_templates.py index cbfafec..28acf28 100644 --- a/server/src/wled_controller/api/schemas/pattern_templates.py +++ b/server/src/wled_controller/api/schemas/pattern_templates.py @@ -14,6 +14,7 @@ class PatternTemplateCreate(BaseModel): name: str = Field(description="Template name", min_length=1, max_length=100) rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class PatternTemplateUpdate(BaseModel): @@ -22,6 +23,7 @@ class PatternTemplateUpdate(BaseModel): name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: Optional[List[str]] = None class PatternTemplateResponse(BaseModel): @@ -30,6 +32,7 @@ class PatternTemplateResponse(BaseModel): id: str = Field(description="Template ID") name: str = Field(description="Template name") rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py index 367953c..b1e725a 100644 --- a/server/src/wled_controller/api/schemas/picture_sources.py +++ b/server/src/wled_controller/api/schemas/picture_sources.py @@ -18,6 +18,7 @@ class PictureSourceCreate(BaseModel): postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") description: Optional[str] = Field(None, description="Stream description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class PictureSourceUpdate(BaseModel): @@ -31,6 +32,7 @@ class PictureSourceUpdate(BaseModel): postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") description: Optional[str] = Field(None, description="Stream description", max_length=500) + tags: Optional[List[str]] = None class PictureSourceResponse(BaseModel): @@ -45,6 +47,7 @@ class PictureSourceResponse(BaseModel): source_stream_id: Optional[str] = Field(None, description="Source stream ID") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID") image_source: Optional[str] = Field(None, description="Image URL or file path") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Stream description") diff --git a/server/src/wled_controller/api/schemas/postprocessing.py b/server/src/wled_controller/api/schemas/postprocessing.py index 18a3a21..cc6ddbb 100644 --- a/server/src/wled_controller/api/schemas/postprocessing.py +++ b/server/src/wled_controller/api/schemas/postprocessing.py @@ -14,6 +14,7 @@ class PostprocessingTemplateCreate(BaseModel): name: str = Field(description="Template name", min_length=1, max_length=100) filters: List[FilterInstanceSchema] = Field(default_factory=list, description="Ordered list of filter instances") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class PostprocessingTemplateUpdate(BaseModel): @@ -22,6 +23,7 @@ class PostprocessingTemplateUpdate(BaseModel): name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100) filters: Optional[List[FilterInstanceSchema]] = Field(None, description="Ordered list of filter instances") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: Optional[List[str]] = None class PostprocessingTemplateResponse(BaseModel): @@ -30,6 +32,7 @@ class PostprocessingTemplateResponse(BaseModel): id: str = Field(description="Template ID") name: str = Field(description="Template name") filters: List[FilterInstanceSchema] = Field(description="Ordered list of filter instances") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") diff --git a/server/src/wled_controller/api/schemas/scene_presets.py b/server/src/wled_controller/api/schemas/scene_presets.py index 3d6857f..710241b 100644 --- a/server/src/wled_controller/api/schemas/scene_presets.py +++ b/server/src/wled_controller/api/schemas/scene_presets.py @@ -20,6 +20,7 @@ class ScenePresetCreate(BaseModel): name: str = Field(description="Preset name", min_length=1, max_length=100) description: str = Field(default="", max_length=500) target_ids: Optional[List[str]] = Field(None, description="Target IDs to capture (all if omitted)") + tags: List[str] = Field(default_factory=list, description="User-defined tags") class ScenePresetUpdate(BaseModel): @@ -29,6 +30,7 @@ class ScenePresetUpdate(BaseModel): description: Optional[str] = Field(None, max_length=500) order: Optional[int] = None target_ids: Optional[List[str]] = Field(None, description="Update target list: keep state for existing, capture fresh for new, drop removed") + tags: Optional[List[str]] = None class ScenePresetResponse(BaseModel): @@ -39,6 +41,7 @@ class ScenePresetResponse(BaseModel): description: str targets: List[TargetSnapshotSchema] order: int + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime updated_at: datetime diff --git a/server/src/wled_controller/api/schemas/sync_clocks.py b/server/src/wled_controller/api/schemas/sync_clocks.py index 40dc26b..300bd9e 100644 --- a/server/src/wled_controller/api/schemas/sync_clocks.py +++ b/server/src/wled_controller/api/schemas/sync_clocks.py @@ -12,6 +12,7 @@ class SyncClockCreate(BaseModel): 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) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class SyncClockUpdate(BaseModel): @@ -20,6 +21,7 @@ class SyncClockUpdate(BaseModel): 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) + tags: Optional[List[str]] = None class SyncClockResponse(BaseModel): @@ -29,6 +31,7 @@ class SyncClockResponse(BaseModel): name: str = Field(description="Clock name") speed: float = Field(description="Speed multiplier") description: Optional[str] = Field(None, description="Description") + tags: List[str] = Field(default_factory=list, description="User-defined tags") 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") diff --git a/server/src/wled_controller/api/schemas/templates.py b/server/src/wled_controller/api/schemas/templates.py index c0d04ca..063a8c3 100644 --- a/server/src/wled_controller/api/schemas/templates.py +++ b/server/src/wled_controller/api/schemas/templates.py @@ -13,6 +13,7 @@ class TemplateCreate(BaseModel): engine_type: str = Field(description="Engine type (e.g., 'mss', 'dxcam', 'wgc')", min_length=1) engine_config: Dict = Field(default_factory=dict, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class TemplateUpdate(BaseModel): @@ -22,6 +23,7 @@ class TemplateUpdate(BaseModel): engine_type: Optional[str] = Field(None, description="Capture engine type (mss, dxcam, wgc)") engine_config: Optional[Dict] = Field(None, description="Engine-specific configuration") description: Optional[str] = Field(None, description="Template description", max_length=500) + tags: Optional[List[str]] = None class TemplateResponse(BaseModel): @@ -31,6 +33,7 @@ class TemplateResponse(BaseModel): name: str = Field(description="Template name") engine_type: str = Field(description="Engine type identifier") engine_config: Dict = Field(description="Engine-specific configuration") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Template description") diff --git a/server/src/wled_controller/api/schemas/value_sources.py b/server/src/wled_controller/api/schemas/value_sources.py index b610a14..3679825 100644 --- a/server/src/wled_controller/api/schemas/value_sources.py +++ b/server/src/wled_controller/api/schemas/value_sources.py @@ -29,6 +29,7 @@ class ValueSourceCreate(BaseModel): picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode") scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: List[str] = Field(default_factory=list, description="User-defined tags") class ValueSourceUpdate(BaseModel): @@ -53,6 +54,7 @@ class ValueSourceUpdate(BaseModel): picture_source_id: Optional[str] = Field(None, description="Picture source ID for scene mode") scene_behavior: Optional[str] = Field(None, description="Scene behavior: complement|match") description: Optional[str] = Field(None, description="Optional description", max_length=500) + tags: Optional[List[str]] = None class ValueSourceResponse(BaseModel): @@ -75,6 +77,7 @@ class ValueSourceResponse(BaseModel): picture_source_id: Optional[str] = Field(None, description="Picture source ID") scene_behavior: Optional[str] = Field(None, description="Scene behavior") description: Optional[str] = Field(None, description="Description") + tags: List[str] = Field(default_factory=list, description="User-defined tags") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/core/audio/analysis.py b/server/src/wled_controller/core/audio/analysis.py index 5af0354..77762f0 100644 --- a/server/src/wled_controller/core/audio/analysis.py +++ b/server/src/wled_controller/core/audio/analysis.py @@ -99,6 +99,17 @@ class AudioAnalyzer: self._spectrum_buf_right = np.zeros(NUM_BANDS, dtype=np.float32) self._sq_buf = np.empty(chunk_size, dtype=np.float32) + # Double-buffered output spectra — avoids allocating new arrays each + # analyze() call. Consumers hold a reference to the "old" buffer while + # the analyzer writes into the alternate one. + self._out_spectrum = [np.zeros(NUM_BANDS, dtype=np.float32), + np.zeros(NUM_BANDS, dtype=np.float32)] + self._out_spectrum_left = [np.zeros(NUM_BANDS, dtype=np.float32), + np.zeros(NUM_BANDS, dtype=np.float32)] + self._out_spectrum_right = [np.zeros(NUM_BANDS, dtype=np.float32), + np.zeros(NUM_BANDS, dtype=np.float32)] + self._out_idx = 0 # toggles 0/1 each analyze() call + # Pre-compute band start/end arrays and widths for vectorized binning self._band_starts = np.array([s for s, _ in self._bands], dtype=np.intp) self._band_ends = np.array([e for _, e in self._bands], dtype=np.intp) @@ -168,10 +179,14 @@ class AudioAnalyzer: # FFT for mono, left, right self._fft_bands(samples, self._spectrum_buf, self._smooth_spectrum, alpha, one_minus_alpha) - self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left, - alpha, one_minus_alpha) - self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right, - alpha, one_minus_alpha) + if channels > 1: + self._fft_bands(left_samples, self._spectrum_buf_left, self._smooth_spectrum_left, + alpha, one_minus_alpha) + self._fft_bands(right_samples, self._spectrum_buf_right, self._smooth_spectrum_right, + alpha, one_minus_alpha) + else: + np.copyto(self._smooth_spectrum_left, self._smooth_spectrum) + np.copyto(self._smooth_spectrum_right, self._smooth_spectrum) # Beat detection — compare current energy to rolling average (mono) np.multiply(samples, samples, out=self._sq_buf[:n]) @@ -188,17 +203,27 @@ class AudioAnalyzer: beat = True beat_intensity = min(1.0, (ratio - 1.0) / 2.0) + # Snapshot spectra into double-buffered output arrays (no allocation) + idx = self._out_idx + self._out_idx = 1 - idx + out_spec = self._out_spectrum[idx] + out_left = self._out_spectrum_left[idx] + out_right = self._out_spectrum_right[idx] + np.copyto(out_spec, self._smooth_spectrum) + np.copyto(out_left, self._smooth_spectrum_left) + np.copyto(out_right, self._smooth_spectrum_right) + return AudioAnalysis( timestamp=time.perf_counter(), rms=rms, peak=peak, - spectrum=self._smooth_spectrum.copy(), + spectrum=out_spec, beat=beat, beat_intensity=beat_intensity, left_rms=left_rms, - left_spectrum=self._smooth_spectrum_left.copy(), + left_spectrum=out_left, right_rms=right_rms, - right_spectrum=self._smooth_spectrum_right.copy(), + right_spectrum=out_right, ) def _fft_bands(self, samps, buf, smooth_buf, alpha, one_minus_alpha): diff --git a/server/src/wled_controller/core/backup/auto_backup.py b/server/src/wled_controller/core/backup/auto_backup.py index acf618d..3539814 100644 --- a/server/src/wled_controller/core/backup/auto_backup.py +++ b/server/src/wled_controller/core/backup/auto_backup.py @@ -201,20 +201,25 @@ class AutoBackupEngine: }) return backups - def delete_backup(self, filename: str) -> None: - # Validate filename to prevent path traversal - if os.sep in filename or "/" in filename or ".." in filename: + def _safe_backup_path(self, filename: str) -> Path: + """Resolve a backup filename to an absolute path, guarding against path traversal.""" + if not filename or os.sep in filename or "/" in filename or ".." in filename: raise ValueError("Invalid filename") - target = self._backup_dir / filename + target = (self._backup_dir / filename).resolve() + # Ensure resolved path is still inside the backup directory + if not target.is_relative_to(self._backup_dir.resolve()): + raise ValueError("Invalid filename") + return target + + def delete_backup(self, filename: str) -> None: + target = self._safe_backup_path(filename) if not target.exists(): raise FileNotFoundError(f"Backup not found: {filename}") target.unlink() logger.info(f"Deleted backup: {filename}") def get_backup_path(self, filename: str) -> Path: - if os.sep in filename or "/" in filename or ".." in filename: - raise ValueError("Invalid filename") - target = self._backup_dir / filename + target = self._safe_backup_path(filename) if not target.exists(): raise FileNotFoundError(f"Backup not found: {filename}") return target diff --git a/server/src/wled_controller/core/devices/adalight_client.py b/server/src/wled_controller/core/devices/adalight_client.py index 63b0883..2eade9d 100644 --- a/server/src/wled_controller/core/devices/adalight_client.py +++ b/server/src/wled_controller/core/devices/adalight_client.py @@ -1,7 +1,7 @@ """Adalight serial LED client — sends pixel data over serial using the Adalight protocol.""" import asyncio -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Tuple import numpy as np @@ -199,7 +199,7 @@ class AdalightClient(LEDClient): return DeviceHealth( online=True, latency_ms=0.0, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), device_name=prev_health.device_name if prev_health else None, device_version=None, device_led_count=prev_health.device_led_count if prev_health else None, @@ -207,12 +207,12 @@ class AdalightClient(LEDClient): else: return DeviceHealth( online=False, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), error=f"Serial port {port} not found", ) except Exception as e: return DeviceHealth( online=False, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), error=str(e), ) diff --git a/server/src/wled_controller/core/devices/ddp_client.py b/server/src/wled_controller/core/devices/ddp_client.py index b07c945..98449bb 100644 --- a/server/src/wled_controller/core/devices/ddp_client.py +++ b/server/src/wled_controller/core/devices/ddp_client.py @@ -190,9 +190,12 @@ class DDPClient: try: # Send plain RGB — WLED handles per-bus color order conversion # internally when outputting to hardware. - # Convert to numpy to avoid per-pixel Python loop + # Accept numpy arrays directly to avoid per-pixel Python loop bpp = 4 if self.rgbw else 3 # bytes per pixel - pixel_array = np.array(pixels, dtype=np.uint8) + if isinstance(pixels, np.ndarray): + pixel_array = pixels + else: + pixel_array = np.array(pixels, dtype=np.uint8) if self.rgbw: n = pixel_array.shape[0] if n != self._rgbw_buf_n: @@ -219,7 +222,7 @@ class DDPClient: for i in range(num_packets): start = i * bytes_per_packet end = min(start + bytes_per_packet, total_bytes) - chunk = bytes(pixel_bytes[start:end]) + chunk = pixel_bytes[start:end] is_last = (i == num_packets - 1) # Increment sequence number diff --git a/server/src/wled_controller/core/devices/led_client.py b/server/src/wled_controller/core/devices/led_client.py index eba010a..3043961 100644 --- a/server/src/wled_controller/core/devices/led_client.py +++ b/server/src/wled_controller/core/devices/led_client.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple, Union import numpy as np @@ -139,7 +139,7 @@ class LEDClient(ABC): http_client: Shared httpx.AsyncClient for HTTP requests prev_health: Previous health result (for preserving cached metadata) """ - return DeviceHealth(online=True, last_checked=datetime.utcnow()) + return DeviceHealth(online=True, last_checked=datetime.now(timezone.utc)) async def __aenter__(self): await self.connect() diff --git a/server/src/wled_controller/core/devices/mock_client.py b/server/src/wled_controller/core/devices/mock_client.py index a0e8519..0b65696 100644 --- a/server/src/wled_controller/core/devices/mock_client.py +++ b/server/src/wled_controller/core/devices/mock_client.py @@ -1,7 +1,7 @@ """Mock LED client — simulates an LED strip with configurable latency for testing.""" import asyncio -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Tuple, Union import numpy as np @@ -69,5 +69,5 @@ class MockClient(LEDClient): return DeviceHealth( online=True, latency_ms=0.0, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), ) diff --git a/server/src/wled_controller/core/devices/mock_provider.py b/server/src/wled_controller/core/devices/mock_provider.py index e81ead7..6faf10f 100644 --- a/server/src/wled_controller/core/devices/mock_provider.py +++ b/server/src/wled_controller/core/devices/mock_provider.py @@ -1,6 +1,6 @@ """Mock device provider — virtual LED strip for testing.""" -from datetime import datetime +from datetime import datetime, timezone from typing import List from wled_controller.core.devices.led_client import ( @@ -28,7 +28,7 @@ class MockDeviceProvider(LEDDeviceProvider): return MockClient(url, **kwargs) async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: - return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow()) + return DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc)) async def validate_device(self, url: str) -> dict: return {} diff --git a/server/src/wled_controller/core/devices/mqtt_client.py b/server/src/wled_controller/core/devices/mqtt_client.py index f5b42f9..55d0db8 100644 --- a/server/src/wled_controller/core/devices/mqtt_client.py +++ b/server/src/wled_controller/core/devices/mqtt_client.py @@ -87,12 +87,12 @@ class MQTTLEDClient(LEDClient): http_client, prev_health=None, ) -> DeviceHealth: - from datetime import datetime + from datetime import datetime, timezone svc = _mqtt_service if svc is None or not svc.is_enabled: - return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.utcnow()) + return DeviceHealth(online=False, error="MQTT disabled", last_checked=datetime.now(timezone.utc)) return DeviceHealth( online=svc.is_connected, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), error=None if svc.is_connected else "MQTT broker disconnected", ) diff --git a/server/src/wled_controller/core/devices/openrgb_client.py b/server/src/wled_controller/core/devices/openrgb_client.py index 8717ba7..bb5acf2 100644 --- a/server/src/wled_controller/core/devices/openrgb_client.py +++ b/server/src/wled_controller/core/devices/openrgb_client.py @@ -4,7 +4,7 @@ import asyncio import socket import struct import threading -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple, Union import numpy as np @@ -428,13 +428,13 @@ class OpenRGBLEDClient(LEDClient): return DeviceHealth( online=True, latency_ms=latency, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), device_name=device_name, device_led_count=device_led_count, ) except Exception as e: return DeviceHealth( online=False, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), error=str(e), ) diff --git a/server/src/wled_controller/core/devices/wled_client.py b/server/src/wled_controller/core/devices/wled_client.py index b74d178..1aa8d9c 100644 --- a/server/src/wled_controller/core/devices/wled_client.py +++ b/server/src/wled_controller/core/devices/wled_client.py @@ -3,7 +3,7 @@ import asyncio import time from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Tuple, Optional, Dict, Any from urllib.parse import urlparse @@ -540,7 +540,7 @@ class WLEDClient(LEDClient): return DeviceHealth( online=True, latency_ms=round(latency, 1), - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), device_name=data.get("name"), device_version=data.get("ver"), device_led_count=leds_info.get("count"), @@ -553,7 +553,7 @@ class WLEDClient(LEDClient): return DeviceHealth( online=False, latency_ms=None, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), device_name=prev_health.device_name if prev_health else None, device_version=prev_health.device_version if prev_health else None, device_led_count=prev_health.device_led_count if prev_health else None, diff --git a/server/src/wled_controller/core/devices/ws_client.py b/server/src/wled_controller/core/devices/ws_client.py index 2ee46e8..eaff28e 100644 --- a/server/src/wled_controller/core/devices/ws_client.py +++ b/server/src/wled_controller/core/devices/ws_client.py @@ -1,7 +1,7 @@ """WebSocket LED client — broadcasts pixel data to connected WebSocket clients.""" import asyncio -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple, Union import numpy as np @@ -126,5 +126,5 @@ class WSLEDClient(LEDClient): return DeviceHealth( online=True, latency_ms=0.0, - last_checked=datetime.utcnow(), + last_checked=datetime.now(timezone.utc), ) diff --git a/server/src/wled_controller/core/devices/ws_provider.py b/server/src/wled_controller/core/devices/ws_provider.py index 04f0809..2369fe0 100644 --- a/server/src/wled_controller/core/devices/ws_provider.py +++ b/server/src/wled_controller/core/devices/ws_provider.py @@ -1,6 +1,6 @@ """WebSocket device provider — factory, validation, health checks.""" -from datetime import datetime +from datetime import datetime, timezone from typing import List from wled_controller.core.devices.led_client import ( @@ -33,7 +33,7 @@ class WSDeviceProvider(LEDDeviceProvider): self, url: str, http_client, prev_health=None, ) -> DeviceHealth: return DeviceHealth( - online=True, latency_ms=0.0, last_checked=datetime.utcnow(), + online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc), ) async def validate_device(self, url: str) -> dict: diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index 08f8d69..d2151cc 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -46,6 +46,7 @@ class AudioColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 # Per-frame timing (read by WledTargetProcessor via get_last_timing()) self._last_timing: dict = {} @@ -128,6 +129,7 @@ class AudioColorStripStream(ColorStripStream): def set_capture_fps(self, fps: int) -> None: self._fps = max(1, min(90, fps)) + self._frame_time = 1.0 / self._fps def start(self) -> None: if self._running: @@ -233,7 +235,7 @@ class AudioColorStripStream(ColorStripStream): with high_resolution_timer(): while self._running: loop_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: n = self._led_count diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 069f348..28a61d1 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -587,6 +587,7 @@ class StaticColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime self._update_from_source(source) @@ -636,6 +637,7 @@ class StaticColorStripStream(ColorStripStream): """Update animation loop rate. Thread-safe (read atomically by the loop).""" fps = max(1, min(90, fps)) self._fps = fps + self._frame_time = 1.0 / fps def start(self) -> None: if self._running: @@ -693,7 +695,7 @@ class StaticColorStripStream(ColorStripStream): with high_resolution_timer(): while self._running: wall_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: anim = self._animation if anim and anim.get("enabled"): @@ -807,6 +809,7 @@ class ColorCycleColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime self._update_from_source(source) @@ -849,6 +852,7 @@ class ColorCycleColorStripStream(ColorStripStream): """Update animation loop rate. Thread-safe (read atomically by the loop).""" fps = max(1, min(90, fps)) self._fps = fps + self._frame_time = 1.0 / fps def start(self) -> None: if self._running: @@ -902,7 +906,7 @@ class ColorCycleColorStripStream(ColorStripStream): with high_resolution_timer(): while self._running: wall_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: color_list = self._color_list clock = self._clock @@ -967,6 +971,7 @@ class GradientColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime self._update_from_source(source) @@ -1015,6 +1020,7 @@ class GradientColorStripStream(ColorStripStream): """Update animation loop rate. Thread-safe (read atomically by the loop).""" fps = max(1, min(90, fps)) self._fps = fps + self._frame_time = 1.0 / fps def start(self) -> None: if self._running: @@ -1077,7 +1083,7 @@ class GradientColorStripStream(ColorStripStream): with high_resolution_timer(): while self._running: wall_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: anim = self._animation if anim and anim.get("enabled"): diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 9bc6bdf..2942943 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -46,6 +46,8 @@ class _ColorStripEntry: picture_source_ids: list = None # Per-consumer target FPS values (target_id → fps) target_fps: Dict[str, int] = None + # Clock ID currently acquired for this stream (for correct release) + clock_id: Optional[str] = None def __post_init__(self): if self.picture_source_ids is None: @@ -79,24 +81,36 @@ class ColorStripStreamManager: 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.""" + def _inject_clock(self, css_stream, source) -> Optional[str]: + """Inject a SyncClockRuntime into the stream if source has clock_id. + + Returns the clock_id that was acquired, or None. + """ 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}") + return clock_id except Exception as e: logger.warning(f"Could not inject clock {clock_id}: {e}") + return None - def _release_clock(self, source_id: str, stream) -> None: - """Release the clock runtime acquired for a stream.""" + def _release_clock(self, source_id: str, stream, clock_id: str = None) -> None: + """Release the clock runtime acquired for a stream. + + Args: + source_id: CSS source ID (used as fallback to look up clock_id from store) + stream: The stream instance (unused, kept for API compat) + clock_id: Explicit clock_id to release. If None, looks up from store. + """ 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 not clock_id: + 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: @@ -153,11 +167,12 @@ class ColorStripStreamManager: ) css_stream = stream_cls(source) # Inject sync clock runtime if source references a clock - self._inject_clock(css_stream, source) + acquired_clock_id = 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( stream=css_stream, ref_count=1, picture_source_ids=[], + clock_id=acquired_clock_id, ) logger.info(f"Created {source.source_type} stream {key}") return css_stream @@ -249,8 +264,9 @@ class ColorStripStreamManager: 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) + if entry.clock_id: + source_id = key.split(":")[0] if ":" in key else key + self._release_clock(source_id, entry.stream, clock_id=entry.clock_id) picture_source_ids = entry.picture_source_ids del self._streams[key] @@ -282,26 +298,28 @@ class ColorStripStreamManager: for key in matching_keys: entry = self._streams[key] + old_clock_id = entry.clock_id 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: + if new_clock_id != old_clock_id: + try: + clock_rt = self._sync_clock_manager.acquire(new_clock_id) + entry.stream.set_clock(clock_rt) + entry.clock_id = new_clock_id + # Release old clock after acquiring new one + if old_clock_id: + source_id = key.split(":")[0] if ":" in key else key + self._release_clock(source_id, entry.stream, clock_id=old_clock_id) + except Exception as e: + logger.warning(f"Could not hot-swap clock {new_clock_id}: {e}") + elif old_clock_id: entry.stream.set_clock(None) + entry.clock_id = None source_id = key.split(":")[0] if ":" in key else key - self._release_clock(source_id, entry.stream) + self._release_clock(source_id, entry.stream, clock_id=old_clock_id) # Track picture source changes for future reference counting from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 9205925..17acbab 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -36,6 +36,7 @@ class CompositeColorStripStream(ColorStripStream): self._auto_size: bool = source.led_count == 0 self._css_manager = css_manager self._fps: int = 30 + self._frame_time: float = 1.0 / 30 self._running = False self._thread: Optional[threading.Thread] = None @@ -44,6 +45,7 @@ class CompositeColorStripStream(ColorStripStream): # layer_index -> (source_id, consumer_id, stream) self._sub_streams: Dict[int, tuple] = {} + self._sub_lock = threading.Lock() # guards _sub_streams access across threads # Pre-allocated scratch (rebuilt when LED count changes) self._pool_n = 0 @@ -60,6 +62,10 @@ class CompositeColorStripStream(ColorStripStream): def target_fps(self) -> int: return self._fps + def set_capture_fps(self, fps: int) -> None: + self._fps = max(1, min(90, fps)) + self._frame_time = 1.0 / self._fps + @property def led_count(self) -> int: return self._led_count @@ -69,7 +75,8 @@ class CompositeColorStripStream(ColorStripStream): return True def start(self) -> None: - self._acquire_sub_streams() + with self._sub_lock: + self._acquire_sub_streams() self._running = True self._thread = threading.Thread( target=self._processing_loop, daemon=True, @@ -86,7 +93,8 @@ class CompositeColorStripStream(ColorStripStream): if self._thread is not None: self._thread.join(timeout=5.0) self._thread = None - self._release_sub_streams() + with self._sub_lock: + self._release_sub_streams() logger.info(f"CompositeColorStripStream stopped: {self._source_id}") def get_latest_colors(self) -> Optional[np.ndarray]: @@ -97,7 +105,9 @@ class CompositeColorStripStream(ColorStripStream): if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: self._led_count = device_led_count # Re-configure sub-streams that support auto-sizing - for _idx, (src_id, consumer_id, stream) in self._sub_streams.items(): + with self._sub_lock: + snapshot = dict(self._sub_streams) + for _idx, (src_id, consumer_id, stream) in snapshot.items(): if hasattr(stream, "configure"): stream.configure(device_led_count) logger.debug(f"CompositeColorStripStream auto-sized to {device_led_count} LEDs") @@ -118,8 +128,9 @@ class CompositeColorStripStream(ColorStripStream): # If layer composition changed, rebuild sub-streams if old_layer_ids != new_layer_ids: - self._release_sub_streams() - self._acquire_sub_streams() + with self._sub_lock: + self._release_sub_streams() + self._acquire_sub_streams() logger.info(f"CompositeColorStripStream rebuilt sub-streams: {self._source_id}") # ── Sub-stream lifecycle ──────────────────────────────────── @@ -256,7 +267,7 @@ class CompositeColorStripStream(ColorStripStream): try: while self._running: loop_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: target_n = self._led_count @@ -270,13 +281,16 @@ class CompositeColorStripStream(ColorStripStream): self._use_a = not self._use_a has_result = False + with self._sub_lock: + sub_snapshot = dict(self._sub_streams) + for i, layer in enumerate(self._layers): if not layer.get("enabled", True): continue - if i not in self._sub_streams: + if i not in sub_snapshot: continue - _src_id, _consumer_id, stream = self._sub_streams[i] + _src_id, _consumer_id, stream = sub_snapshot[i] colors = stream.get_latest_colors() if colors is None: continue diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index 9421a10..059184d 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -182,6 +182,7 @@ class EffectColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 self._clock = None # optional SyncClockRuntime self._effective_speed = 1.0 # resolved speed (from clock or source) self._noise = _ValueNoise1D(seed=42) @@ -233,6 +234,7 @@ class EffectColorStripStream(ColorStripStream): def set_capture_fps(self, fps: int) -> None: self._fps = max(1, min(90, fps)) + self._frame_time = 1.0 / self._fps def start(self) -> None: if self._running: @@ -294,7 +296,7 @@ class EffectColorStripStream(ColorStripStream): with high_resolution_timer(): while self._running: wall_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: # Resolve animation time and speed from clock or local clock = self._clock diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py index 2d98313..4cd5bf3 100644 --- a/server/src/wled_controller/core/processing/kc_target_processor.py +++ b/server/src/wled_controller/core/processing/kc_target_processor.py @@ -6,7 +6,7 @@ import asyncio import collections import json import time -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, List, Optional, Tuple import cv2 @@ -169,7 +169,7 @@ class KCTargetProcessor(TargetProcessor): self._value_stream = None # Reset metrics - self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) + self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc)) self._previous_colors = None self._latest_colors = None @@ -276,7 +276,7 @@ class KCTargetProcessor(TargetProcessor): metrics = self._metrics uptime = 0.0 if metrics.start_time and self._is_running: - uptime = (datetime.utcnow() - metrics.start_time).total_seconds() + uptime = (datetime.now(timezone.utc) - metrics.start_time).total_seconds() return { "target_id": self._target_id, @@ -417,7 +417,7 @@ class KCTargetProcessor(TargetProcessor): # Update metrics self._metrics.frames_processed += 1 - self._metrics.last_update = datetime.utcnow() + self._metrics.last_update = datetime.now(timezone.utc) # Calculate actual FPS now = time.perf_counter() @@ -452,6 +452,7 @@ class KCTargetProcessor(TargetProcessor): except Exception as e: logger.error(f"Fatal error in KC processing loop for target {self._target_id}: {e}") self._is_running = False + self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True}) raise finally: logger.info(f"KC processing loop ended for target {self._target_id}") @@ -468,7 +469,7 @@ class KCTargetProcessor(TargetProcessor): name: {"r": c[0], "g": c[1], "b": c[2]} for name, c in colors.items() }, - "timestamp": datetime.utcnow().isoformat(), + "timestamp": datetime.now(timezone.utc).isoformat(), }) async def _send_safe(ws): @@ -478,8 +479,9 @@ class KCTargetProcessor(TargetProcessor): except Exception: return False - results = await asyncio.gather(*[_send_safe(ws) for ws in self._ws_clients]) + clients = list(self._ws_clients) + results = await asyncio.gather(*[_send_safe(ws) for ws in clients]) - disconnected = [ws for ws, ok in zip(self._ws_clients, results) if not ok] - for ws in disconnected: - self._ws_clients.remove(ws) + for ws, ok in zip(clients, results): + if not ok and ws in self._ws_clients: + self._ws_clients.remove(ws) diff --git a/server/src/wled_controller/core/processing/live_stream.py b/server/src/wled_controller/core/processing/live_stream.py index 28b789e..a4cb2f6 100644 --- a/server/src/wled_controller/core/processing/live_stream.py +++ b/server/src/wled_controller/core/processing/live_stream.py @@ -75,6 +75,7 @@ class ScreenCaptureLiveStream(LiveStream): def __init__(self, capture_stream: CaptureStream, fps: int): self._capture_stream = capture_stream self._fps = fps + self._frame_time = 1.0 / fps if fps > 0 else 1.0 self._latest_frame: Optional[ScreenCapture] = None self._frame_lock = threading.Lock() self._running = False @@ -128,7 +129,7 @@ class ScreenCaptureLiveStream(LiveStream): return self._latest_frame def _capture_loop(self) -> None: - frame_time = 1.0 / self._fps if self._fps > 0 else 1.0 + frame_time = self._frame_time consecutive_errors = 0 try: with high_resolution_timer(): diff --git a/server/src/wled_controller/core/processing/mapped_stream.py b/server/src/wled_controller/core/processing/mapped_stream.py index a442cb0..227ac00 100644 --- a/server/src/wled_controller/core/processing/mapped_stream.py +++ b/server/src/wled_controller/core/processing/mapped_stream.py @@ -31,6 +31,7 @@ class MappedColorStripStream(ColorStripStream): self._auto_size: bool = source.led_count == 0 self._css_manager = css_manager self._fps: int = 30 + self._frame_time: float = 1.0 / 30 self._running = False self._thread: Optional[threading.Thread] = None @@ -39,6 +40,7 @@ class MappedColorStripStream(ColorStripStream): # zone_index -> (source_id, consumer_id, stream) self._sub_streams: Dict[int, tuple] = {} + self._sub_lock = threading.Lock() # guards _sub_streams access across threads # ── ColorStripStream interface ────────────────────────────── @@ -46,6 +48,10 @@ class MappedColorStripStream(ColorStripStream): def target_fps(self) -> int: return self._fps + def set_capture_fps(self, fps: int) -> None: + self._fps = max(1, min(90, fps)) + self._frame_time = 1.0 / self._fps + @property def led_count(self) -> int: return self._led_count @@ -55,7 +61,8 @@ class MappedColorStripStream(ColorStripStream): return True def start(self) -> None: - self._acquire_sub_streams() + with self._sub_lock: + self._acquire_sub_streams() self._running = True self._thread = threading.Thread( target=self._processing_loop, daemon=True, @@ -72,7 +79,8 @@ class MappedColorStripStream(ColorStripStream): if self._thread is not None: self._thread.join(timeout=5.0) self._thread = None - self._release_sub_streams() + with self._sub_lock: + self._release_sub_streams() logger.info(f"MappedColorStripStream stopped: {self._source_id}") def get_latest_colors(self) -> Optional[np.ndarray]: @@ -82,7 +90,8 @@ class MappedColorStripStream(ColorStripStream): def configure(self, device_led_count: int) -> None: if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: self._led_count = device_led_count - self._reconfigure_sub_streams() + with self._sub_lock: + self._reconfigure_sub_streams() logger.debug(f"MappedColorStripStream auto-sized to {device_led_count} LEDs") def update_source(self, source) -> None: @@ -100,8 +109,9 @@ class MappedColorStripStream(ColorStripStream): self._auto_size = False if old_zone_ids != new_zone_ids: - self._release_sub_streams() - self._acquire_sub_streams() + with self._sub_lock: + self._release_sub_streams() + self._acquire_sub_streams() logger.info(f"MappedColorStripStream rebuilt sub-streams: {self._source_id}") # ── Sub-stream lifecycle ──────────────────────────────────── @@ -152,7 +162,7 @@ class MappedColorStripStream(ColorStripStream): # ── Processing loop ───────────────────────────────────────── def _processing_loop(self) -> None: - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: while self._running: loop_start = time.perf_counter() @@ -165,11 +175,14 @@ class MappedColorStripStream(ColorStripStream): result = np.zeros((target_n, 3), dtype=np.uint8) + with self._sub_lock: + sub_snapshot = dict(self._sub_streams) + for i, zone in enumerate(self._zones): - if i not in self._sub_streams: + if i not in sub_snapshot: continue - _src_id, _consumer_id, stream = self._sub_streams[i] + _src_id, _consumer_id, stream = sub_snapshot[i] colors = stream.get_latest_colors() if colors is None: continue diff --git a/server/src/wled_controller/core/processing/metrics_history.py b/server/src/wled_controller/core/processing/metrics_history.py index 571f336..fa36514 100644 --- a/server/src/wled_controller/core/processing/metrics_history.py +++ b/server/src/wled_controller/core/processing/metrics_history.py @@ -2,7 +2,7 @@ import asyncio from collections import deque -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, Optional from wled_controller.utils import get_logger @@ -22,7 +22,7 @@ def _collect_system_snapshot() -> dict: mem = psutil.virtual_memory() snapshot = { - "t": datetime.utcnow().isoformat(), + "t": datetime.now(timezone.utc).isoformat(), "cpu": psutil.cpu_percent(interval=None), "ram_pct": mem.percent, "ram_used": round(mem.used / 1024 / 1024, 1), @@ -95,7 +95,7 @@ class MetricsHistory: except Exception: all_states = {} - now = datetime.utcnow().isoformat() + now = datetime.now(timezone.utc).isoformat() active_ids = set() for target_id, state in all_states.items(): active_ids.add(target_id) diff --git a/server/src/wled_controller/core/processing/notification_stream.py b/server/src/wled_controller/core/processing/notification_stream.py index 7e7da1a..f34a93d 100644 --- a/server/src/wled_controller/core/processing/notification_stream.py +++ b/server/src/wled_controller/core/processing/notification_stream.py @@ -53,6 +53,7 @@ class NotificationColorStripStream(ColorStripStream): self._running = False self._thread: Optional[threading.Thread] = None self._fps = 30 + self._frame_time = 1.0 / 30 # Event queue: deque of (color_rgb_tuple, start_time) self._event_queue: collections.deque = collections.deque(maxlen=16) @@ -119,6 +120,10 @@ class NotificationColorStripStream(ColorStripStream): def target_fps(self) -> int: return self._fps + def set_capture_fps(self, fps: int) -> None: + self._fps = max(1, min(90, fps)) + self._frame_time = 1.0 / self._fps + @property def is_animated(self) -> bool: return True @@ -179,7 +184,7 @@ class NotificationColorStripStream(ColorStripStream): try: while self._running: wall_start = time.perf_counter() - frame_time = 1.0 / self._fps + frame_time = self._frame_time try: # Check for new events diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 5345273..f04d062 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -122,6 +122,10 @@ class ProcessorManager: def metrics_history(self) -> MetricsHistory: return self._metrics_history + @property + def color_strip_stream_manager(self) -> ColorStripStreamManager: + return self._color_strip_stream_manager + # ===== SHARED CONTEXT (passed to target processors) ===== def _build_context(self) -> TargetContext: @@ -821,8 +825,8 @@ class ProcessorManager: return # Skip periodic health checks for virtual devices (always online) if "health_check" not in get_device_capabilities(state.device_type): - from datetime import datetime - state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.utcnow()) + from datetime import datetime, timezone + state.health = DeviceHealth(online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc)) return if state.health_task and not state.health_task.done(): return @@ -897,6 +901,22 @@ class ProcessorManager: # ===== HELPERS ===== + def has_device(self, device_id: str) -> bool: + """Check if a device is registered.""" + return device_id in self._devices + + def find_device_state(self, device_id: str) -> Optional[DeviceState]: + """Get device state, returning None if not registered.""" + return self._devices.get(device_id) + + async def send_clear_pixels(self, device_id: str) -> None: + """Send all-black pixels to a device (public wrapper).""" + await self._send_clear_pixels(device_id) + + def get_processor(self, target_id: str) -> Optional[TargetProcessor]: + """Look up a processor by target_id, returning None if not found.""" + return self._processors.get(target_id) + def _get_processor(self, target_id: str) -> TargetProcessor: """Look up a processor by target_id, raising ValueError if not found.""" proc = self._processors.get(target_id) diff --git a/server/src/wled_controller/core/processing/sync_clock_manager.py b/server/src/wled_controller/core/processing/sync_clock_manager.py index 034a4e0..fe929b7 100644 --- a/server/src/wled_controller/core/processing/sync_clock_manager.py +++ b/server/src/wled_controller/core/processing/sync_clock_manager.py @@ -4,6 +4,7 @@ Runtimes are created lazily when a stream first acquires a clock and destroyed when the last consumer releases it. """ +import threading from typing import Dict, Optional from wled_controller.core.processing.sync_clock_runtime import SyncClockRuntime @@ -18,6 +19,7 @@ class SyncClockManager: def __init__(self, store: SyncClockStore) -> None: self._store = store + self._lock = threading.Lock() self._runtimes: Dict[str, SyncClockRuntime] = {} self._ref_counts: Dict[str, int] = {} @@ -25,56 +27,62 @@ class SyncClockManager: 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] + with self._lock: + 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 + 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}") + with self._lock: + 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) + with self._lock: + 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() + with self._lock: + 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) + with self._lock: + 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: + with self._lock: + 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 - 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 ───────────────────────────────────────── diff --git a/server/src/wled_controller/core/processing/sync_clock_runtime.py b/server/src/wled_controller/core/processing/sync_clock_runtime.py index 2d99a4e..0c5df67 100644 --- a/server/src/wled_controller/core/processing/sync_clock_runtime.py +++ b/server/src/wled_controller/core/processing/sync_clock_runtime.py @@ -44,9 +44,10 @@ class SyncClockRuntime: Returns *real* (wall-clock) elapsed time, not speed-scaled. """ - if not self._running: - return self._offset - return self._offset + (time.perf_counter() - self._epoch) + with self._lock: + if not self._running: + return self._offset + return self._offset + (time.perf_counter() - self._epoch) # ── Control ──────────────────────────────────────────────────── diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 59401c4..d2b261f 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import collections import time -from datetime import datetime +from datetime import datetime, timezone from typing import Optional import httpx @@ -173,7 +173,7 @@ class WledTargetProcessor(TargetProcessor): self._value_stream = None # Reset metrics and start loop - self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) + self._metrics = ProcessingMetrics(start_time=datetime.now(timezone.utc)) self._is_running = True self._task = asyncio.create_task(self._processing_loop()) @@ -404,7 +404,7 @@ class WledTargetProcessor(TargetProcessor): fps_target = self._target_fps uptime_seconds = 0.0 if metrics.start_time and self._is_running: - uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds() + uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds() return { "target_id": self._target_id, @@ -514,11 +514,12 @@ class WledTargetProcessor(TargetProcessor): except Exception: return False - results = await asyncio.gather(*[_send_safe(ws) for ws in self._preview_clients]) + clients = list(self._preview_clients) + results = await asyncio.gather(*[_send_safe(ws) for ws in clients]) - disconnected = [ws for ws, ok in zip(self._preview_clients, results) if not ok] - for ws in disconnected: - self._preview_clients.remove(ws) + for ws, ok in zip(clients, results): + if not ok and ws in self._preview_clients: + self._preview_clients.remove(ws) # ----- Private: processing loop ----- @@ -808,7 +809,7 @@ class WledTargetProcessor(TargetProcessor): self._metrics.timing_send_ms = send_ms self._metrics.frames_processed += 1 - self._metrics.last_update = datetime.utcnow() + self._metrics.last_update = datetime.now(timezone.utc) if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0: logger.info( @@ -898,6 +899,7 @@ class WledTargetProcessor(TargetProcessor): self._metrics.last_error = f"FATAL: {e}" self._metrics.errors_count += 1 self._is_running = False + self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False, "crashed": True}) raise finally: # Clean up probe client diff --git a/server/src/wled_controller/core/scenes/scene_activator.py b/server/src/wled_controller/core/scenes/scene_activator.py index e996748..25cd338 100644 --- a/server/src/wled_controller/core/scenes/scene_activator.py +++ b/server/src/wled_controller/core/scenes/scene_activator.py @@ -30,7 +30,7 @@ def capture_current_snapshot( for t in target_store.get_all_targets(): if target_ids is not None and t.id not in target_ids: continue - proc = processor_manager._processors.get(t.id) + proc = processor_manager.get_processor(t.id) running = proc.is_running if proc else False targets.append(TargetSnapshot( target_id=t.id, @@ -65,7 +65,7 @@ async def apply_scene_state( for ts in preset.targets: if not ts.running: try: - proc = processor_manager._processors.get(ts.target_id) + proc = processor_manager.get_processor(ts.target_id) if proc and proc.is_running: await processor_manager.stop_processing(ts.target_id) except Exception as e: @@ -87,7 +87,7 @@ async def apply_scene_state( target_store.update_target(ts.target_id, **changed) # Sync live processor if running - proc = processor_manager._processors.get(ts.target_id) + proc = processor_manager.get_processor(ts.target_id) if proc and proc.is_running: css_changed = "color_strip_source_id" in changed bvs_changed = "brightness_value_source_id" in changed @@ -107,7 +107,7 @@ async def apply_scene_state( for ts in preset.targets: if ts.running: try: - proc = processor_manager._processors.get(ts.target_id) + proc = processor_manager.get_processor(ts.target_id) if not proc or not proc.is_running: await processor_manager.start_processing(ts.target_id) except Exception as e: diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 76a6062..4871b8c 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -401,6 +401,119 @@ input:-webkit-autofill:focus { background: var(--info-color); } +/* ── Card Tags ──────────────────────────────────────────── */ + +.card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + margin-bottom: 4px; +} + +.card-tag { + display: inline-block; + font-size: 0.68rem; + font-weight: 600; + color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent); + padding: 1px 7px; + border-radius: 8px; + white-space: nowrap; + line-height: 1.4; +} + +/* ── Tag Input (chip-based input with autocomplete) ──── */ + +.tag-input-wrap { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + cursor: text; + min-height: 38px; + transition: border-color 0.15s; +} +.tag-input-wrap:focus-within { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); +} + +.tag-chip { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 0.8rem; + font-weight: 500; + color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent); + padding: 2px 6px; + border-radius: 6px; + white-space: nowrap; + line-height: 1.3; +} + +.tag-chip-remove { + background: none; + border: none; + color: inherit; + font-size: 0.9rem; + cursor: pointer; + padding: 0 2px; + line-height: 1; + opacity: 0.6; + transition: opacity 0.15s; +} +.tag-chip-remove:hover { + opacity: 1; +} + +.tag-input-field { + flex: 1 1 60px; + min-width: 60px; + border: none !important; + outline: none !important; + background: none !important; + padding: 2px 0 !important; + font-size: 0.85rem; + color: var(--text-color); + box-shadow: none !important; +} + +.tag-input-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 1000; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 4px 12px var(--shadow-color); + margin-top: 4px; + max-height: 200px; + overflow-y: auto; +} + +.tag-dropdown-item { + padding: 6px 10px; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.1s; +} +.tag-dropdown-item:hover, +.tag-dropdown-item.tag-dropdown-active { + background: var(--bg-secondary); +} + /* ── Focus-visible indicators for keyboard navigation ── */ .btn:focus-visible, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index a0514cf..7f54227 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -255,6 +255,26 @@ export const automationsCacheObj = new DataCache({ }); automationsCacheObj.subscribe(v => { _automationsCache = v; }); +export const colorStripSourcesCache = new DataCache({ + endpoint: '/color-strip-sources', + extractData: json => json.sources || [], +}); + +export const devicesCache = new DataCache({ + endpoint: '/devices', + extractData: json => json.devices || [], +}); + +export const outputTargetsCache = new DataCache({ + endpoint: '/output-targets', + extractData: json => json.targets || [], +}); + +export const patternTemplatesCache = new DataCache({ + endpoint: '/pattern-templates', + extractData: json => json.templates || [], +}); + export const scenePresetsCache = new DataCache({ endpoint: '/scene-presets', extractData: json => json.presets || [], diff --git a/server/src/wled_controller/static/js/core/tag-input.js b/server/src/wled_controller/static/js/core/tag-input.js new file mode 100644 index 0000000..f26271d --- /dev/null +++ b/server/src/wled_controller/static/js/core/tag-input.js @@ -0,0 +1,225 @@ +/** + * TagInput — reusable chip-based tag input with autocomplete. + * + * Usage: + * import { TagInput } from '../core/tag-input.js'; + * + * const tagInput = new TagInput(document.getElementById('my-container')); + * tagInput.setValue(['bedroom', 'gaming']); + * tagInput.getValue(); // ['bedroom', 'gaming'] + * tagInput.destroy(); + * + * The component fetches available tags from GET /api/v1/tags for autocomplete. + * Tags are stored lowercase, trimmed, deduplicated. + */ + +import { fetchWithAuth } from './api.js'; + +let _allTagsCache = null; +let _allTagsFetchPromise = null; + +/** Fetch all tags from API (cached). Call invalidateTagsCache() after mutations. */ +export async function fetchAllTags() { + if (_allTagsCache) return _allTagsCache; + if (_allTagsFetchPromise) return _allTagsFetchPromise; + _allTagsFetchPromise = fetchWithAuth('/tags') + .then(r => r.json()) + .then(data => { + _allTagsCache = data.tags || []; + _allTagsFetchPromise = null; + return _allTagsCache; + }) + .catch(() => { + _allTagsFetchPromise = null; + return []; + }); + return _allTagsFetchPromise; +} + +/** Call after create/update to refresh autocomplete suggestions. */ +export function invalidateTagsCache() { + _allTagsCache = null; +} + +/** + * Render tag chips HTML for display on cards. + * @param {string[]} tags + * @returns {string} HTML string + */ +export function renderTagChips(tags) { + if (!tags || !tags.length) return ''; + return `
${tags.map(tag => + `${_escapeHtml(tag)}` + ).join('')}
`; +} + +function _escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +export class TagInput { + /** + * @param {HTMLElement} container Element to render the tag input into + * @param {object} [opts] + * @param {string} [opts.placeholder] Placeholder text for input + */ + constructor(container, opts = {}) { + this._container = container; + this._tags = []; + this._placeholder = opts.placeholder || 'Add tag...'; + this._dropdownVisible = false; + this._selectedIdx = -1; + + this._render(); + this._bindEvents(); + } + + getValue() { + return [...this._tags]; + } + + setValue(tags) { + this._tags = (tags || []).map(t => t.toLowerCase().trim()).filter(Boolean); + this._tags = [...new Set(this._tags)]; + this._renderChips(); + } + + destroy() { + this._container.innerHTML = ''; + this._hideDropdown(); + } + + // ── private ── + + _render() { + this._container.innerHTML = ` +
+
+ +
+
+ `; + this._chipsEl = this._container.querySelector('.tag-input-chips'); + this._inputEl = this._container.querySelector('.tag-input-field'); + this._dropdownEl = this._container.querySelector('.tag-input-dropdown'); + } + + _renderChips() { + this._chipsEl.innerHTML = this._tags.map((tag, i) => + `${_escapeHtml(tag)}` + ).join(''); + } + + _bindEvents() { + // Chip remove buttons + this._chipsEl.addEventListener('click', (e) => { + const btn = e.target.closest('.tag-chip-remove'); + if (!btn) return; + const idx = parseInt(btn.dataset.idx, 10); + this._tags.splice(idx, 1); + this._renderChips(); + }); + + // Input keydown + this._inputEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') { + if (this._dropdownVisible && this._selectedIdx >= 0) { + // Select from dropdown + e.preventDefault(); + const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item'); + if (items[this._selectedIdx]) { + this._addTag(items[this._selectedIdx].dataset.tag); + } + } else if (this._inputEl.value.trim()) { + e.preventDefault(); + this._addTag(this._inputEl.value); + } + } else if (e.key === 'Backspace' && !this._inputEl.value && this._tags.length) { + this._tags.pop(); + this._renderChips(); + } else if (e.key === 'ArrowDown' && this._dropdownVisible) { + e.preventDefault(); + this._moveSelection(1); + } else if (e.key === 'ArrowUp' && this._dropdownVisible) { + e.preventDefault(); + this._moveSelection(-1); + } else if (e.key === 'Escape') { + this._hideDropdown(); + } + }); + + // Input typing → autocomplete + this._inputEl.addEventListener('input', () => { + this._updateDropdown(); + }); + + // Focus → show dropdown + this._inputEl.addEventListener('focus', () => { + this._updateDropdown(); + }); + + // Blur → hide (with delay so clicks register) + this._inputEl.addEventListener('blur', () => { + setTimeout(() => this._hideDropdown(), 200); + }); + + // Dropdown click + this._dropdownEl.addEventListener('mousedown', (e) => { + e.preventDefault(); // prevent blur + const item = e.target.closest('.tag-dropdown-item'); + if (item) this._addTag(item.dataset.tag); + }); + } + + _addTag(raw) { + const tag = raw.toLowerCase().trim().replace(/,/g, ''); + if (!tag || this._tags.includes(tag)) { + this._inputEl.value = ''; + this._hideDropdown(); + return; + } + this._tags.push(tag); + this._renderChips(); + this._inputEl.value = ''; + this._hideDropdown(); + invalidateTagsCache(); + } + + async _updateDropdown() { + const query = this._inputEl.value.toLowerCase().trim(); + const allTags = await fetchAllTags(); + + // Filter: exclude already-selected tags, match query + const suggestions = allTags + .filter(t => !this._tags.includes(t)) + .filter(t => !query || t.includes(query)) + .slice(0, 8); + + if (!suggestions.length) { + this._hideDropdown(); + return; + } + + this._dropdownEl.innerHTML = suggestions.map((tag, i) => + `
${_escapeHtml(tag)}
` + ).join(''); + this._dropdownEl.style.display = 'block'; + this._dropdownVisible = true; + this._selectedIdx = 0; + } + + _hideDropdown() { + this._dropdownEl.style.display = 'none'; + this._dropdownVisible = false; + this._selectedIdx = -1; + } + + _moveSelection(delta) { + const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item'); + if (!items.length) return; + items[this._selectedIdx]?.classList.remove('tag-dropdown-active'); + this._selectedIdx = Math.max(0, Math.min(items.length - 1, this._selectedIdx + delta)); + items[this._selectedIdx]?.classList.add('tag-dropdown-active'); + items[this._selectedIdx]?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/server/src/wled_controller/static/js/features/advanced-calibration.js b/server/src/wled_controller/static/js/features/advanced-calibration.js index b045ce1..53d7d66 100644 --- a/server/src/wled_controller/static/js/features/advanced-calibration.js +++ b/server/src/wled_controller/static/js/features/advanced-calibration.js @@ -6,6 +6,7 @@ */ import { API_BASE, fetchWithAuth } from '../core/api.js'; +import { colorStripSourcesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -77,13 +78,12 @@ const _modal = new AdvancedCalibrationModal(); export async function showAdvancedCalibration(cssId) { try { - const [cssResp, psResp] = await Promise.all([ - fetchWithAuth(`/color-strip-sources/${cssId}`), + const [cssSources, psResp] = await Promise.all([ + colorStripSourcesCache.fetch(), fetchWithAuth('/picture-sources'), ]); - if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } - - const source = await cssResp.json(); + const source = cssSources.find(s => s.id === cssId); + if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } const calibration = source.calibration || {}; const psList = psResp.ok ? ((await psResp.json()).streams || []) : []; @@ -168,6 +168,7 @@ export async function saveAdvancedCalibration() { if (resp.ok) { showToast(t('calibration.saved'), 'success'); + colorStripSourcesCache.invalidate(); _modal.forceClose(); } else { const err = await resp.json().catch(() => ({})); diff --git a/server/src/wled_controller/static/js/features/audio-sources.js b/server/src/wled_controller/static/js/features/audio-sources.js index be9727c..5034139 100644 --- a/server/src/wled_controller/static/js/features/audio-sources.js +++ b/server/src/wled_controller/static/js/features/audio-sources.js @@ -17,11 +17,18 @@ import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js'; import { EntitySelect } from '../core/entity-palette.js'; +import { TagInput } from '../core/tag-input.js'; import { loadPictureSources } from './streams.js'; +let _audioSourceTagsInput = null; + class AudioSourceModal extends Modal { constructor() { super('audio-source-modal'); } + onForceClose() { + if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; } + } + snapshotValues() { return { name: document.getElementById('audio-source-name').value, @@ -31,6 +38,7 @@ class AudioSourceModal extends Modal { audioTemplate: document.getElementById('audio-source-audio-template').value, parent: document.getElementById('audio-source-parent').value, channel: document.getElementById('audio-source-channel').value, + tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []), }; } } @@ -86,6 +94,11 @@ export async function showAudioSourceModal(sourceType, editData) { } } + // Tags + if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; } + _audioSourceTagsInput = new TagInput(document.getElementById('audio-source-tags-container'), { placeholder: t('tags.placeholder') }); + _audioSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []); + audioSourceModal.open(); audioSourceModal.snapshot(); } @@ -115,7 +128,7 @@ export async function saveAudioSource() { return; } - const payload = { name, source_type: sourceType, description }; + const payload = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] }; if (sourceType === 'multichannel') { const deviceVal = document.getElementById('audio-source-device').value || '-1:1'; diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 2c12aed..518f56e 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -12,14 +12,21 @@ import { updateTabBadge } from './tabs.js'; import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { attachProcessPicker } from '../core/process-picker.js'; import { csScenes, createSceneCard } from './scene-presets.js'; +let _automationTagsInput = null; + class AutomationEditorModal extends Modal { constructor() { super('automation-editor-modal'); } + onForceClose() { + if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; } + } + snapshotValues() { return { name: document.getElementById('automation-editor-name').value, @@ -29,6 +36,7 @@ class AutomationEditorModal extends Modal { scenePresetId: document.getElementById('automation-scene-id').value, deactivationMode: document.getElementById('automation-deactivation-mode').value, deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value, + tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []), }; } } @@ -204,7 +212,8 @@ function createAutomationCard(automation, sceneMap = new Map()) { ${deactivationLabel ? `${deactivationLabel}` : ''} ${lastActivityMeta} -
${condPills}
`, +
${condPills}
+ ${renderTagChips(automation.tags)}`, actions: ` @@ -240,6 +249,8 @@ export async function openAutomationEditor(automationId, cloneData) { if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none'); document.getElementById('automation-fallback-scene-group').style.display = 'none'; + let _editorTags = []; + if (automationId) { titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`; try { @@ -266,6 +277,7 @@ export async function openAutomationEditor(automationId, cloneData) { if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode); _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id); + _editorTags = automation.tags || []; } catch (e) { showToast(e.message, 'error'); return; @@ -293,6 +305,7 @@ export async function openAutomationEditor(automationId, cloneData) { if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode); _onDeactivationModeChange(); _initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id); + _editorTags = cloneData.tags || []; } else { titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`; idInput.value = ''; @@ -314,6 +327,12 @@ export async function openAutomationEditor(automationId, cloneData) { modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => { el.placeholder = t(el.getAttribute('data-i18n-placeholder')); }); + + // Tags + if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; } + _automationTagsInput = new TagInput(document.getElementById('automation-tags-container'), { placeholder: t('tags.placeholder') }); + _automationTagsInput.setValue(_editorTags); + automationModal.snapshot(); } @@ -671,6 +690,7 @@ export async function saveAutomationEditor() { scene_preset_id: document.getElementById('automation-scene-id').value || null, deactivation_mode: document.getElementById('automation-deactivation-mode').value, deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null, + tags: _automationTagsInput ? _automationTagsInput.getValue() : [], }; const automationId = idInput.value; diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js index e43686f..08d99ee 100644 --- a/server/src/wled_controller/static/js/features/calibration.js +++ b/server/src/wled_controller/static/js/features/calibration.js @@ -6,6 +6,7 @@ import { calibrationTestState, EDGE_TEST_COLORS, displaysCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; +import { colorStripSourcesCache, devicesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -231,13 +232,12 @@ export async function closeCalibrationModal() { export async function showCSSCalibration(cssId) { try { - const [cssResp, devicesResp] = await Promise.all([ - fetchWithAuth(`/color-strip-sources/${cssId}`), - fetchWithAuth('/devices'), + const [cssSources, devices] = await Promise.all([ + colorStripSourcesCache.fetch(), + devicesCache.fetch().catch(() => []), ]); - - if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } - const source = await cssResp.json(); + const source = cssSources.find(s => s.id === cssId); + if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } const calibration = source.calibration || { } @@ -246,7 +246,6 @@ export async function showCSSCalibration(cssId) { document.getElementById('calibration-css-id').value = cssId; // Populate device picker for edge test - const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : []; const testDeviceSelect = document.getElementById('calibration-test-device'); testDeviceSelect.innerHTML = ''; devices.forEach(d => { @@ -940,6 +939,7 @@ export async function saveCalibration() { } if (response.ok) { showToast(t('calibration.saved'), 'success'); + if (cssMode) colorStripSourcesCache.invalidate(); calibModal.forceClose(); if (cssMode) { if (window.loadTargetsTab) window.loadTargetsTab(); diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 4d935f3..5522b1c 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -3,7 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.js'; -import { _cachedSyncClocks, audioSourcesCache, streamsCache } from '../core/state.js'; +import { _cachedSyncClocks, audioSourcesCache, streamsCache, colorStripSourcesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -16,15 +16,28 @@ import { } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { attachProcessPicker } from '../core/process-picker.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; +import { + rgbArrayToHex, hexToRgbArray, + gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset, + getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML, +} from './css-gradient-editor.js'; + +// Re-export for app.js window global bindings +export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset }; class CSSEditorModal extends Modal { constructor() { super('css-editor-modal'); } + onForceClose() { + if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; } + } + snapshotValues() { const type = document.getElementById('css-editor-type').value; return { @@ -39,7 +52,7 @@ class CSSEditorModal extends Modal { color: document.getElementById('css-editor-color').value, frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, led_count: document.getElementById('css-editor-led-count').value, - gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', + gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]', animation_type: document.getElementById('css-editor-animation-type').value, cycle_colors: JSON.stringify(_colorCycleColors), effect_type: document.getElementById('css-editor-effect-type').value, @@ -67,12 +80,15 @@ class CSSEditorModal extends Modal { notification_filter_list: document.getElementById('css-editor-notification-filter-list').value, notification_app_colors: JSON.stringify(_notificationAppColors), clock_id: document.getElementById('css-editor-clock').value, + tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []), }; } } const cssEditorModal = new CSSEditorModal(); +let _cssTagsInput = null; + // ── EntitySelect instances for CSS editor ── let _cssPictureSourceEntitySelect = null; let _cssAudioSourceEntitySelect = null; @@ -272,13 +288,7 @@ function _gradientStripHTML(pts, w = 80, h = 16) { return ``; } -/** - * Build a gradient preview from _GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}). - */ -function _gradientPresetStripHTML(stops, w = 80, h = 16) { - const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); - return ``; -} +/* gradientPresetStripHTML imported from css-gradient-editor.js */ /* ── Effect / audio palette IconSelect instances ─────────────── */ @@ -355,8 +365,8 @@ function _ensureGradientPresetIconSelect() { if (!sel) return; const items = [ { value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') }, - ...Object.entries(_GRADIENT_PRESETS).map(([key, stops]) => ({ - value: key, icon: _gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`), + ...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({ + value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`), })), ]; if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } @@ -468,16 +478,7 @@ function _loadColorCycleState(css) { } /** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */ -function rgbArrayToHex(rgb) { - if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; - return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); -} - -/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */ -function hexToRgbArray(hex) { - const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); - return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; -} +/* rgbArrayToHex / hexToRgbArray imported from css-gradient-editor.js */ /* ── Composite layer helpers ──────────────────────────────────── */ @@ -1090,7 +1091,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
${propsHtml} -
`, + + ${renderTagChips(source.tags)}`, actions: ` @@ -1132,8 +1134,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) { const sources = await streamsCache.fetch(); // Fetch all color strip sources for composite layer dropdowns - const cssListResp = await fetchWithAuth('/color-strip-sources'); - const allCssSources = cssListResp.ok ? ((await cssListResp.json()).sources || []) : []; + const allCssSources = await colorStripSourcesCache.fetch().catch(() => []); _compositeAvailableSources = allCssSources.filter(s => s.source_type !== 'composite' && (!cssId || s.id !== cssId) ); @@ -1251,9 +1252,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : ''; if (cssId) { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); - if (!resp.ok) throw new Error('Failed to load color strip source'); - const css = await resp.json(); + const cssSources = await colorStripSourcesCache.fetch(); + const css = cssSources.find(s => s.id === cssId); + if (!css) throw new Error('Failed to load color strip source'); document.getElementById('css-editor-id').value = css.id; document.getElementById('css-editor-name').value = css.name; @@ -1328,6 +1329,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) { document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName(); document.getElementById('css-editor-error').style.display = 'none'; + + // Tags + if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; } + const _cssTags = cssId + ? ((await colorStripSourcesCache.fetch()).find(s => s.id === cssId)?.tags || []) + : (cloneData ? (cloneData.tags || []) : []); + _cssTagsInput = new TagInput(document.getElementById('css-tags-container'), { placeholder: t('tags.placeholder') }); + _cssTagsInput.setValue(_cssTags); + cssEditorModal.snapshot(); cssEditorModal.open(); setTimeout(() => document.getElementById('css-editor-name').focus(), 100); @@ -1374,13 +1384,14 @@ export async function saveCSSEditor() { }; if (!cssId) payload.source_type = 'color_cycle'; } else if (sourceType === 'gradient') { - if (_gradientStops.length < 2) { + const gStops = getGradientStops(); + if (gStops.length < 2) { cssEditorModal.showError(t('color_strip.gradient.min_stops')); return; } payload = { name, - stops: _gradientStops.map(s => ({ + stops: gStops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}), @@ -1496,6 +1507,9 @@ export async function saveCSSEditor() { payload.clock_id = clockVal || null; } + // Tags + payload.tags = _cssTagsInput ? _cssTagsInput.getValue() : []; + try { let response; if (cssId) { @@ -1516,6 +1530,7 @@ export async function saveCSSEditor() { } showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success'); + colorStripSourcesCache.invalidate(); cssEditorModal.forceClose(); if (window.loadTargetsTab) await window.loadTargetsTab(); } catch (error) { @@ -1562,9 +1577,9 @@ export function copyEndpointUrl(btn) { export async function cloneColorStrip(cssId) { try { - const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`); - if (!resp.ok) throw new Error('Failed to load color strip source'); - const css = await resp.json(); + const sources = await colorStripSourcesCache.fetch(); + const css = sources.find(s => s.id === cssId); + if (!css) throw new Error('Color strip source not found'); showCSSEditor(null, css); } catch (error) { if (error.isAuth) return; @@ -1585,6 +1600,7 @@ export async function deleteColorStrip(cssId) { }); if (response.ok) { showToast(t('color_strip.deleted'), 'success'); + colorStripSourcesCache.invalidate(); if (window.loadTargetsTab) await window.loadTargetsTab(); } else { const err = await response.json(); @@ -1636,363 +1652,4 @@ export async function stopCSSOverlay(cssId) { } } -/* ══════════════════════════════════════════════════════════════ - GRADIENT EDITOR - ══════════════════════════════════════════════════════════════ */ - -/** - * Internal state: array of stop objects. - * Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null } - */ -let _gradientStops = []; -let _gradientSelectedIdx = -1; -let _gradientDragging = null; // { idx, trackRect } while dragging - -/* ── Interpolation (mirrors Python backend exactly) ───────────── */ - -function _gradientInterpolate(stops, pos) { - if (!stops.length) return [128, 128, 128]; - const sorted = [...stops].sort((a, b) => a.position - b.position); - - if (pos <= sorted[0].position) return sorted[0].color.slice(); - - const last = sorted[sorted.length - 1]; - if (pos >= last.position) return (last.colorRight || last.color).slice(); - - for (let i = 0; i < sorted.length - 1; i++) { - const a = sorted[i]; - const b = sorted[i + 1]; - if (a.position <= pos && pos <= b.position) { - const span = b.position - a.position; - const t2 = span > 0 ? (pos - a.position) / span : 0; - const lc = a.colorRight || a.color; - const rc = b.color; - return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c))); - } - } - return [128, 128, 128]; -} - -/* ── Init ─────────────────────────────────────────────────────── */ - -export function gradientInit(stops) { - _gradientStops = stops.map(s => ({ - position: parseFloat(s.position ?? 0), - color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255], - colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null, - })); - _gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1; - _gradientDragging = null; - _gradientSetupTrackClick(); - gradientRenderAll(); -} - -/* ── Presets ──────────────────────────────────────────────────── */ - -const _GRADIENT_PRESETS = { - rainbow: [ - { position: 0.0, color: [255, 0, 0] }, - { position: 0.17, color: [255, 165, 0] }, - { position: 0.33, color: [255, 255, 0] }, - { position: 0.5, color: [0, 255, 0] }, - { position: 0.67, color: [0, 100, 255] }, - { position: 0.83, color: [75, 0, 130] }, - { position: 1.0, color: [148, 0, 211] }, - ], - sunset: [ - { position: 0.0, color: [255, 60, 0] }, - { position: 0.3, color: [255, 120, 20] }, - { position: 0.6, color: [200, 40, 80] }, - { position: 0.8, color: [120, 20, 120] }, - { position: 1.0, color: [40, 10, 60] }, - ], - ocean: [ - { position: 0.0, color: [0, 10, 40] }, - { position: 0.3, color: [0, 60, 120] }, - { position: 0.6, color: [0, 140, 180] }, - { position: 0.8, color: [100, 220, 240] }, - { position: 1.0, color: [200, 240, 255] }, - ], - forest: [ - { position: 0.0, color: [0, 40, 0] }, - { position: 0.3, color: [0, 100, 20] }, - { position: 0.6, color: [60, 180, 30] }, - { position: 0.8, color: [140, 220, 50] }, - { position: 1.0, color: [220, 255, 80] }, - ], - fire: [ - { position: 0.0, color: [0, 0, 0] }, - { position: 0.25, color: [80, 0, 0] }, - { position: 0.5, color: [255, 40, 0] }, - { position: 0.75, color: [255, 160, 0] }, - { position: 1.0, color: [255, 255, 60] }, - ], - lava: [ - { position: 0.0, color: [0, 0, 0] }, - { position: 0.3, color: [120, 0, 0] }, - { position: 0.6, color: [255, 60, 0] }, - { position: 0.8, color: [255, 160, 40] }, - { position: 1.0, color: [255, 255, 120] }, - ], - aurora: [ - { position: 0.0, color: [0, 20, 40] }, - { position: 0.25, color: [0, 200, 100] }, - { position: 0.5, color: [0, 100, 200] }, - { position: 0.75, color: [120, 0, 200] }, - { position: 1.0, color: [0, 200, 140] }, - ], - ice: [ - { position: 0.0, color: [255, 255, 255] }, - { position: 0.3, color: [180, 220, 255] }, - { position: 0.6, color: [80, 160, 255] }, - { position: 0.85, color: [20, 60, 180] }, - { position: 1.0, color: [10, 20, 80] }, - ], - warm: [ - { position: 0.0, color: [255, 255, 80] }, - { position: 0.33, color: [255, 160, 0] }, - { position: 0.67, color: [255, 60, 0] }, - { position: 1.0, color: [160, 0, 0] }, - ], - cool: [ - { position: 0.0, color: [0, 255, 200] }, - { position: 0.33, color: [0, 120, 255] }, - { position: 0.67, color: [60, 0, 255] }, - { position: 1.0, color: [120, 0, 180] }, - ], - neon: [ - { position: 0.0, color: [255, 0, 200] }, - { position: 0.25, color: [0, 255, 255] }, - { position: 0.5, color: [0, 255, 50] }, - { position: 0.75, color: [255, 255, 0] }, - { position: 1.0, color: [255, 0, 100] }, - ], - pastel: [ - { position: 0.0, color: [255, 180, 180] }, - { position: 0.2, color: [255, 220, 160] }, - { position: 0.4, color: [255, 255, 180] }, - { position: 0.6, color: [180, 255, 200] }, - { position: 0.8, color: [180, 200, 255] }, - { position: 1.0, color: [220, 180, 255] }, - ], -}; - -export function applyGradientPreset(key) { - if (!key || !_GRADIENT_PRESETS[key]) return; - gradientInit(_GRADIENT_PRESETS[key]); -} - -/* ── Render ───────────────────────────────────────────────────── */ - -export function gradientRenderAll() { - _gradientRenderCanvas(); - _gradientRenderMarkers(); - _gradientRenderStopList(); -} - -function _gradientRenderCanvas() { - const canvas = document.getElementById('gradient-canvas'); - if (!canvas) return; - - // Sync canvas pixel width to its CSS display width - const W = Math.max(1, Math.round(canvas.offsetWidth || 300)); - if (canvas.width !== W) canvas.width = W; - - const ctx = canvas.getContext('2d'); - const H = canvas.height; - const imgData = ctx.createImageData(W, H); - - for (let x = 0; x < W; x++) { - const pos = W > 1 ? x / (W - 1) : 0; - const [r, g, b] = _gradientInterpolate(_gradientStops, pos); - for (let y = 0; y < H; y++) { - const idx = (y * W + x) * 4; - imgData.data[idx] = r; - imgData.data[idx + 1] = g; - imgData.data[idx + 2] = b; - imgData.data[idx + 3] = 255; - } - } - ctx.putImageData(imgData, 0, 0); -} - -function _gradientRenderMarkers() { - const track = document.getElementById('gradient-markers-track'); - if (!track) return; - track.innerHTML = ''; - - _gradientStops.forEach((stop, idx) => { - const marker = document.createElement('div'); - marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : ''); - marker.style.left = `${stop.position * 100}%`; - marker.style.background = rgbArrayToHex(stop.color); - marker.title = `${(stop.position * 100).toFixed(0)}%`; - - marker.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - _gradientSelectedIdx = idx; - _gradientStartDrag(e, idx); - _gradientRenderMarkers(); - _gradientRenderStopList(); - }); - - track.appendChild(marker); - }); -} - -/** - * Update the selected stop index and reflect it via CSS classes only — - * no DOM rebuild, so in-flight click events on child elements are preserved. - */ -function _gradientSelectStop(idx) { - _gradientSelectedIdx = idx; - document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx)); - document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx)); -} - -function _gradientRenderStopList() { - const list = document.getElementById('gradient-stops-list'); - if (!list) return; - list.innerHTML = ''; - - _gradientStops.forEach((stop, idx) => { - const row = document.createElement('div'); - row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : ''); - - const hasBidir = !!stop.colorRight; - const rightColor = stop.colorRight || stop.color; - - row.innerHTML = ` - - - - - - - `; - - // Select row on mousedown — CSS-only update so child click events are not interrupted - row.addEventListener('mousedown', () => _gradientSelectStop(idx)); - - // Position - const posInput = row.querySelector('.gradient-stop-pos'); - posInput.addEventListener('change', (e) => { - const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)); - e.target.value = val.toFixed(2); - _gradientStops[idx].position = val; - gradientRenderAll(); - }); - posInput.addEventListener('focus', () => _gradientSelectStop(idx)); - - // Left color - row.querySelector('.gradient-stop-color').addEventListener('input', (e) => { - _gradientStops[idx].color = hexToRgbArray(e.target.value); - const markers = document.querySelectorAll('.gradient-marker'); - if (markers[idx]) markers[idx].style.background = e.target.value; - _gradientRenderCanvas(); - }); - - // Bidirectional toggle - row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => { - e.stopPropagation(); - _gradientStops[idx].colorRight = _gradientStops[idx].colorRight - ? null - : [..._gradientStops[idx].color]; - _gradientRenderStopList(); - _gradientRenderCanvas(); - }); - - // Right color - row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => { - _gradientStops[idx].colorRight = hexToRgbArray(e.target.value); - _gradientRenderCanvas(); - }); - - // Remove - row.querySelector('.btn-danger').addEventListener('click', (e) => { - e.stopPropagation(); - if (_gradientStops.length > 2) { - _gradientStops.splice(idx, 1); - if (_gradientSelectedIdx >= _gradientStops.length) { - _gradientSelectedIdx = _gradientStops.length - 1; - } - gradientRenderAll(); - } - }); - - list.appendChild(row); - }); -} - -/* ── Add Stop ─────────────────────────────────────────────────── */ - -export function gradientAddStop(position) { - if (position === undefined) { - // Find the largest gap between adjacent stops and place in the middle - const sorted = [..._gradientStops].sort((a, b) => a.position - b.position); - let maxGap = 0, gapMid = 0.5; - for (let i = 0; i < sorted.length - 1; i++) { - const gap = sorted[i + 1].position - sorted[i].position; - if (gap > maxGap) { - maxGap = gap; - gapMid = (sorted[i].position + sorted[i + 1].position) / 2; - } - } - position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5; - } - position = Math.min(1, Math.max(0, position)); - const color = _gradientInterpolate(_gradientStops, position); - _gradientStops.push({ position, color, colorRight: null }); - _gradientSelectedIdx = _gradientStops.length - 1; - gradientRenderAll(); -} - -/* ── Drag ─────────────────────────────────────────────────────── */ - -function _gradientStartDrag(e, idx) { - const track = document.getElementById('gradient-markers-track'); - if (!track) return; - _gradientDragging = { idx, trackRect: track.getBoundingClientRect() }; - - const onMove = (me) => { - if (!_gradientDragging) return; - const { trackRect } = _gradientDragging; - const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width)); - _gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100; - gradientRenderAll(); - }; - - const onUp = () => { - _gradientDragging = null; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); -} - -/* ── Track click → add stop ───────────────────────────────────── */ - -function _gradientSetupTrackClick() { - const track = document.getElementById('gradient-markers-track'); - if (!track || track._gradientClickBound) return; - track._gradientClickBound = true; - - track.addEventListener('click', (e) => { - if (_gradientDragging) return; - const rect = track.getBoundingClientRect(); - const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); - // Ignore clicks very close to an existing marker - const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03); - if (!tooClose) { - gradientAddStop(Math.round(pos * 100) / 100); - } - }); -} +/* Gradient editor moved to css-gradient-editor.js */ diff --git a/server/src/wled_controller/static/js/features/css-gradient-editor.js b/server/src/wled_controller/static/js/features/css-gradient-editor.js new file mode 100644 index 0000000..bdeee18 --- /dev/null +++ b/server/src/wled_controller/static/js/features/css-gradient-editor.js @@ -0,0 +1,393 @@ +/** + * Gradient stop editor — canvas preview, draggable markers, stop list, presets. + * + * Extracted from color-strips.js. Self-contained module that manages + * gradient stops state and renders into the CSS editor modal DOM. + */ + +import { t } from '../core/i18n.js'; + +/* ── Color conversion utilities ───────────────────────────────── */ + +export function rgbArrayToHex(rgb) { + if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; + return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join(''); +} + +/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */ +export function hexToRgbArray(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; +} + +/* ── State ────────────────────────────────────────────────────── */ + +/** + * Internal state: array of stop objects. + * Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null } + */ +let _gradientStops = []; +let _gradientSelectedIdx = -1; +let _gradientDragging = null; // { idx, trackRect } while dragging + +/** Read-only accessor for save/dirty-check from the parent module. */ +export function getGradientStops() { + return _gradientStops; +} + +/* ── Interpolation (mirrors Python backend exactly) ───────────── */ + +function _gradientInterpolate(stops, pos) { + if (!stops.length) return [128, 128, 128]; + const sorted = [...stops].sort((a, b) => a.position - b.position); + + if (pos <= sorted[0].position) return sorted[0].color.slice(); + + const last = sorted[sorted.length - 1]; + if (pos >= last.position) return (last.colorRight || last.color).slice(); + + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (a.position <= pos && pos <= b.position) { + const span = b.position - a.position; + const t2 = span > 0 ? (pos - a.position) / span : 0; + const lc = a.colorRight || a.color; + const rc = b.color; + return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c))); + } + } + return [128, 128, 128]; +} + +/* ── Init ─────────────────────────────────────────────────────── */ + +export function gradientInit(stops) { + _gradientStops = stops.map(s => ({ + position: parseFloat(s.position ?? 0), + color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255], + colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null, + })); + _gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1; + _gradientDragging = null; + _gradientSetupTrackClick(); + gradientRenderAll(); +} + +/* ── Presets ──────────────────────────────────────────────────── */ + +export const GRADIENT_PRESETS = { + rainbow: [ + { position: 0.0, color: [255, 0, 0] }, + { position: 0.17, color: [255, 165, 0] }, + { position: 0.33, color: [255, 255, 0] }, + { position: 0.5, color: [0, 255, 0] }, + { position: 0.67, color: [0, 100, 255] }, + { position: 0.83, color: [75, 0, 130] }, + { position: 1.0, color: [148, 0, 211] }, + ], + sunset: [ + { position: 0.0, color: [255, 60, 0] }, + { position: 0.3, color: [255, 120, 20] }, + { position: 0.6, color: [200, 40, 80] }, + { position: 0.8, color: [120, 20, 120] }, + { position: 1.0, color: [40, 10, 60] }, + ], + ocean: [ + { position: 0.0, color: [0, 10, 40] }, + { position: 0.3, color: [0, 60, 120] }, + { position: 0.6, color: [0, 140, 180] }, + { position: 0.8, color: [100, 220, 240] }, + { position: 1.0, color: [200, 240, 255] }, + ], + forest: [ + { position: 0.0, color: [0, 40, 0] }, + { position: 0.3, color: [0, 100, 20] }, + { position: 0.6, color: [60, 180, 30] }, + { position: 0.8, color: [140, 220, 50] }, + { position: 1.0, color: [220, 255, 80] }, + ], + fire: [ + { position: 0.0, color: [0, 0, 0] }, + { position: 0.25, color: [80, 0, 0] }, + { position: 0.5, color: [255, 40, 0] }, + { position: 0.75, color: [255, 160, 0] }, + { position: 1.0, color: [255, 255, 60] }, + ], + lava: [ + { position: 0.0, color: [0, 0, 0] }, + { position: 0.3, color: [120, 0, 0] }, + { position: 0.6, color: [255, 60, 0] }, + { position: 0.8, color: [255, 160, 40] }, + { position: 1.0, color: [255, 255, 120] }, + ], + aurora: [ + { position: 0.0, color: [0, 20, 40] }, + { position: 0.25, color: [0, 200, 100] }, + { position: 0.5, color: [0, 100, 200] }, + { position: 0.75, color: [120, 0, 200] }, + { position: 1.0, color: [0, 200, 140] }, + ], + ice: [ + { position: 0.0, color: [255, 255, 255] }, + { position: 0.3, color: [180, 220, 255] }, + { position: 0.6, color: [80, 160, 255] }, + { position: 0.85, color: [20, 60, 180] }, + { position: 1.0, color: [10, 20, 80] }, + ], + warm: [ + { position: 0.0, color: [255, 255, 80] }, + { position: 0.33, color: [255, 160, 0] }, + { position: 0.67, color: [255, 60, 0] }, + { position: 1.0, color: [160, 0, 0] }, + ], + cool: [ + { position: 0.0, color: [0, 255, 200] }, + { position: 0.33, color: [0, 120, 255] }, + { position: 0.67, color: [60, 0, 255] }, + { position: 1.0, color: [120, 0, 180] }, + ], + neon: [ + { position: 0.0, color: [255, 0, 200] }, + { position: 0.25, color: [0, 255, 255] }, + { position: 0.5, color: [0, 255, 50] }, + { position: 0.75, color: [255, 255, 0] }, + { position: 1.0, color: [255, 0, 100] }, + ], + pastel: [ + { position: 0.0, color: [255, 180, 180] }, + { position: 0.2, color: [255, 220, 160] }, + { position: 0.4, color: [255, 255, 180] }, + { position: 0.6, color: [180, 255, 200] }, + { position: 0.8, color: [180, 200, 255] }, + { position: 1.0, color: [220, 180, 255] }, + ], +}; + +/** + * Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}). + */ +export function gradientPresetStripHTML(stops, w = 80, h = 16) { + const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', '); + return ``; +} + +export function applyGradientPreset(key) { + if (!key || !GRADIENT_PRESETS[key]) return; + gradientInit(GRADIENT_PRESETS[key]); +} + +/* ── Render ───────────────────────────────────────────────────── */ + +export function gradientRenderAll() { + _gradientRenderCanvas(); + _gradientRenderMarkers(); + _gradientRenderStopList(); +} + +function _gradientRenderCanvas() { + const canvas = document.getElementById('gradient-canvas'); + if (!canvas) return; + + // Sync canvas pixel width to its CSS display width + const W = Math.max(1, Math.round(canvas.offsetWidth || 300)); + if (canvas.width !== W) canvas.width = W; + + const ctx = canvas.getContext('2d'); + const H = canvas.height; + const imgData = ctx.createImageData(W, H); + + for (let x = 0; x < W; x++) { + const pos = W > 1 ? x / (W - 1) : 0; + const [r, g, b] = _gradientInterpolate(_gradientStops, pos); + for (let y = 0; y < H; y++) { + const idx = (y * W + x) * 4; + imgData.data[idx] = r; + imgData.data[idx + 1] = g; + imgData.data[idx + 2] = b; + imgData.data[idx + 3] = 255; + } + } + ctx.putImageData(imgData, 0, 0); +} + +function _gradientRenderMarkers() { + const track = document.getElementById('gradient-markers-track'); + if (!track) return; + track.innerHTML = ''; + + _gradientStops.forEach((stop, idx) => { + const marker = document.createElement('div'); + marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : ''); + marker.style.left = `${stop.position * 100}%`; + marker.style.background = rgbArrayToHex(stop.color); + marker.title = `${(stop.position * 100).toFixed(0)}%`; + + marker.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + _gradientSelectedIdx = idx; + _gradientStartDrag(e, idx); + _gradientRenderMarkers(); + _gradientRenderStopList(); + }); + + track.appendChild(marker); + }); +} + +/** + * Update the selected stop index and reflect it via CSS classes only — + * no DOM rebuild, so in-flight click events on child elements are preserved. + */ +function _gradientSelectStop(idx) { + _gradientSelectedIdx = idx; + document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx)); + document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx)); +} + +function _gradientRenderStopList() { + const list = document.getElementById('gradient-stops-list'); + if (!list) return; + list.innerHTML = ''; + + _gradientStops.forEach((stop, idx) => { + const row = document.createElement('div'); + row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : ''); + + const hasBidir = !!stop.colorRight; + const rightColor = stop.colorRight || stop.color; + + row.innerHTML = ` + + + + + + + `; + + // Select row on mousedown — CSS-only update so child click events are not interrupted + row.addEventListener('mousedown', () => _gradientSelectStop(idx)); + + // Position + const posInput = row.querySelector('.gradient-stop-pos'); + posInput.addEventListener('change', (e) => { + const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)); + e.target.value = val.toFixed(2); + _gradientStops[idx].position = val; + gradientRenderAll(); + }); + posInput.addEventListener('focus', () => _gradientSelectStop(idx)); + + // Left color + row.querySelector('.gradient-stop-color').addEventListener('input', (e) => { + _gradientStops[idx].color = hexToRgbArray(e.target.value); + const markers = document.querySelectorAll('.gradient-marker'); + if (markers[idx]) markers[idx].style.background = e.target.value; + _gradientRenderCanvas(); + }); + + // Bidirectional toggle + row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => { + e.stopPropagation(); + _gradientStops[idx].colorRight = _gradientStops[idx].colorRight + ? null + : [..._gradientStops[idx].color]; + _gradientRenderStopList(); + _gradientRenderCanvas(); + }); + + // Right color + row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => { + _gradientStops[idx].colorRight = hexToRgbArray(e.target.value); + _gradientRenderCanvas(); + }); + + // Remove + row.querySelector('.btn-danger').addEventListener('click', (e) => { + e.stopPropagation(); + if (_gradientStops.length > 2) { + _gradientStops.splice(idx, 1); + if (_gradientSelectedIdx >= _gradientStops.length) { + _gradientSelectedIdx = _gradientStops.length - 1; + } + gradientRenderAll(); + } + }); + + list.appendChild(row); + }); +} + +/* ── Add Stop ─────────────────────────────────────────────────── */ + +export function gradientAddStop(position) { + if (position === undefined) { + // Find the largest gap between adjacent stops and place in the middle + const sorted = [..._gradientStops].sort((a, b) => a.position - b.position); + let maxGap = 0, gapMid = 0.5; + for (let i = 0; i < sorted.length - 1; i++) { + const gap = sorted[i + 1].position - sorted[i].position; + if (gap > maxGap) { + maxGap = gap; + gapMid = (sorted[i].position + sorted[i + 1].position) / 2; + } + } + position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5; + } + position = Math.min(1, Math.max(0, position)); + const color = _gradientInterpolate(_gradientStops, position); + _gradientStops.push({ position, color, colorRight: null }); + _gradientSelectedIdx = _gradientStops.length - 1; + gradientRenderAll(); +} + +/* ── Drag ─────────────────────────────────────────────────────── */ + +function _gradientStartDrag(e, idx) { + const track = document.getElementById('gradient-markers-track'); + if (!track) return; + _gradientDragging = { idx, trackRect: track.getBoundingClientRect() }; + + const onMove = (me) => { + if (!_gradientDragging) return; + const { trackRect } = _gradientDragging; + const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width)); + _gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100; + gradientRenderAll(); + }; + + const onUp = () => { + _gradientDragging = null; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); +} + +/* ── Track click → add stop ───────────────────────────────────── */ + +function _gradientSetupTrackClick() { + const track = document.getElementById('gradient-markers-track'); + if (!track || track._gradientClickBound) return; + track._gradientClickBound = true; + + track.addEventListener('click', (e) => { + if (_gradientDragging) return; + const rect = track.getBoundingClientRect(); + const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width)); + // Ignore clicks very close to an existing marker + const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03); + if (!tooClose) { + gradientAddStop(Math.round(pos * 100) / 100); + } + }); +} diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 7e732e7..a71db6c 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -2,7 +2,7 @@ * Dashboard — real-time target status overview. */ -import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js'; +import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js'; @@ -418,27 +418,23 @@ export async function loadDashboard(forceFullRender = false) { try { // Fire all requests in a single batch to avoid sequential RTTs - const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([ - fetchWithAuth('/output-targets'), + const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([ + outputTargetsCache.fetch().catch(() => []), fetchWithAuth('/automations').catch(() => null), - fetchWithAuth('/devices').catch(() => null), - fetchWithAuth('/color-strip-sources').catch(() => null), + devicesCache.fetch().catch(() => []), + colorStripSourcesCache.fetch().catch(() => []), fetchWithAuth('/output-targets/batch/states').catch(() => null), fetchWithAuth('/output-targets/batch/metrics').catch(() => null), loadScenePresets(), fetchWithAuth('/sync-clocks').catch(() => null), ]); - const targetsData = await targetsResp.json(); - const targets = targetsData.targets || []; const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] }; const automations = automationsData.automations || []; - const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] }; const devicesMap = {}; - for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; } - const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] }; + for (const d of devicesArr) { devicesMap[d.id] = d; } const cssSourceMap = {}; - for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; } + for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; } const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] }; const syncClocks = syncClocksData.clocks || []; @@ -782,14 +778,13 @@ export async function dashboardStopTarget(targetId) { export async function dashboardStopAll() { try { - const [targetsResp, statesResp] = await Promise.all([ - fetchWithAuth('/output-targets'), + const [allTargets, statesResp] = await Promise.all([ + outputTargetsCache.fetch().catch(() => []), fetchWithAuth('/output-targets/batch/states'), ]); - const data = await targetsResp.json(); const statesData = statesResp.ok ? await statesResp.json() : { states: {} }; const states = statesData.states || {}; - const running = (data.targets || []).filter(t => states[t.id]?.processing); + const running = allTargets.filter(t => states[t.id]?.processing); await Promise.all(running.map(t => fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {}) )); diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index cb56c3f..b6bc018 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -7,6 +7,7 @@ import { _discoveryCache, set_discoveryCache, } from '../core/state.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, escapeHtml } from '../core/api.js'; +import { devicesCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -463,6 +464,7 @@ export async function handleAddDevice(event) { const result = await response.json(); console.log('Device added successfully:', result); showToast(t('device_discovery.added'), 'success'); + devicesCache.invalidate(); addDeviceModal.forceClose(); if (typeof window.loadDevices === 'function') await window.loadDevices(); if (!localStorage.getItem('deviceTutorialSeen')) { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 9401c3a..f9bd6d8 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -6,12 +6,16 @@ import { _deviceBrightnessCache, updateDeviceBrightness, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js'; +import { devicesCache } from '../core/state.js'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; + +let _deviceTagsInput = null; class DeviceSettingsModal extends Modal { constructor() { super('device-settings-modal'); } @@ -30,6 +34,7 @@ class DeviceSettingsModal extends Modal { send_latency: document.getElementById('settings-send-latency')?.value || '0', zones: JSON.stringify(_getCheckedZones('settings-zone-list')), zoneMode: _getZoneMode('settings-zone-mode'), + tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []), }; } @@ -125,7 +130,8 @@ export function createDeviceCard(device) { onchange="saveCardBrightness('${device.id}', this.value)" title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}" ${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}> - ` : ''}`, + ` : ''} + ${renderTagChips(device.tags)}`, actions: ` `, @@ -109,6 +115,8 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n setPatternEditorSelectedIdx(-1); setPatternCanvasDragMode(null); + let _editorTags = []; + if (templateId) { const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load pattern template'); @@ -119,12 +127,14 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n document.getElementById('pattern-template-description').value = tmpl.description || ''; document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`; setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r }))); + _editorTags = tmpl.tags || []; } else if (cloneData) { document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('pattern-template-description').value = cloneData.description || ''; document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`; setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r }))); + _editorTags = cloneData.tags || []; } else { document.getElementById('pattern-template-id').value = ''; document.getElementById('pattern-template-name').value = ''; @@ -133,6 +143,11 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n setPatternEditorRects([]); } + // Tags + if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; } + _patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') }); + _patternTagsInput.setValue(_editorTags); + patternModal.snapshot(); renderPatternRectList(); @@ -177,6 +192,7 @@ export async function savePatternTemplate() { name: r.name, x: r.x, y: r.y, width: r.width, height: r.height, })), description: description || null, + tags: _patternTagsInput ? _patternTagsInput.getValue() : [], }; try { @@ -197,6 +213,7 @@ export async function savePatternTemplate() { } showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success'); + patternTemplatesCache.invalidate(); patternModal.forceClose(); // Use window.* to avoid circular import with targets.js if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); @@ -209,9 +226,9 @@ export async function savePatternTemplate() { export async function clonePatternTemplate(templateId) { try { - const resp = await fetchWithAuth(`/pattern-templates/${templateId}`); - if (!resp.ok) throw new Error('Failed to load pattern template'); - const tmpl = await resp.json(); + const templates = await patternTemplatesCache.fetch(); + const tmpl = templates.find(t => t.id === templateId); + if (!tmpl) throw new Error('Pattern template not found'); showPatternTemplateEditor(null, tmpl); } catch (error) { if (error.isAuth) return; @@ -229,6 +246,7 @@ export async function deletePatternTemplate(templateId) { }); if (response.ok) { showToast(t('pattern.deleted'), 'success'); + patternTemplatesCache.invalidate(); if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); } else { const error = await response.json(); diff --git a/server/src/wled_controller/static/js/features/scene-presets.js b/server/src/wled_controller/static/js/features/scene-presets.js index ea27077..bfe3e1a 100644 --- a/server/src/wled_controller/static/js/features/scene-presets.js +++ b/server/src/wled_controller/static/js/features/scene-presets.js @@ -11,15 +11,20 @@ import { CardSection } from '../core/card-sections.js'; import { ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE, } from '../core/icons.js'; -import { scenePresetsCache } from '../core/state.js'; +import { scenePresetsCache, outputTargetsCache } from '../core/state.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { cardColorStyle, cardColorButton } from '../core/card-colors.js'; import { EntityPalette } from '../core/entity-palette.js'; let _editingId = null; let _allTargets = []; // fetched on capture open +let _sceneTagsInput = null; class ScenePresetEditorModal extends Modal { constructor() { super('scene-preset-editor-modal'); } + onForceClose() { + if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; } + } snapshotValues() { const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')] .map(el => el.dataset.targetId).sort().join(','); @@ -27,6 +32,7 @@ class ScenePresetEditorModal extends Modal { name: document.getElementById('scene-preset-editor-name').value, description: document.getElementById('scene-preset-editor-description').value, targets: items, + tags: JSON.stringify(_sceneTagsInput ? _sceneTagsInput.getValue() : []), }; } } @@ -61,6 +67,7 @@ export function createSceneCard(preset) { ${meta.map(m => `${m}`).join('')} ${updated ? `${updated}` : ''} + ${renderTagChips(preset.tags)}
@@ -129,15 +136,15 @@ export async function openScenePresetCapture() { selectorGroup.style.display = ''; targetList.innerHTML = ''; try { - const resp = await fetchWithAuth('/output-targets'); - if (resp.ok) { - const data = await resp.json(); - _allTargets = data.targets || []; - _refreshTargetSelect(); - } + _allTargets = await outputTargetsCache.fetch().catch(() => []); + _refreshTargetSelect(); } catch { /* ignore */ } } + if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; } + _sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') }); + _sceneTagsInput.setValue([]); + scenePresetModal.open(); scenePresetModal.snapshot(); } @@ -164,27 +171,27 @@ export async function editScenePreset(presetId) { selectorGroup.style.display = ''; targetList.innerHTML = ''; try { - const resp = await fetchWithAuth('/output-targets'); - if (resp.ok) { - const data = await resp.json(); - _allTargets = data.targets || []; + _allTargets = await outputTargetsCache.fetch().catch(() => []); - // Pre-add targets already in the preset - const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id); - for (const tid of presetTargetIds) { - const tgt = _allTargets.find(t => t.id === tid); - if (!tgt) continue; - const item = document.createElement('div'); - item.className = 'scene-target-item'; - item.dataset.targetId = tid; - item.innerHTML = `${escapeHtml(tgt.name)}`; - targetList.appendChild(item); - } - _refreshTargetSelect(); + // Pre-add targets already in the preset + const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id); + for (const tid of presetTargetIds) { + const tgt = _allTargets.find(t => t.id === tid); + if (!tgt) continue; + const item = document.createElement('div'); + item.className = 'scene-target-item'; + item.dataset.targetId = tid; + item.innerHTML = `${escapeHtml(tgt.name)}`; + targetList.appendChild(item); } + _refreshTargetSelect(); } catch { /* ignore */ } } + if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; } + _sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') }); + _sceneTagsInput.setValue(preset.tags || []); + scenePresetModal.open(); scenePresetModal.snapshot(); } @@ -202,6 +209,8 @@ export async function saveScenePreset() { return; } + const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : []; + try { let resp; if (_editingId) { @@ -209,14 +218,14 @@ export async function saveScenePreset() { .map(el => el.dataset.targetId); resp = await fetchWithAuth(`/scene-presets/${_editingId}`, { method: 'PUT', - body: JSON.stringify({ name, description, target_ids }), + body: JSON.stringify({ name, description, target_ids, tags }), }); } else { const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')] .map(el => el.dataset.targetId); resp = await fetchWithAuth('/scene-presets', { method: 'POST', - body: JSON.stringify({ name, description, target_ids }), + body: JSON.stringify({ name, description, target_ids, tags }), }); } @@ -367,27 +376,27 @@ export async function cloneScenePreset(presetId) { selectorGroup.style.display = ''; targetList.innerHTML = ''; try { - const resp = await fetchWithAuth('/output-targets'); - if (resp.ok) { - const data = await resp.json(); - _allTargets = data.targets || []; + _allTargets = await outputTargetsCache.fetch().catch(() => []); - // Pre-add targets from the cloned preset - const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id); - for (const tid of clonedTargetIds) { - const tgt = _allTargets.find(t => t.id === tid); - if (!tgt) continue; - const item = document.createElement('div'); - item.className = 'scene-target-item'; - item.dataset.targetId = tid; - item.innerHTML = `${escapeHtml(tgt.name)}`; - targetList.appendChild(item); - } - _refreshTargetSelect(); + // Pre-add targets from the cloned preset + const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id); + for (const tid of clonedTargetIds) { + const tgt = _allTargets.find(t => t.id === tid); + if (!tgt) continue; + const item = document.createElement('div'); + item.className = 'scene-target-item'; + item.dataset.targetId = tid; + item.innerHTML = `${escapeHtml(tgt.name)}`; + targetList.appendChild(item); } + _refreshTargetSelect(); } catch { /* ignore */ } } + if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; } + _sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') }); + _sceneTagsInput.setValue(preset.tags || []); + scenePresetModal.open(); scenePresetModal.snapshot(); } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index e81204e..951d325 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -48,10 +48,17 @@ import { ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import * as P from '../core/icon-paths.js'; +// ── TagInput instances for modals ── +let _captureTemplateTagsInput = null; +let _streamTagsInput = null; +let _ppTemplateTagsInput = null; +let _audioTemplateTagsInput = null; + // ── Card section instances ── const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' }); const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' }); @@ -77,6 +84,7 @@ class CaptureTemplateModal extends Modal { name: document.getElementById('template-name').value, description: document.getElementById('template-description').value, engine: document.getElementById('template-engine').value, + tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []), }; document.querySelectorAll('[data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; @@ -85,6 +93,7 @@ class CaptureTemplateModal extends Modal { } onForceClose() { + if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } setCurrentEditingTemplateId(null); set_templateNameManuallyEdited(false); } @@ -104,10 +113,12 @@ class StreamEditorModal extends Modal { source: document.getElementById('stream-source').value, ppTemplate: document.getElementById('stream-pp-template').value, imageSource: document.getElementById('stream-image-source').value, + tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []), }; } onForceClose() { + if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; } document.getElementById('stream-type').disabled = false; set_streamNameManuallyEdited(false); } @@ -121,10 +132,12 @@ class PPTemplateEditorModal extends Modal { name: document.getElementById('pp-template-name').value, description: document.getElementById('pp-template-description').value, filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))), + tags: JSON.stringify(_ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : []), }; } onForceClose() { + if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; } set_modalFilters([]); set_ppTemplateNameManuallyEdited(false); } @@ -138,6 +151,7 @@ class AudioTemplateModal extends Modal { name: document.getElementById('audio-template-name').value, description: document.getElementById('audio-template-description').value, engine: document.getElementById('audio-template-engine').value, + tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []), }; document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => { vals['cfg_' + field.dataset.configKey] = field.value; @@ -146,6 +160,7 @@ class AudioTemplateModal extends Modal { } onForceClose() { + if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } setCurrentEditingAudioTemplateId(null); set_audioTemplateNameManuallyEdited(false); } @@ -194,6 +209,11 @@ export async function showAddTemplateModal(cloneData = null) { populateEngineConfig(cloneData.engine_config); } + // Tags + if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } + _captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') }); + _captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); + templateModal.open(); templateModal.snapshot(); } @@ -221,6 +241,11 @@ export async function editTemplate(templateId) { if (testResults) testResults.style.display = 'none'; document.getElementById('template-error').style.display = 'none'; + // Tags + if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; } + _captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') }); + _captureTemplateTagsInput.setValue(template.tags || []); + templateModal.open(); templateModal.snapshot(); } catch (error) { @@ -611,7 +636,7 @@ export async function saveTemplate() { const description = document.getElementById('template-description').value.trim(); const engineConfig = collectEngineConfig(); - const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null }; + const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] }; try { let response; @@ -813,6 +838,11 @@ export async function showAddAudioTemplateModal(cloneData = null) { populateAudioEngineConfig(cloneData.engine_config); } + // Tags + if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } + _audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') }); + _audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); + audioTemplateModal.open(); audioTemplateModal.snapshot(); } @@ -836,6 +866,11 @@ export async function editAudioTemplate(templateId) { document.getElementById('audio-template-error').style.display = 'none'; + // Tags + if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; } + _audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') }); + _audioTemplateTagsInput.setValue(template.tags || []); + audioTemplateModal.open(); audioTemplateModal.snapshot(); } catch (error) { @@ -861,7 +896,7 @@ export async function saveAudioTemplate() { const description = document.getElementById('audio-template-description').value.trim(); const engineConfig = collectAudioEngineConfig(); - const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null }; + const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] }; try { let response; @@ -1235,6 +1270,7 @@ function renderPictureSourcesList(streams) {
${typeIcon} ${escapeHtml(stream.name)}
${detailsHtml} + ${renderTagChips(stream.tags)} ${stream.description ? `
${escapeHtml(stream.description)}
` : ''}`, actions: ` @@ -1261,6 +1297,7 @@ function renderPictureSourcesList(streams) { ${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''} + ${renderTagChips(template.tags)} ${configEntries.length > 0 ? `
@@ -1302,7 +1339,8 @@ function renderPictureSourcesList(streams) {
${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} - ${filterChainHtml}`, + ${filterChainHtml} + ${renderTagChips(tmpl.tags)}`, actions: ` @@ -1367,6 +1405,7 @@ function renderPictureSourcesList(streams) {
${icon} ${escapeHtml(src.name)}
${propsHtml}
+ ${renderTagChips(src.tags)} ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, actions: ` @@ -1392,6 +1431,7 @@ function renderPictureSourcesList(streams) { ${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''} + ${renderTagChips(template.tags)} ${configEntries.length > 0 ? `
@@ -1563,6 +1603,12 @@ export async function showAddStreamModal(presetType, cloneData = null) { } _showStreamModalLoading(false); + + // Tags + if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; } + _streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') }); + _streamTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); + streamModal.snapshot(); } @@ -1616,6 +1662,12 @@ export async function editStream(streamId) { } _showStreamModalLoading(false); + + // Tags + if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; } + _streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') }); + _streamTagsInput.setValue(stream.tags || []); + streamModal.snapshot(); } catch (error) { console.error('Error loading stream:', error); @@ -1772,7 +1824,7 @@ export async function saveStream() { if (!name) { showToast(t('streams.error.required'), 'error'); return; } - const payload = { name, description: description || null }; + const payload = { name, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] }; if (!streamId) payload.stream_type = streamType; if (streamType === 'raw') { @@ -2429,6 +2481,11 @@ export async function showAddPPTemplateModal(cloneData = null) { document.getElementById('pp-template-description').value = cloneData.description || ''; } + // Tags + if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; } + _ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') }); + _ppTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); + ppTemplateModal.open(); ppTemplateModal.snapshot(); } @@ -2455,6 +2512,11 @@ export async function editPPTemplate(templateId) { _populateFilterSelect(); renderModalFilterList(); + // Tags + if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; } + _ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') }); + _ppTemplateTagsInput.setValue(tmpl.tags || []); + ppTemplateModal.open(); ppTemplateModal.snapshot(); } catch (error) { @@ -2471,7 +2533,7 @@ export async function savePPTemplate() { if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; } - const payload = { name, filters: collectFilters(), description: description || null }; + const payload = { name, filters: collectFilters(), description: description || null, tags: _ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : [] }; try { let response; diff --git a/server/src/wled_controller/static/js/features/sync-clocks.js b/server/src/wled_controller/static/js/features/sync-clocks.js index 915dbee..5f032ad 100644 --- a/server/src/wled_controller/static/js/features/sync-clocks.js +++ b/server/src/wled_controller/static/js/features/sync-clocks.js @@ -9,18 +9,26 @@ 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 { TagInput, renderTagChips } from '../core/tag-input.js'; import { loadPictureSources } from './streams.js'; // ── Modal ── +let _syncClockTagsInput = null; + class SyncClockModal extends Modal { constructor() { super('sync-clock-modal'); } + onForceClose() { + if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; } + } + snapshotValues() { return { name: document.getElementById('sync-clock-name').value, speed: document.getElementById('sync-clock-speed').value, description: document.getElementById('sync-clock-description').value, + tags: JSON.stringify(_syncClockTagsInput ? _syncClockTagsInput.getValue() : []), }; } } @@ -48,6 +56,11 @@ export async function showSyncClockModal(editData) { document.getElementById('sync-clock-description').value = ''; } + // Tags + if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; } + _syncClockTagsInput = new TagInput(document.getElementById('sync-clock-tags-container'), { placeholder: t('tags.placeholder') }); + _syncClockTagsInput.setValue(isEdit ? (editData.tags || []) : []); + syncClockModal.open(); syncClockModal.snapshot(); } @@ -69,7 +82,7 @@ export async function saveSyncClock() { return; } - const payload = { name, speed, description }; + const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] }; try { const method = id ? 'PUT' : 'POST'; @@ -199,6 +212,7 @@ export function createSyncClockCard(clock) { ${statusIcon} ${statusLabel} ${ICON_CLOCK} ${clock.speed}x
+ ${renderTagChips(clock.tags)} ${clock.description ? `
${escapeHtml(clock.description)}
` : ''}`, actions: ` diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 76d5e65..cace13e 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -10,6 +10,7 @@ import { ledPreviewWebSockets, _cachedValueSources, valueSourcesCache, streamsCache, audioSourcesCache, syncClocksCache, + colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js'; import { t } from '../core/i18n.js'; @@ -28,6 +29,7 @@ import { } from '../core/icons.js'; import { EntitySelect } from '../core/entity-palette.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash, updateTabBadge } from './tabs.js'; @@ -140,6 +142,7 @@ function _updateSubTabCounts(subTabs) { // --- Editor state --- let _editorCssSources = []; // populated when editor opens +let _targetTagsInput = null; class TargetEditorModal extends Modal { constructor() { @@ -157,6 +160,7 @@ class TargetEditorModal extends Modal { fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked, + tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []), }; } } @@ -311,14 +315,12 @@ function _ensureTargetEntitySelects() { export async function showTargetEditor(targetId = null, cloneData = null) { try { // Load devices, CSS sources, and value sources for dropdowns - const [devicesResp, cssResp] = await Promise.all([ - fetch(`${API_BASE}/devices`, { headers: getHeaders() }), - fetchWithAuth('/color-strip-sources'), + const [devices, cssSources] = await Promise.all([ + devicesCache.fetch().catch(() => []), + colorStripSourcesCache.fetch().catch(() => []), valueSourcesCache.fetch(), ]); - const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; - const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; set_targetEditorDevices(devices); _editorCssSources = cssSources; @@ -335,11 +337,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) { deviceSelect.appendChild(opt); }); + let _editorTags = []; if (targetId) { // Editing existing target const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() }); if (!resp.ok) throw new Error('Failed to load target'); const target = await resp.json(); + _editorTags = target.tags || []; document.getElementById('target-editor-id').value = target.id; document.getElementById('target-editor-name').value = target.name; @@ -362,6 +366,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) { _populateBrightnessVsDropdown(target.brightness_value_source_id || ''); } else if (cloneData) { // Cloning — create mode but pre-filled from clone data + _editorTags = cloneData.tags || []; document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)'; deviceSelect.value = cloneData.device_id || ''; @@ -420,6 +425,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) { _updateFpsRecommendation(); _updateBrightnessThresholdVisibility(); + // Tags + if (_targetTagsInput) _targetTagsInput.destroy(); + _targetTagsInput = new TagInput(document.getElementById('target-tags-container'), { + placeholder: window.t ? t('tags.placeholder') : 'Add tag...' + }); + _targetTagsInput.setValue(_editorTags); + targetEditorModal.snapshot(); targetEditorModal.open(); @@ -440,6 +452,7 @@ export async function closeTargetEditorModal() { } export function forceCloseTargetEditorModal() { + if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; } targetEditorModal.forceClose(); } @@ -473,6 +486,7 @@ export async function saveTargetEditor() { keepalive_interval: standbyInterval, adaptive_fps: adaptiveFps, protocol, + tags: _targetTagsInput ? _targetTagsInput.getValue() : [], }; try { @@ -496,6 +510,7 @@ export async function saveTargetEditor() { } showToast(targetId ? t('targets.updated') : t('targets.created'), 'success'); + outputTargetsCache.invalidate(); targetEditorModal.forceClose(); await loadTargetsTab(); } catch (error) { @@ -546,41 +561,26 @@ export async function loadTargetsTab() { if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true); try { - // 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('/output-targets'), - fetchWithAuth('/color-strip-sources').catch(() => null), - fetchWithAuth('/pattern-templates').catch(() => null), + // Fetch all entities via DataCache + const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([ + devicesCache.fetch().catch(() => []), + outputTargetsCache.fetch().catch(() => []), + colorStripSourcesCache.fetch().catch(() => []), + patternTemplatesCache.fetch().catch(() => []), streamsCache.fetch().catch(() => []), valueSourcesCache.fetch().catch(() => []), audioSourcesCache.fetch().catch(() => []), syncClocksCache.fetch().catch(() => []), ]); - const devicesData = await devicesResp.json(); - const devices = devicesData.devices || []; - - const targetsData = await targetsResp.json(); - const targets = targetsData.targets || []; - let colorStripSourceMap = {}; - if (cssResp && cssResp.ok) { - const cssData = await cssResp.json(); - (cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; }); - } + cssArr.forEach(s => { colorStripSourceMap[s.id] = s; }); let pictureSourceMap = {}; psArr.forEach(s => { pictureSourceMap[s.id] = s; }); - let patternTemplates = []; let patternTemplateMap = {}; - if (patResp && patResp.ok) { - const patData = await patResp.json(); - patternTemplates = patData.templates || []; - patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); - } + patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); let valueSourceMap = {}; valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; }); @@ -959,6 +959,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} ${target.min_brightness_threshold > 0 ? `${ICON_SUN_DIM} <${target.min_brightness_threshold} → off` : ''} + ${renderTagChips(target.tags)}
${isProcessing ? `
@@ -1082,15 +1083,14 @@ export async function stopAllKCTargets() { async function _stopAllByType(targetType) { try { - const [targetsResp, statesResp] = await Promise.all([ - fetchWithAuth('/output-targets'), + const [allTargets, statesResp] = await Promise.all([ + outputTargetsCache.fetch().catch(() => []), fetchWithAuth('/output-targets/batch/states'), ]); - const data = await targetsResp.json(); const statesData = statesResp.ok ? await statesResp.json() : { states: {} }; const states = statesData.states || {}; const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType; - const running = (data.targets || []).filter(t => typeMatch(t) && states[t.id]?.processing); + const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing); if (!running.length) { showToast(t('targets.stop_all.none_running'), 'info'); return; @@ -1156,6 +1156,7 @@ export async function deleteTarget(targetId) { }); if (response.ok) { showToast(t('targets.deleted'), 'success'); + outputTargetsCache.invalidate(); } else { const error = await response.json(); showToast(error.detail || t('target.error.delete_failed'), 'error'); diff --git a/server/src/wled_controller/static/js/features/value-sources.js b/server/src/wled_controller/static/js/features/value-sources.js index a6c3a3d..6b29202 100644 --- a/server/src/wled_controller/static/js/features/value-sources.js +++ b/server/src/wled_controller/static/js/features/value-sources.js @@ -22,6 +22,7 @@ import { ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, } from '../core/icons.js'; import { wrapCard } from '../core/card-colors.js'; +import { TagInput, renderTagChips } from '../core/tag-input.js'; import { IconSelect } from '../core/icon-select.js'; import { EntitySelect } from '../core/entity-palette.js'; import { loadPictureSources } from './streams.js'; @@ -31,10 +32,15 @@ export { getValueSourceIcon }; // ── EntitySelect instances for value source editor ── let _vsAudioSourceEntitySelect = null; let _vsPictureSourceEntitySelect = null; +let _vsTagsInput = null; class ValueSourceModal extends Modal { constructor() { super('value-source-modal'); } + onForceClose() { + if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } + } + snapshotValues() { const type = document.getElementById('value-source-type').value; return { @@ -58,6 +64,7 @@ class ValueSourceModal extends Modal { sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value, sceneSmoothing: document.getElementById('value-source-scene-smoothing').value, schedule: JSON.stringify(_getScheduleFromUI()), + tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []), }; } } @@ -241,6 +248,11 @@ export async function showValueSourceModal(editData) { document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName(); document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName(); + // Tags + if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; } + _vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') }); + _vsTagsInput.setValue(editData ? (editData.tags || []) : []); + valueSourceModal.open(); valueSourceModal.snapshot(); } @@ -293,7 +305,7 @@ export async function saveValueSource() { return; } - const payload = { name, source_type: sourceType, description }; + const payload = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] }; if (sourceType === 'static') { payload.value = parseFloat(document.getElementById('value-source-value').value); @@ -648,6 +660,7 @@ export function createValueSourceCard(src) {
${icon} ${escapeHtml(src.name)}
${propsHtml}
+ ${renderTagChips(src.tags)} ${src.description ? `
${escapeHtml(src.description)}
` : ''}`, actions: ` diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 6ed6541..a71af55 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -332,6 +332,9 @@ "palette.search": "Search…", "section.filter.placeholder": "Filter...", "section.filter.reset": "Clear filter", + "tags.label": "Tags", + "tags.hint": "Assign tags for grouping and filtering cards", + "tags.placeholder": "Add tag...", "section.expand_all": "Expand all sections", "section.collapse_all": "Collapse all sections", "streams.title": "Sources", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 09702e6..ec31cf2 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -332,6 +332,9 @@ "palette.search": "Поиск…", "section.filter.placeholder": "Фильтр...", "section.filter.reset": "Очистить фильтр", + "tags.label": "Теги", + "tags.hint": "Назначьте теги для группировки и фильтрации карточек", + "tags.placeholder": "Добавить тег...", "section.expand_all": "Развернуть все секции", "section.collapse_all": "Свернуть все секции", "streams.title": "Источники", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index b1bd5d2..29be851 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -332,6 +332,9 @@ "palette.search": "搜索…", "section.filter.placeholder": "筛选...", "section.filter.reset": "清除筛选", + "tags.label": "标签", + "tags.hint": "为卡片分配标签以进行分组和筛选", + "tags.placeholder": "添加标签...", "section.expand_all": "全部展开", "section.collapse_all": "全部折叠", "streams.title": "源", diff --git a/server/src/wled_controller/storage/audio_source.py b/server/src/wled_controller/storage/audio_source.py index 084a1cb..374ac74 100644 --- a/server/src/wled_controller/storage/audio_source.py +++ b/server/src/wled_controller/storage/audio_source.py @@ -5,9 +5,9 @@ An AudioSource represents a reusable audio input configuration: MonoAudioSource — extracts a single channel from a multichannel source """ -from dataclasses import dataclass -from datetime import datetime -from typing import Optional +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional @dataclass @@ -20,6 +20,7 @@ class AudioSource: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert source to dictionary. Subclasses extend this.""" @@ -30,6 +31,7 @@ class AudioSource: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, # Subclass fields default to None for forward compat "device_index": None, "is_loopback": None, @@ -45,26 +47,27 @@ class AudioSource: sid: str = data["id"] name: str = data["name"] description: str | None = data.get("description") + tags: list = data.get("tags", []) raw_created = data.get("created_at") created_at: datetime = ( datetime.fromisoformat(raw_created) if isinstance(raw_created, str) else raw_created if isinstance(raw_created, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) raw_updated = data.get("updated_at") updated_at: datetime = ( datetime.fromisoformat(raw_updated) if isinstance(raw_updated, str) else raw_updated if isinstance(raw_updated, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) if source_type == "mono": return MonoAudioSource( id=sid, name=name, source_type="mono", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, audio_source_id=data.get("audio_source_id") or "", channel=data.get("channel") or "mono", ) @@ -72,7 +75,7 @@ class AudioSource: # Default: multichannel return MultichannelAudioSource( id=sid, name=name, source_type="multichannel", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, device_index=int(data.get("device_index", -1)), is_loopback=bool(data.get("is_loopback", True)), audio_template_id=data.get("audio_template_id"), diff --git a/server/src/wled_controller/storage/audio_source_store.py b/server/src/wled_controller/storage/audio_source_store.py index d20f974..7177b83 100644 --- a/server/src/wled_controller/storage/audio_source_store.py +++ b/server/src/wled_controller/storage/audio_source_store.py @@ -1,87 +1,36 @@ """Audio source storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional, Tuple +from datetime import datetime, timezone +from typing import List, Optional, Tuple from wled_controller.storage.audio_source import ( AudioSource, MonoAudioSource, MultichannelAudioSource, ) -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.utils import get_logger logger = get_logger(__name__) -class AudioSourceStore: +class AudioSourceStore(BaseJsonStore[AudioSource]): """Persistent storage for audio sources.""" + _json_key = "audio_sources" + _entity_name = "Audio source" + def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._sources: Dict[str, AudioSource] = {} - self._load() + super().__init__(file_path, AudioSource.from_dict) - def _load(self) -> None: - if not self.file_path.exists(): - logger.info("Audio source store file not found — starting empty") - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - sources_data = data.get("audio_sources", {}) - loaded = 0 - for source_id, source_dict in sources_data.items(): - try: - source = AudioSource.from_dict(source_dict) - self._sources[source_id] = source - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load audio source {source_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} audio sources from storage") - - except Exception as e: - logger.error(f"Failed to load audio sources from {self.file_path}: {e}") - raise - - logger.info(f"Audio source store initialized with {len(self._sources)} sources") - - def _save(self) -> None: - try: - data = { - "version": "1.0.0", - "audio_sources": { - sid: source.to_dict() - for sid, source in self._sources.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save audio sources to {self.file_path}: {e}") - raise - - # ── CRUD ───────────────────────────────────────────────────────── - - def get_all_sources(self) -> List[AudioSource]: - return list(self._sources.values()) + # Backward-compatible aliases + get_all_sources = BaseJsonStore.get_all + get_source = BaseJsonStore.get def get_mono_sources(self) -> List[MonoAudioSource]: """Return only mono audio sources (for CSS dropdown).""" - return [s for s in self._sources.values() if isinstance(s, MonoAudioSource)] - - def get_source(self, source_id: str) -> AudioSource: - if source_id not in self._sources: - raise ValueError(f"Audio source not found: {source_id}") - return self._sources[source_id] + return [s for s in self._items.values() if isinstance(s, MonoAudioSource)] def create_source( self, @@ -93,25 +42,21 @@ class AudioSourceStore: channel: Optional[str] = None, description: Optional[str] = None, audio_template_id: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> AudioSource: - if not name or not name.strip(): - raise ValueError("Name is required") + self._check_name_unique(name) if source_type not in ("multichannel", "mono"): raise ValueError(f"Invalid source type: {source_type}") - for source in self._sources.values(): - if source.name == name: - raise ValueError(f"Audio source with name '{name}' already exists") - sid = f"as_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) if source_type == "mono": if not audio_source_id: raise ValueError("Mono sources require audio_source_id") # Validate parent exists and is multichannel - parent = self._sources.get(audio_source_id) + parent = self._items.get(audio_source_id) if not parent: raise ValueError(f"Parent audio source not found: {audio_source_id}") if not isinstance(parent, MultichannelAudioSource): @@ -119,20 +64,20 @@ class AudioSourceStore: source = MonoAudioSource( id=sid, name=name, source_type="mono", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=tags or [], audio_source_id=audio_source_id, channel=channel or "mono", ) else: source = MultichannelAudioSource( id=sid, name=name, source_type="multichannel", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=tags or [], device_index=device_index if device_index is not None else -1, is_loopback=bool(is_loopback) if is_loopback is not None else True, audio_template_id=audio_template_id, ) - self._sources[sid] = source + self._items[sid] = source self._save() logger.info(f"Created audio source: {name} ({sid}, type={source_type})") @@ -148,20 +93,18 @@ class AudioSourceStore: channel: Optional[str] = None, description: Optional[str] = None, audio_template_id: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> AudioSource: - if source_id not in self._sources: - raise ValueError(f"Audio source not found: {source_id}") - - source = self._sources[source_id] + source = self.get(source_id) if name is not None: - for other in self._sources.values(): - if other.id != source_id and other.name == name: - raise ValueError(f"Audio source with name '{name}' already exists") + self._check_name_unique(name, exclude_id=source_id) source.name = name if description is not None: source.description = description + if tags is not None: + source.tags = tags if isinstance(source, MultichannelAudioSource): if device_index is not None: @@ -172,7 +115,7 @@ class AudioSourceStore: source.audio_template_id = audio_template_id elif isinstance(source, MonoAudioSource): if audio_source_id is not None: - parent = self._sources.get(audio_source_id) + parent = self._items.get(audio_source_id) if not parent: raise ValueError(f"Parent audio source not found: {audio_source_id}") if not isinstance(parent, MultichannelAudioSource): @@ -181,27 +124,27 @@ class AudioSourceStore: if channel is not None: source.channel = channel - source.updated_at = datetime.utcnow() + source.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated audio source: {source_id}") return source def delete_source(self, source_id: str) -> None: - if source_id not in self._sources: - raise ValueError(f"Audio source not found: {source_id}") + if source_id not in self._items: + raise ValueError(f"{self._entity_name} not found: {source_id}") - source = self._sources[source_id] + source = self._items[source_id] # Prevent deleting multichannel sources referenced by mono sources if isinstance(source, MultichannelAudioSource): - for other in self._sources.values(): + for other in self._items.values(): if isinstance(other, MonoAudioSource) and other.audio_source_id == source_id: raise ValueError( f"Cannot delete '{source.name}': referenced by mono source '{other.name}'" ) - del self._sources[source_id] + del self._items[source_id] self._save() logger.info(f"Deleted audio source: {source_id}") @@ -231,4 +174,3 @@ class AudioSourceStore: return parent.device_index, parent.is_loopback, source.channel, parent.audio_template_id raise ValueError(f"Audio source {source_id} is not a valid audio source") - diff --git a/server/src/wled_controller/storage/audio_template.py b/server/src/wled_controller/storage/audio_template.py index 2304a18..ac7f9e3 100644 --- a/server/src/wled_controller/storage/audio_template.py +++ b/server/src/wled_controller/storage/audio_template.py @@ -1,8 +1,8 @@ """Audio capture template data model.""" -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Optional +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional @dataclass @@ -16,6 +16,7 @@ class AudioCaptureTemplate: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert template to dictionary.""" @@ -27,6 +28,7 @@ class AudioCaptureTemplate: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, } @classmethod @@ -39,9 +41,10 @@ class AudioCaptureTemplate: engine_config=data.get("engine_config", {}), created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) - else data.get("created_at", datetime.utcnow()), + else data.get("created_at", datetime.now(timezone.utc)), updated_at=datetime.fromisoformat(data["updated_at"]) if isinstance(data.get("updated_at"), str) - else data.get("updated_at", datetime.utcnow()), + else data.get("updated_at", datetime.now(timezone.utc)), description=data.get("description"), + tags=data.get("tags", []), ) diff --git a/server/src/wled_controller/storage/audio_template_store.py b/server/src/wled_controller/storage/audio_template_store.py index c2ab52f..817b32c 100644 --- a/server/src/wled_controller/storage/audio_template_store.py +++ b/server/src/wled_controller/storage/audio_template_store.py @@ -1,19 +1,18 @@ """Audio template storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path +from datetime import datetime, timezone from typing import Dict, List, Optional from wled_controller.core.audio.factory import AudioEngineRegistry from wled_controller.storage.audio_template import AudioCaptureTemplate -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.utils import get_logger logger = get_logger(__name__) -class AudioTemplateStore: +class AudioTemplateStore(BaseJsonStore[AudioCaptureTemplate]): """Storage for audio capture templates. All templates are persisted to the JSON file. @@ -21,15 +20,20 @@ class AudioTemplateStore: highest-priority available engine. """ + _json_key = "templates" + _entity_name = "Audio capture template" + def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._templates: Dict[str, AudioCaptureTemplate] = {} - self._load() + super().__init__(file_path, AudioCaptureTemplate.from_dict) self._ensure_initial_template() + # Backward-compatible aliases + get_all_templates = BaseJsonStore.get_all + get_template = BaseJsonStore.get + def _ensure_initial_template(self) -> None: """Auto-create a template if none exist, using the best available engine.""" - if self._templates: + if self._items: return best_engine = AudioEngineRegistry.get_best_available_engine() @@ -39,7 +43,7 @@ class AudioTemplateStore: engine_class = AudioEngineRegistry.get_engine(best_engine) default_config = engine_class.get_default_config() - now = datetime.utcnow() + now = datetime.now(timezone.utc) template_id = f"atpl_{uuid.uuid4().hex[:8]}" template = AudioCaptureTemplate( @@ -52,71 +56,17 @@ class AudioTemplateStore: description=f"Default audio template using {best_engine.upper()} engine", ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info( f"Auto-created initial audio template: {template.name} " f"({template_id}, engine={best_engine})" ) - def _load(self) -> None: - """Load templates from file.""" - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - templates_data = data.get("templates", {}) - loaded = 0 - for template_id, template_dict in templates_data.items(): - try: - template = AudioCaptureTemplate.from_dict(template_dict) - self._templates[template_id] = template - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load audio template {template_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} audio templates from storage") - - except Exception as e: - logger.error(f"Failed to load audio templates from {self.file_path}: {e}") - raise - - logger.info(f"Audio template store initialized with {len(self._templates)} templates") - - def _save(self) -> None: - """Save all templates to file.""" - try: - data = { - "version": "1.0.0", - "templates": { - template_id: template.to_dict() - for template_id, template in self._templates.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save audio templates to {self.file_path}: {e}") - raise - - def get_all_templates(self) -> List[AudioCaptureTemplate]: - return list(self._templates.values()) - - def get_template(self, template_id: str) -> AudioCaptureTemplate: - if template_id not in self._templates: - raise ValueError(f"Audio template not found: {template_id}") - return self._templates[template_id] - def get_default_template_id(self) -> Optional[str]: """Return the ID of the first template, or None if none exist.""" - if self._templates: - return next(iter(self._templates)) + if self._items: + return next(iter(self._items)) return None def create_template( @@ -125,13 +75,12 @@ class AudioTemplateStore: engine_type: str, engine_config: Dict[str, any], description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> AudioCaptureTemplate: - for template in self._templates.values(): - if template.name == name: - raise ValueError(f"Audio template with name '{name}' already exists") + self._check_name_unique(name) template_id = f"atpl_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) template = AudioCaptureTemplate( id=template_id, name=name, @@ -140,9 +89,10 @@ class AudioTemplateStore: created_at=now, updated_at=now, description=description, + tags=tags or [], ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Created audio template: {name} ({template_id})") return template @@ -154,16 +104,12 @@ class AudioTemplateStore: engine_type: Optional[str] = None, engine_config: Optional[Dict[str, any]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> AudioCaptureTemplate: - if template_id not in self._templates: - raise ValueError(f"Audio template not found: {template_id}") - - template = self._templates[template_id] + template = self.get(template_id) if name is not None: - for tid, t in self._templates.items(): - if tid != template_id and t.name == name: - raise ValueError(f"Audio template with name '{name}' already exists") + self._check_name_unique(name, exclude_id=template_id) template.name = name if engine_type is not None: template.engine_type = engine_type @@ -171,8 +117,10 @@ class AudioTemplateStore: template.engine_config = engine_config if description is not None: template.description = description + if tags is not None: + template.tags = tags - template.updated_at = datetime.utcnow() + template.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated audio template: {template_id}") return template @@ -187,8 +135,8 @@ class AudioTemplateStore: Raises: ValueError: If template not found or still referenced """ - if template_id not in self._templates: - raise ValueError(f"Audio template not found: {template_id}") + if template_id not in self._items: + raise ValueError(f"{self._entity_name} not found: {template_id}") # Check if any multichannel audio source references this template if audio_source_store is not None: @@ -203,6 +151,6 @@ class AudioTemplateStore: f"referenced by audio source '{source.name}' ({source.id})" ) - del self._templates[template_id] + del self._items[template_id] self._save() logger.info(f"Deleted audio template: {template_id}") diff --git a/server/src/wled_controller/storage/automation.py b/server/src/wled_controller/storage/automation.py index 3434caf..86cf4f6 100644 --- a/server/src/wled_controller/storage/automation.py +++ b/server/src/wled_controller/storage/automation.py @@ -1,7 +1,7 @@ """Automation and Condition data models.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional @@ -204,6 +204,7 @@ class Automation: deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode created_at: datetime updated_at: datetime + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: return { @@ -215,6 +216,7 @@ class Automation: "scene_preset_id": self.scene_preset_id, "deactivation_mode": self.deactivation_mode, "deactivation_scene_preset_id": self.deactivation_scene_preset_id, + "tags": self.tags, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } @@ -237,6 +239,7 @@ class Automation: scene_preset_id=data.get("scene_preset_id"), deactivation_mode=data.get("deactivation_mode", "none"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), - created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + tags=data.get("tags", []), + created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) diff --git a/server/src/wled_controller/storage/automation_store.py b/server/src/wled_controller/storage/automation_store.py index 3783e0c..09d4bc1 100644 --- a/server/src/wled_controller/storage/automation_store.py +++ b/server/src/wled_controller/storage/automation_store.py @@ -1,72 +1,27 @@ """Automation storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional from wled_controller.storage.automation import Automation, Condition -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.storage.base_store import BaseJsonStore +from wled_controller.utils import get_logger logger = get_logger(__name__) -class AutomationStore: - """Persistent storage for automations.""" +class AutomationStore(BaseJsonStore[Automation]): + _json_key = "automations" + _entity_name = "Automation" def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._automations: Dict[str, Automation] = {} - self._load() + super().__init__(file_path, Automation.from_dict) - def _load(self) -> None: - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - automations_data = data.get("automations", {}) - loaded = 0 - for auto_id, auto_dict in automations_data.items(): - try: - automation = Automation.from_dict(auto_dict) - self._automations[auto_id] = automation - loaded += 1 - except Exception as e: - logger.error(f"Failed to load automation {auto_id}: {e}", exc_info=True) - - if loaded > 0: - logger.info(f"Loaded {loaded} automations from storage") - - except Exception as e: - logger.error(f"Failed to load automations from {self.file_path}: {e}") - raise - - logger.info(f"Automation store initialized with {len(self._automations)} automations") - - def _save(self) -> None: - try: - data = { - "version": "1.0.0", - "automations": { - aid: a.to_dict() for aid, a in self._automations.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save automations to {self.file_path}: {e}") - raise - - def get_all_automations(self) -> List[Automation]: - return list(self._automations.values()) - - def get_automation(self, automation_id: str) -> Automation: - if automation_id not in self._automations: - raise ValueError(f"Automation not found: {automation_id}") - return self._automations[automation_id] + # Backward-compatible aliases + get_all_automations = BaseJsonStore.get_all + get_automation = BaseJsonStore.get + delete_automation = BaseJsonStore.delete def create_automation( self, @@ -77,13 +32,14 @@ class AutomationStore: scene_preset_id: Optional[str] = None, deactivation_mode: str = "none", deactivation_scene_preset_id: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Automation: - for a in self._automations.values(): + for a in self._items.values(): if a.name == name: raise ValueError(f"Automation with name '{name}' already exists") automation_id = f"auto_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) automation = Automation( id=automation_id, @@ -96,11 +52,11 @@ class AutomationStore: deactivation_scene_preset_id=deactivation_scene_preset_id, created_at=now, updated_at=now, + tags=tags or [], ) - self._automations[automation_id] = automation + self._items[automation_id] = automation self._save() - logger.info(f"Created automation: {name} ({automation_id})") return automation @@ -114,16 +70,12 @@ class AutomationStore: scene_preset_id: str = "__unset__", deactivation_mode: Optional[str] = None, deactivation_scene_preset_id: str = "__unset__", + tags: Optional[List[str]] = None, ) -> Automation: - if automation_id not in self._automations: - raise ValueError(f"Automation not found: {automation_id}") - - automation = self._automations[automation_id] + automation = self.get(automation_id) if name is not None: - for aid, a in self._automations.items(): - if aid != automation_id and a.name == name: - raise ValueError(f"Automation with name '{name}' already exists") + self._check_name_unique(name, exclude_id=automation_id) automation.name = name if enabled is not None: automation.enabled = enabled @@ -137,21 +89,10 @@ class AutomationStore: automation.deactivation_mode = deactivation_mode if deactivation_scene_preset_id != "__unset__": automation.deactivation_scene_preset_id = deactivation_scene_preset_id + if tags is not None: + automation.tags = tags - automation.updated_at = datetime.utcnow() + automation.updated_at = datetime.now(timezone.utc) self._save() - logger.info(f"Updated automation: {automation_id}") return automation - - def delete_automation(self, automation_id: str) -> None: - if automation_id not in self._automations: - raise ValueError(f"Automation not found: {automation_id}") - - del self._automations[automation_id] - self._save() - - logger.info(f"Deleted automation: {automation_id}") - - def count(self) -> int: - return len(self._automations) diff --git a/server/src/wled_controller/storage/base_store.py b/server/src/wled_controller/storage/base_store.py new file mode 100644 index 0000000..905730e --- /dev/null +++ b/server/src/wled_controller/storage/base_store.py @@ -0,0 +1,115 @@ +"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores.""" + +import json +from pathlib import Path +from typing import Callable, Dict, Generic, List, TypeVar + +from wled_controller.utils import atomic_write_json, get_logger + +T = TypeVar("T") +logger = get_logger(__name__) + + +class BaseJsonStore(Generic[T]): + """JSON-file-backed entity store with common CRUD helpers. + + Provides: + - ``_load()`` / ``_save()``: atomic JSON file I/O + - ``get_all()`` / ``get(id)`` / ``delete(id)`` / ``count()``: read/delete + - ``_check_name_unique(name, exclude_id)``: duplicate-name guard + + Subclasses must set class attributes: + - ``_json_key``: root key in JSON file (e.g. ``"sync_clocks"``) + - ``_entity_name``: human label for errors (e.g. ``"Sync clock"``) + - ``_version``: schema version string (default ``"1.0.0"``) + """ + + _json_key: str + _entity_name: str + _version: str = "1.0.0" + + def __init__(self, file_path: str, deserializer: Callable[[dict], T]): + self.file_path = Path(file_path) + self._items: Dict[str, T] = {} + self._deserializer = deserializer + self._load() + + # ── I/O ──────────────────────────────────────────────────────── + + def _load(self) -> None: + if not self.file_path.exists(): + logger.info(f"{self._entity_name} store file not found — starting empty") + return + + try: + with open(self.file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + items_data = data.get(self._json_key, {}) + loaded = 0 + for item_id, item_dict in items_data.items(): + try: + self._items[item_id] = self._deserializer(item_dict) + loaded += 1 + except Exception as e: + logger.error( + f"Failed to load {self._entity_name} {item_id}: {e}", + exc_info=True, + ) + + if loaded > 0: + logger.info(f"Loaded {loaded} {self._json_key} from storage") + + except Exception as e: + logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}") + raise + + logger.info( + f"{self._entity_name} store initialized with {len(self._items)} items" + ) + + def _save(self) -> None: + try: + data = { + "version": self._version, + self._json_key: { + item_id: item.to_dict() + for item_id, item in self._items.items() + }, + } + atomic_write_json(self.file_path, data) + except Exception as e: + logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}") + raise + + # ── Common CRUD ──────────────────────────────────────────────── + + def get_all(self) -> List[T]: + return list(self._items.values()) + + def get(self, item_id: str) -> T: + if item_id not in self._items: + raise ValueError(f"{self._entity_name} not found: {item_id}") + return self._items[item_id] + + def delete(self, item_id: str) -> None: + if item_id not in self._items: + raise ValueError(f"{self._entity_name} not found: {item_id}") + del self._items[item_id] + self._save() + logger.info(f"Deleted {self._entity_name}: {item_id}") + + def count(self) -> int: + return len(self._items) + + # ── Helpers ──────────────────────────────────────────────────── + + def _check_name_unique(self, name: str, exclude_id: str = None) -> None: + """Raise ValueError if *name* is empty or already taken.""" + if not name or not name.strip(): + raise ValueError("Name is required") + for item_id, item in self._items.items(): + if item_id != exclude_id and getattr(item, "name", None) == name: + raise ValueError( + f"{self._entity_name} with name '{name}' already exists" + ) diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 2ff1751..6655704 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -16,8 +16,8 @@ Current types: """ from dataclasses import dataclass, field -from datetime import datetime -from typing import Optional +from datetime import datetime, timezone +from typing import List, Optional from wled_controller.core.capture.calibration import ( CalibrationConfig, @@ -37,6 +37,7 @@ class ColorStripSource: updated_at: datetime description: Optional[str] = None clock_id: Optional[str] = None # optional SyncClock reference + tags: List[str] = field(default_factory=list) @property def sharable(self) -> bool: @@ -57,6 +58,7 @@ class ColorStripSource: "updated_at": self.updated_at.isoformat(), "description": self.description, "clock_id": self.clock_id, + "tags": self.tags, # Subclass fields default to None for forward compat "picture_source_id": None, "fps": None, @@ -102,20 +104,21 @@ class ColorStripSource: description: str | None = data.get("description") clock_id: str | None = data.get("clock_id") + tags: list = data.get("tags", []) raw_created = data.get("created_at") created_at: datetime = ( datetime.fromisoformat(raw_created) if isinstance(raw_created, str) else raw_created if isinstance(raw_created, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) raw_updated = data.get("updated_at") updated_at: datetime = ( datetime.fromisoformat(raw_updated) if isinstance(raw_updated, str) else raw_updated if isinstance(raw_updated, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) calibration_data = data.get("calibration") @@ -134,7 +137,7 @@ class ColorStripSource: return StaticColorStripSource( id=sid, name=name, source_type="static", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, color=color, + clock_id=clock_id, tags=tags, color=color, animation=data.get("animation"), ) @@ -144,7 +147,7 @@ class ColorStripSource: return GradientColorStripSource( id=sid, name=name, source_type="gradient", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, stops=stops, + clock_id=clock_id, tags=tags, stops=stops, animation=data.get("animation"), ) @@ -154,14 +157,14 @@ class ColorStripSource: return ColorCycleColorStripSource( id=sid, name=name, source_type="color_cycle", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, colors=colors, + clock_id=clock_id, tags=tags, colors=colors, ) if source_type == "composite": return CompositeColorStripSource( id=sid, name=name, source_type="composite", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, layers=data.get("layers") or [], + clock_id=clock_id, tags=tags, layers=data.get("layers") or [], led_count=data.get("led_count") or 0, ) @@ -169,7 +172,7 @@ class ColorStripSource: return MappedColorStripSource( id=sid, name=name, source_type="mapped", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, zones=data.get("zones") or [], + clock_id=clock_id, tags=tags, zones=data.get("zones") or [], led_count=data.get("led_count") or 0, ) @@ -181,7 +184,7 @@ class ColorStripSource: return AudioColorStripSource( id=sid, name=name, source_type="audio", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, visualization_mode=data.get("visualization_mode") or "spectrum", + clock_id=clock_id, tags=tags, 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), @@ -201,7 +204,7 @@ class ColorStripSource: return EffectColorStripSource( id=sid, name=name, source_type="effect", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, effect_type=data.get("effect_type") or "fire", + clock_id=clock_id, tags=tags, effect_type=data.get("effect_type") or "fire", palette=data.get("palette") or "fire", color=color, intensity=float(data.get("intensity") or 1.0), @@ -218,7 +221,7 @@ class ColorStripSource: return ApiInputColorStripSource( id=sid, name=name, source_type="api_input", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, led_count=data.get("led_count") or 0, + clock_id=clock_id, tags=tags, led_count=data.get("led_count") or 0, fallback_color=fallback_color, timeout=float(data.get("timeout") or 5.0), ) @@ -231,7 +234,7 @@ class ColorStripSource: return NotificationColorStripSource( id=sid, name=name, source_type="notification", created_at=created_at, updated_at=updated_at, description=description, - clock_id=clock_id, + clock_id=clock_id, tags=tags, notification_effect=data.get("notification_effect") or "flash", duration_ms=int(data.get("duration_ms") or 1500), default_color=data.get("default_color") or "#FFFFFF", @@ -243,6 +246,7 @@ class ColorStripSource: # Shared picture-type field extraction _picture_kwargs = dict( + tags=tags, 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, diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 783b72f..d71fbf8 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -1,12 +1,11 @@ """Color strip source storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.color_strip_source import ( AdvancedPictureColorStripSource, ApiInputColorStripSource, @@ -21,73 +20,27 @@ from wled_controller.storage.color_strip_source import ( PictureColorStripSource, StaticColorStripSource, ) -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class ColorStripStore: +class ColorStripStore(BaseJsonStore[ColorStripSource]): """Persistent storage for color strip sources.""" + _json_key = "color_strip_sources" + _entity_name = "Color strip source" + def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._sources: Dict[str, ColorStripSource] = {} - self._load() + super().__init__(file_path, ColorStripSource.from_dict) - def _load(self) -> None: - if not self.file_path.exists(): - logger.info("Color strip store file not found — starting empty") - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - sources_data = data.get("color_strip_sources", {}) - loaded = 0 - for source_id, source_dict in sources_data.items(): - try: - source = ColorStripSource.from_dict(source_dict) - self._sources[source_id] = source - loaded += 1 - except Exception as e: - logger.error(f"Failed to load color strip source {source_id}: {e}", exc_info=True) - - if loaded > 0: - logger.info(f"Loaded {loaded} color strip sources from storage") - - except Exception as e: - logger.error(f"Failed to load color strip sources from {self.file_path}: {e}") - raise - - logger.info(f"Color strip store initialized with {len(self._sources)} sources") - - def _save(self) -> None: - try: - data = { - "version": "1.0.0", - "color_strip_sources": { - sid: source.to_dict() - for sid, source in self._sources.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save color strip sources to {self.file_path}: {e}") - raise - - def get_all_sources(self) -> List[ColorStripSource]: - return list(self._sources.values()) + # Backward-compatible aliases + get_all_sources = BaseJsonStore.get_all + delete_source = BaseJsonStore.delete def get_source(self, source_id: str) -> ColorStripSource: - """Get a color strip source by ID. - - Raises: - ValueError: If source not found - """ - if source_id not in self._sources: - raise ValueError(f"Color strip source not found: {source_id}") - return self._sources[source_id] + """Get a color strip source by ID (alias for get()).""" + return self.get(source_id) def create_source( self, @@ -129,6 +82,7 @@ class ColorStripStore: app_filter_mode: Optional[str] = None, app_filter_list: Optional[list] = None, os_listener: Optional[bool] = None, + tags: Optional[List[str]] = None, ) -> ColorStripSource: """Create a new color strip source. @@ -138,12 +92,12 @@ class ColorStripStore: if not name or not name.strip(): raise ValueError("Name is required") - for source in self._sources.values(): + for source in self._items.values(): if source.name == name: raise ValueError(f"Color strip source with name '{name}' already exists") source_id = f"css_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) if source_type == "static": rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255] @@ -325,7 +279,8 @@ class ColorStripStore: frame_interpolation=frame_interpolation, ) - self._sources[source_id] = source + source.tags = tags or [] + self._items[source_id] = source self._save() logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})") @@ -371,19 +326,20 @@ class ColorStripStore: app_filter_mode: Optional[str] = None, app_filter_list: Optional[list] = None, os_listener: Optional[bool] = None, + tags: Optional[List[str]] = None, ) -> ColorStripSource: """Update an existing color strip source. Raises: ValueError: If source not found """ - if source_id not in self._sources: + if source_id not in self._items: raise ValueError(f"Color strip source not found: {source_id}") - source = self._sources[source_id] + source = self._items[source_id] if name is not None: - for other in self._sources.values(): + for other in self._items.values(): if other.id != source_id and other.name == name: raise ValueError(f"Color strip source with name '{name}' already exists") source.name = name @@ -394,6 +350,9 @@ class ColorStripStore: if clock_id is not None: source.clock_id = clock_id if clock_id else None + if tags is not None: + source.tags = tags + if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)): if picture_source_id is not None and isinstance(source, PictureColorStripSource): source.picture_source_id = picture_source_id @@ -494,30 +453,16 @@ class ColorStripStore: if os_listener is not None: source.os_listener = bool(os_listener) - source.updated_at = datetime.utcnow() + source.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated color strip source: {source_id}") return source - def delete_source(self, source_id: str) -> None: - """Delete a color strip source. - - Raises: - ValueError: If source not found - """ - if source_id not in self._sources: - raise ValueError(f"Color strip source not found: {source_id}") - - del self._sources[source_id] - self._save() - - logger.info(f"Deleted color strip source: {source_id}") - def get_composites_referencing(self, source_id: str) -> List[str]: """Return names of composite sources that reference a given source as a layer.""" names = [] - for source in self._sources.values(): + for source in self._items.values(): if isinstance(source, CompositeColorStripSource): for layer in source.layers: if layer.get("source_id") == source_id: @@ -528,7 +473,7 @@ class ColorStripStore: def get_mapped_referencing(self, source_id: str) -> List[str]: """Return names of mapped sources that reference a given source as a zone.""" names = [] - for source in self._sources.values(): + for source in self._items.values(): if isinstance(source, MappedColorStripSource): for zone in source.zones: if zone.get("source_id") == source_id: diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index e019db7..c796f8a 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -2,11 +2,11 @@ import json import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional -from wled_controller.utils import get_logger +from wled_controller.utils import atomic_write_json, get_logger logger = get_logger(__name__) @@ -33,6 +33,7 @@ class Device: send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", + tags: List[str] = None, created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, ): @@ -48,8 +49,9 @@ class Device: self.send_latency_ms = send_latency_ms self.rgbw = rgbw self.zone_mode = zone_mode - self.created_at = created_at or datetime.utcnow() - self.updated_at = updated_at or datetime.utcnow() + self.tags = tags or [] + self.created_at = created_at or datetime.now(timezone.utc) + self.updated_at = updated_at or datetime.now(timezone.utc) def to_dict(self) -> dict: """Convert device to dictionary.""" @@ -75,6 +77,8 @@ class Device: d["rgbw"] = True if self.zone_mode != "combined": d["zone_mode"] = self.zone_mode + if self.tags: + d["tags"] = self.tags return d @classmethod @@ -93,8 +97,9 @@ class Device: send_latency_ms=data.get("send_latency_ms", 0), rgbw=data.get("rgbw", False), zone_mode=data.get("zone_mode", "combined"), - created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + tags=data.get("tags", []), + created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) @@ -158,11 +163,7 @@ class DeviceStore: } } - temp_file = self.storage_file.with_suffix(".tmp") - with open(temp_file, "w") as f: - json.dump(data, f, indent=2) - - temp_file.replace(self.storage_file) + atomic_write_json(self.storage_file, data) logger.debug(f"Saved {len(self._devices)} devices to storage") @@ -181,6 +182,7 @@ class DeviceStore: send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", + tags: Optional[List[str]] = None, ) -> Device: """Create a new device.""" device_id = f"device_{uuid.uuid4().hex[:8]}" @@ -200,6 +202,7 @@ class DeviceStore: send_latency_ms=send_latency_ms, rgbw=rgbw, zone_mode=zone_mode, + tags=tags or [], ) self._devices[device_id] = device @@ -228,6 +231,7 @@ class DeviceStore: send_latency_ms: Optional[int] = None, rgbw: Optional[bool] = None, zone_mode: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> Device: """Update device.""" device = self._devices.get(device_id) @@ -252,8 +256,10 @@ class DeviceStore: device.rgbw = rgbw if zone_mode is not None: device.zone_mode = zone_mode + if tags is not None: + device.tags = tags - device.updated_at = datetime.utcnow() + device.updated_at = datetime.now(timezone.utc) self.save() logger.info(f"Updated device {device_id}") diff --git a/server/src/wled_controller/storage/key_colors_output_target.py b/server/src/wled_controller/storage/key_colors_output_target.py index d93a753..c119fa9 100644 --- a/server/src/wled_controller/storage/key_colors_output_target.py +++ b/server/src/wled_controller/storage/key_colors_output_target.py @@ -1,7 +1,7 @@ """Key colors output target — extracts key colors from image rectangles.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from wled_controller.storage.output_target import OutputTarget @@ -100,9 +100,10 @@ class KeyColorsOutputTarget(OutputTarget): def update_fields(self, *, name=None, device_id=None, picture_source_id=None, settings=None, key_colors_settings=None, description=None, + tags=None, **_kwargs) -> None: """Apply mutable field updates for KC targets.""" - super().update_fields(name=name, description=description) + super().update_fields(name=name, description=description, tags=tags) if picture_source_id is not None: self.picture_source_id = picture_source_id if key_colors_settings is not None: @@ -130,6 +131,7 @@ class KeyColorsOutputTarget(OutputTarget): picture_source_id=data.get("picture_source_id", ""), settings=settings, description=data.get("description"), - created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + tags=data.get("tags", []), + created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) diff --git a/server/src/wled_controller/storage/output_target.py b/server/src/wled_controller/storage/output_target.py index 1f46ec4..2165597 100644 --- a/server/src/wled_controller/storage/output_target.py +++ b/server/src/wled_controller/storage/output_target.py @@ -1,8 +1,8 @@ """Output target base data model.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime -from typing import Optional +from typing import List, Optional @dataclass @@ -15,6 +15,7 @@ class OutputTarget: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def register_with_manager(self, manager) -> None: """Register this target with the processor manager. Subclasses override.""" @@ -26,12 +27,15 @@ class OutputTarget: def update_fields(self, *, name=None, device_id=None, picture_source_id=None, settings=None, key_colors_settings=None, description=None, + tags: Optional[List[str]] = None, **_kwargs) -> None: """Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones.""" if name is not None: self.name = name if description is not None: self.description = description + if tags is not None: + self.tags = tags @property def has_picture_source(self) -> bool: @@ -45,6 +49,7 @@ class OutputTarget: "name": self.name, "target_type": self.target_type, "description": self.description, + "tags": self.tags, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } diff --git a/server/src/wled_controller/storage/output_target_store.py b/server/src/wled_controller/storage/output_target_store.py index 10ce17f..b7b7f75 100644 --- a/server/src/wled_controller/storage/output_target_store.py +++ b/server/src/wled_controller/storage/output_target_store.py @@ -2,93 +2,61 @@ import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.output_target import OutputTarget from wled_controller.storage.wled_output_target import WledOutputTarget from wled_controller.storage.key_colors_output_target import ( KeyColorsSettings, KeyColorsOutputTarget, ) -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds -class OutputTargetStore: +class OutputTargetStore(BaseJsonStore[OutputTarget]): """Persistent storage for output targets.""" - def __init__(self, file_path: str): - """Initialize output target store. + _json_key = "output_targets" + _entity_name = "Output target" - Args: - file_path: Path to targets JSON file - """ - self.file_path = Path(file_path) - self._targets: Dict[str, OutputTarget] = {} - self._load() + def __init__(self, file_path: str): + super().__init__(file_path, OutputTarget.from_dict) def _load(self) -> None: - """Load targets from file.""" + """Override to support legacy 'picture_targets' JSON key.""" + import json as _json + from pathlib import Path if not self.file_path.exists(): + logger.info(f"{self._entity_name} store file not found — starting empty") return - try: with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - # Support both new "output_targets" and legacy "picture_targets" keys + data = _json.load(f) targets_data = data.get("output_targets") or data.get("picture_targets", {}) loaded = 0 for target_id, target_dict in targets_data.items(): try: - target = OutputTarget.from_dict(target_dict) - self._targets[target_id] = target + self._items[target_id] = self._deserializer(target_dict) loaded += 1 except Exception as e: - logger.error(f"Failed to load output target {target_id}: {e}", exc_info=True) - + logger.error(f"Failed to load {self._entity_name} {target_id}: {e}", exc_info=True) if loaded > 0: - logger.info(f"Loaded {loaded} output targets from storage") - + logger.info(f"Loaded {loaded} {self._json_key} from storage") except Exception as e: - logger.error(f"Failed to load output targets from {self.file_path}: {e}") + logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}") raise + logger.info(f"{self._entity_name} store initialized with {len(self._items)} items") - logger.info(f"Output target store initialized with {len(self._targets)} targets") - - def _save(self) -> None: - """Save all targets to file.""" - try: - data = { - "version": "1.0.0", - "output_targets": { - target_id: target.to_dict() - for target_id, target in self._targets.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save output targets to {self.file_path}: {e}") - raise - - def get_all_targets(self) -> List[OutputTarget]: - """Get all output targets.""" - return list(self._targets.values()) - - def get_target(self, target_id: str) -> OutputTarget: - """Get target by ID. - - Raises: - ValueError: If target not found - """ - if target_id not in self._targets: - raise ValueError(f"Output target not found: {target_id}") - return self._targets[target_id] + # Backward-compatible aliases + get_all_targets = BaseJsonStore.get_all + get_target = BaseJsonStore.get + delete_target = BaseJsonStore.delete def create_target( self, @@ -106,6 +74,7 @@ class OutputTargetStore: key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, picture_source_id: str = "", + tags: Optional[List[str]] = None, ) -> OutputTarget: """Create a new output target. @@ -116,12 +85,12 @@ class OutputTargetStore: raise ValueError(f"Invalid target type: {target_type}") # Check for duplicate name - for target in self._targets.values(): + for target in self._items.values(): if target.name == name: raise ValueError(f"Output target with name '{name}' already exists") target_id = f"pt_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) if target_type == "led": target: OutputTarget = WledOutputTarget( @@ -155,7 +124,8 @@ class OutputTargetStore: else: raise ValueError(f"Unknown target type: {target_type}") - self._targets[target_id] = target + target.tags = tags or [] + self._items[target_id] = target self._save() logger.info(f"Created output target: {name} ({target_id}, type={target_type})") @@ -176,20 +146,21 @@ class OutputTargetStore: protocol: Optional[str] = None, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> OutputTarget: """Update an output target. Raises: ValueError: If target not found or validation fails """ - if target_id not in self._targets: + if target_id not in self._items: raise ValueError(f"Output target not found: {target_id}") - target = self._targets[target_id] + target = self._items[target_id] if name is not None: # Check for duplicate name (exclude self) - for other in self._targets.values(): + for other in self._items.values(): if other.id != target_id and other.name == name: raise ValueError(f"Output target with name '{name}' already exists") @@ -206,50 +177,37 @@ class OutputTargetStore: protocol=protocol, key_colors_settings=key_colors_settings, description=description, + tags=tags, ) - target.updated_at = datetime.utcnow() + target.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated output target: {target_id}") return target - def delete_target(self, target_id: str) -> None: - """Delete an output target. - - Raises: - ValueError: If target not found - """ - if target_id not in self._targets: - raise ValueError(f"Output target not found: {target_id}") - - del self._targets[target_id] - self._save() - - logger.info(f"Deleted output target: {target_id}") - def get_targets_for_device(self, device_id: str) -> List[OutputTarget]: """Get all targets that reference a specific device.""" return [ - t for t in self._targets.values() + t for t in self._items.values() if isinstance(t, WledOutputTarget) and t.device_id == device_id ] def get_targets_referencing_source(self, source_id: str) -> List[str]: """Return names of KC targets that reference a picture source.""" return [ - target.name for target in self._targets.values() + target.name for target in self._items.values() if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id ] def get_targets_referencing_css(self, css_id: str) -> List[str]: """Return names of LED targets that reference a color strip source.""" return [ - target.name for target in self._targets.values() + target.name for target in self._items.values() if isinstance(target, WledOutputTarget) and target.color_strip_source_id == css_id ] def count(self) -> int: """Get number of targets.""" - return len(self._targets) + return len(self._items) diff --git a/server/src/wled_controller/storage/pattern_template.py b/server/src/wled_controller/storage/pattern_template.py index 08e83a3..b20eb03 100644 --- a/server/src/wled_controller/storage/pattern_template.py +++ b/server/src/wled_controller/storage/pattern_template.py @@ -1,7 +1,7 @@ """Pattern template data model for key color rectangle layouts.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from wled_controller.storage.key_colors_output_target import KeyColorRectangle @@ -17,6 +17,7 @@ class PatternTemplate: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert to dictionary.""" @@ -27,6 +28,7 @@ class PatternTemplate: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, } @classmethod @@ -39,9 +41,10 @@ class PatternTemplate: rectangles=rectangles, created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) - else data.get("created_at", datetime.utcnow()), + else data.get("created_at", datetime.now(timezone.utc)), updated_at=datetime.fromisoformat(data["updated_at"]) if isinstance(data.get("updated_at"), str) - else data.get("updated_at", datetime.utcnow()), + else data.get("updated_at", datetime.now(timezone.utc)), description=data.get("description"), + tags=data.get("tags", []), ) diff --git a/server/src/wled_controller/storage/pattern_template_store.py b/server/src/wled_controller/storage/pattern_template_store.py index a80e64e..abf85ab 100644 --- a/server/src/wled_controller/storage/pattern_template_store.py +++ b/server/src/wled_controller/storage/pattern_template_store.py @@ -1,42 +1,42 @@ """Pattern template storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.key_colors_output_target import KeyColorRectangle from wled_controller.storage.pattern_template import PatternTemplate -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class PatternTemplateStore: +class PatternTemplateStore(BaseJsonStore[PatternTemplate]): """Storage for pattern templates (rectangle layouts for key color extraction). All templates are persisted to the JSON file. On startup, if no templates exist, a default one is auto-created. """ - def __init__(self, file_path: str): - """Initialize pattern template store. + _json_key = "pattern_templates" + _entity_name = "Pattern template" - Args: - file_path: Path to templates JSON file - """ - self.file_path = Path(file_path) - self._templates: Dict[str, PatternTemplate] = {} - self._load() + def __init__(self, file_path: str): + super().__init__(file_path, PatternTemplate.from_dict) self._ensure_initial_template() + # Backward-compatible aliases + get_all_templates = BaseJsonStore.get_all + get_template = BaseJsonStore.get + delete_template = BaseJsonStore.delete + def _ensure_initial_template(self) -> None: """Auto-create a default pattern template if none exist.""" - if self._templates: + if self._items: return - now = datetime.utcnow() + now = datetime.now(timezone.utc) template_id = f"pat_{uuid.uuid4().hex[:8]}" template = PatternTemplate( @@ -50,95 +50,24 @@ class PatternTemplateStore: description="Default pattern template with full-frame rectangle", ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Auto-created initial pattern template: {template.name} ({template_id})") - def _load(self) -> None: - """Load templates from file.""" - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - templates_data = data.get("pattern_templates", {}) - loaded = 0 - for template_id, template_dict in templates_data.items(): - try: - template = PatternTemplate.from_dict(template_dict) - self._templates[template_id] = template - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load pattern template {template_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} pattern templates from storage") - - except Exception as e: - logger.error(f"Failed to load pattern templates from {self.file_path}: {e}") - raise - - logger.info(f"Pattern template store initialized with {len(self._templates)} templates") - - def _save(self) -> None: - """Save all templates to file.""" - try: - data = { - "version": "1.0.0", - "pattern_templates": { - template_id: template.to_dict() - for template_id, template in self._templates.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save pattern templates to {self.file_path}: {e}") - raise - - def get_all_templates(self) -> List[PatternTemplate]: - """Get all pattern templates.""" - return list(self._templates.values()) - - def get_template(self, template_id: str) -> PatternTemplate: - """Get template by ID. - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Pattern template not found: {template_id}") - return self._templates[template_id] - def create_template( self, name: str, rectangles: Optional[List[KeyColorRectangle]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PatternTemplate: - """Create a new pattern template. - - Args: - name: Template name (must be unique) - rectangles: List of named rectangles - description: Optional description - - Raises: - ValueError: If template with same name exists - """ - for template in self._templates.values(): - if template.name == name: - raise ValueError(f"Pattern template with name '{name}' already exists") + self._check_name_unique(name) if rectangles is None: rectangles = [] template_id = f"pat_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) template = PatternTemplate( id=template_id, @@ -147,9 +76,10 @@ class PatternTemplateStore: created_at=now, updated_at=now, description=description, + tags=tags or [], ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Created pattern template: {name} ({template_id})") @@ -161,48 +91,26 @@ class PatternTemplateStore: name: Optional[str] = None, rectangles: Optional[List[KeyColorRectangle]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PatternTemplate: - """Update an existing pattern template. - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Pattern template not found: {template_id}") - - template = self._templates[template_id] + template = self.get(template_id) if name is not None: - for tid, t in self._templates.items(): - if tid != template_id and t.name == name: - raise ValueError(f"Pattern template with name '{name}' already exists") + self._check_name_unique(name, exclude_id=template_id) template.name = name if rectangles is not None: template.rectangles = rectangles if description is not None: template.description = description + if tags is not None: + template.tags = tags - template.updated_at = datetime.utcnow() - + template.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated pattern template: {template_id}") return template - def delete_template(self, template_id: str) -> None: - """Delete a pattern template. - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Pattern template not found: {template_id}") - - del self._templates[template_id] - self._save() - - logger.info(f"Deleted pattern template: {template_id}") - def get_targets_referencing(self, template_id: str, output_target_store) -> List[str]: """Return names of KC targets that reference this template.""" from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget diff --git a/server/src/wled_controller/storage/picture_source.py b/server/src/wled_controller/storage/picture_source.py index 251c414..ccf7c71 100644 --- a/server/src/wled_controller/storage/picture_source.py +++ b/server/src/wled_controller/storage/picture_source.py @@ -1,8 +1,8 @@ """Picture source data model with inheritance-based stream types.""" -from dataclasses import dataclass -from datetime import datetime -from typing import Optional +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional @dataclass @@ -21,6 +21,7 @@ class PictureSource: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert stream to dictionary. Subclasses extend this.""" @@ -31,6 +32,7 @@ class PictureSource: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, # Subclass fields default to None for backward compat "display_index": None, "capture_template_id": None, @@ -47,39 +49,40 @@ class PictureSource: sid: str = data["id"] name: str = data["name"] description: str | None = data.get("description") + tags: list = data.get("tags", []) raw_created = data.get("created_at") created_at: datetime = ( datetime.fromisoformat(raw_created) if isinstance(raw_created, str) else raw_created if isinstance(raw_created, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) raw_updated = data.get("updated_at") updated_at: datetime = ( datetime.fromisoformat(raw_updated) if isinstance(raw_updated, str) else raw_updated if isinstance(raw_updated, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) if stream_type == "processed": return ProcessedPictureSource( id=sid, name=name, stream_type=stream_type, - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, source_stream_id=data.get("source_stream_id") or "", postprocessing_template_id=data.get("postprocessing_template_id") or "", ) elif stream_type == "static_image": return StaticImagePictureSource( id=sid, name=name, stream_type=stream_type, - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, image_source=data.get("image_source") or "", ) else: return ScreenCapturePictureSource( id=sid, name=name, stream_type=stream_type, - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, display_index=data.get("display_index") or 0, capture_template_id=data.get("capture_template_id") or "", target_fps=data.get("target_fps") or 30, diff --git a/server/src/wled_controller/storage/picture_source_store.py b/server/src/wled_controller/storage/picture_source_store.py index a4c03a6..336f09a 100644 --- a/server/src/wled_controller/storage/picture_source_store.py +++ b/server/src/wled_controller/storage/picture_source_store.py @@ -1,84 +1,43 @@ """Picture source storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional, Set +from datetime import datetime, timezone +from typing import List, Optional, Set +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.picture_source import ( PictureSource, - ScreenCapturePictureSource, ProcessedPictureSource, + ScreenCapturePictureSource, StaticImagePictureSource, ) -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class PictureSourceStore: +class PictureSourceStore(BaseJsonStore[PictureSource]): """Storage for picture sources. Supports raw and processed stream types with cycle detection for processed streams that reference other streams. """ + _json_key = "picture_sources" + _entity_name = "Picture source" + def __init__(self, file_path: str): - """Initialize picture source store. + super().__init__(file_path, PictureSource.from_dict) - Args: - file_path: Path to streams JSON file - """ - self.file_path = Path(file_path) - self._streams: Dict[str, PictureSource] = {} - self._load() + # Backward-compatible aliases + get_all_sources = BaseJsonStore.get_all + get_source = BaseJsonStore.get - def _load(self) -> None: - """Load streams from file.""" - if not self.file_path.exists(): - return + # Legacy aliases (old code used "stream" naming) + get_all_streams = BaseJsonStore.get_all + get_stream = BaseJsonStore.get - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - streams_data = data.get("picture_sources", {}) - loaded = 0 - for stream_id, stream_dict in streams_data.items(): - try: - stream = PictureSource.from_dict(stream_dict) - self._streams[stream_id] = stream - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load picture source {stream_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} picture sources from storage") - - except Exception as e: - logger.error(f"Failed to load picture sources from {self.file_path}: {e}") - raise - - logger.info(f"Picture source store initialized with {len(self._streams)} streams") - - def _save(self) -> None: - """Save all streams to file.""" - try: - data = { - "version": "1.0.0", - "picture_sources": { - stream_id: stream.to_dict() - for stream_id, stream in self._streams.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save picture sources to {self.file_path}: {e}") - raise + # ── Helpers ─────────────────────────────────────────────────────── def _detect_cycle(self, source_stream_id: str, exclude_stream_id: Optional[str] = None) -> bool: """Detect if following the source chain from source_stream_id would create a cycle. @@ -100,7 +59,7 @@ class PictureSourceStore: return True visited.add(current_id) - current_stream = self._streams.get(current_id) + current_stream = self._items.get(current_id) if not current_stream: break if not isinstance(current_stream, ProcessedPictureSource): @@ -109,19 +68,7 @@ class PictureSourceStore: return False - def get_all_streams(self) -> List[PictureSource]: - """Get all picture sources.""" - return list(self._streams.values()) - - def get_stream(self, stream_id: str) -> PictureSource: - """Get stream by ID. - - Raises: - ValueError: If stream not found - """ - if stream_id not in self._streams: - raise ValueError(f"Picture source not found: {stream_id}") - return self._streams[stream_id] + # ── CRUD ────────────────────────────────────────────────────────── def create_stream( self, @@ -134,6 +81,7 @@ class PictureSourceStore: postprocessing_template_id: Optional[str] = None, image_source: Optional[str] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PictureSource: """Create a new picture source. @@ -167,7 +115,7 @@ class PictureSourceStore: if not postprocessing_template_id: raise ValueError("Processed streams require postprocessing_template_id") # Validate source stream exists - if source_stream_id not in self._streams: + if source_stream_id not in self._items: raise ValueError(f"Source stream not found: {source_stream_id}") # Check for cycles if self._detect_cycle(source_stream_id): @@ -177,16 +125,15 @@ class PictureSourceStore: raise ValueError("Static image streams require image_source") # Check for duplicate name - for stream in self._streams.values(): - if stream.name == name: - raise ValueError(f"Picture source with name '{name}' already exists") + self._check_name_unique(name) stream_id = f"ps_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) common = dict( id=stream_id, name=name, stream_type=stream_type, created_at=now, updated_at=now, description=description, + tags=tags or [], ) stream: PictureSource @@ -209,7 +156,7 @@ class PictureSourceStore: image_source=image_source, # type: ignore[arg-type] ) - self._streams[stream_id] = stream + self._items[stream_id] = stream self._save() logger.info(f"Created picture source: {name} ({stream_id}, type={stream_type})") @@ -226,28 +173,29 @@ class PictureSourceStore: postprocessing_template_id: Optional[str] = None, image_source: Optional[str] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PictureSource: """Update an existing picture source. Raises: ValueError: If stream not found, validation fails, or cycle detected """ - if stream_id not in self._streams: - raise ValueError(f"Picture source not found: {stream_id}") - - stream = self._streams[stream_id] + stream = self.get(stream_id) # If changing source_stream_id on a processed stream, check for cycles if source_stream_id is not None and isinstance(stream, ProcessedPictureSource): - if source_stream_id not in self._streams: + if source_stream_id not in self._items: raise ValueError(f"Source stream not found: {source_stream_id}") if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id): raise ValueError("Cycle detected in stream chain") if name is not None: + self._check_name_unique(name, exclude_id=stream_id) stream.name = name if description is not None: stream.description = description + if tags is not None: + stream.tags = tags if isinstance(stream, ScreenCapturePictureSource): if display_index is not None: @@ -265,7 +213,7 @@ class PictureSourceStore: if image_source is not None: stream.image_source = image_source - stream.updated_at = datetime.utcnow() + stream.updated_at = datetime.now(timezone.utc) self._save() @@ -278,22 +226,29 @@ class PictureSourceStore: Raises: ValueError: If stream not found or is referenced by another stream """ - if stream_id not in self._streams: + if stream_id not in self._items: raise ValueError(f"Picture source not found: {stream_id}") # Check if any other stream references this one as source - for other_stream in self._streams.values(): + for other_stream in self._items.values(): if isinstance(other_stream, ProcessedPictureSource) and other_stream.source_stream_id == stream_id: raise ValueError( - f"Cannot delete stream '{self._streams[stream_id].name}': " + f"Cannot delete stream '{self._items[stream_id].name}': " f"it is referenced by stream '{other_stream.name}'" ) - del self._streams[stream_id] + del self._items[stream_id] self._save() logger.info(f"Deleted picture source: {stream_id}") + # Also expose as delete_source for consistency + def delete_source(self, source_id: str) -> None: + """Alias for delete_stream with reference checking.""" + self.delete_stream(source_id) + + # ── Query helpers ───────────────────────────────────────────────── + def get_targets_referencing(self, stream_id: str, target_store) -> List[str]: """Return names of targets that reference this stream.""" return target_store.get_targets_referencing_source(stream_id) @@ -324,7 +279,7 @@ class PictureSourceStore: raise ValueError(f"Cycle detected in stream chain at {current_id}") visited.add(current_id) - stream = self.get_stream(current_id) + stream = self.get(current_id) if not isinstance(stream, ProcessedPictureSource): return { diff --git a/server/src/wled_controller/storage/postprocessing_template.py b/server/src/wled_controller/storage/postprocessing_template.py index fd18e84..0f14377 100644 --- a/server/src/wled_controller/storage/postprocessing_template.py +++ b/server/src/wled_controller/storage/postprocessing_template.py @@ -1,7 +1,7 @@ """Postprocessing template data model.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional from wled_controller.core.filters.filter_instance import FilterInstance @@ -17,6 +17,7 @@ class PostprocessingTemplate: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert template to dictionary.""" @@ -27,6 +28,7 @@ class PostprocessingTemplate: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, } @classmethod @@ -40,9 +42,10 @@ class PostprocessingTemplate: filters=filters, created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) - else data.get("created_at", datetime.utcnow()), + else data.get("created_at", datetime.now(timezone.utc)), updated_at=datetime.fromisoformat(data["updated_at"]) if isinstance(data.get("updated_at"), str) - else data.get("updated_at", datetime.utcnow()), + else data.get("updated_at", datetime.now(timezone.utc)), description=data.get("description"), + tags=data.get("tags", []), ) diff --git a/server/src/wled_controller/storage/postprocessing_template_store.py b/server/src/wled_controller/storage/postprocessing_template_store.py index ff440c1..c60044b 100644 --- a/server/src/wled_controller/storage/postprocessing_template_store.py +++ b/server/src/wled_controller/storage/postprocessing_template_store.py @@ -1,44 +1,45 @@ """Postprocessing template storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional from wled_controller.core.filters.filter_instance import FilterInstance from wled_controller.core.filters.registry import FilterRegistry +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.picture_source import ProcessedPictureSource from wled_controller.storage.postprocessing_template import PostprocessingTemplate -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class PostprocessingTemplateStore: +class PostprocessingTemplateStore(BaseJsonStore[PostprocessingTemplate]): """Storage for postprocessing templates. All templates are persisted to the JSON file. On startup, if no templates exist, a default one is auto-created. """ - def __init__(self, file_path: str): - """Initialize postprocessing template store. + _json_key = "postprocessing_templates" + _entity_name = "Postprocessing template" + _version = "2.0.0" - Args: - file_path: Path to templates JSON file - """ - self.file_path = Path(file_path) - self._templates: Dict[str, PostprocessingTemplate] = {} - self._load() + def __init__(self, file_path: str): + super().__init__(file_path, PostprocessingTemplate.from_dict) self._ensure_initial_template() + # Backward-compatible aliases + get_all_templates = BaseJsonStore.get_all + get_template = BaseJsonStore.get + delete_template = BaseJsonStore.delete + def _ensure_initial_template(self) -> None: """Auto-create a default postprocessing template if none exist.""" - if self._templates: + if self._items: return - now = datetime.utcnow() + now = datetime.now(timezone.utc) template_id = f"pp_{uuid.uuid4().hex[:8]}" template = PostprocessingTemplate( @@ -54,89 +55,18 @@ class PostprocessingTemplateStore: description="Default postprocessing template", ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Auto-created initial postprocessing template: {template.name} ({template_id})") - def _load(self) -> None: - """Load templates from file.""" - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - templates_data = data.get("postprocessing_templates", {}) - loaded = 0 - for template_id, template_dict in templates_data.items(): - try: - template = PostprocessingTemplate.from_dict(template_dict) - self._templates[template_id] = template - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load postprocessing template {template_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} postprocessing templates from storage") - - except Exception as e: - logger.error(f"Failed to load postprocessing templates from {self.file_path}: {e}") - raise - - logger.info(f"Postprocessing template store initialized with {len(self._templates)} templates") - - def _save(self) -> None: - """Save all templates to file.""" - try: - data = { - "version": "2.0.0", - "postprocessing_templates": { - template_id: template.to_dict() - for template_id, template in self._templates.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save postprocessing templates to {self.file_path}: {e}") - raise - - def get_all_templates(self) -> List[PostprocessingTemplate]: - """Get all postprocessing templates.""" - return list(self._templates.values()) - - def get_template(self, template_id: str) -> PostprocessingTemplate: - """Get template by ID. - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Postprocessing template not found: {template_id}") - return self._templates[template_id] - def create_template( self, name: str, filters: Optional[List[FilterInstance]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PostprocessingTemplate: - """Create a new postprocessing template. - - Args: - name: Template name (must be unique) - filters: Ordered list of filter instances - description: Optional description - - Raises: - ValueError: If template with same name exists or invalid filter_id - """ - for template in self._templates.values(): - if template.name == name: - raise ValueError(f"Postprocessing template with name '{name}' already exists") + self._check_name_unique(name) if filters is None: filters = [] @@ -147,7 +77,7 @@ class PostprocessingTemplateStore: raise ValueError(f"Unknown filter type: '{fi.filter_id}'") template_id = f"pp_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) template = PostprocessingTemplate( id=template_id, @@ -156,9 +86,10 @@ class PostprocessingTemplateStore: created_at=now, updated_at=now, description=description, + tags=tags or [], ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Created postprocessing template: {name} ({template_id})") @@ -170,21 +101,12 @@ class PostprocessingTemplateStore: name: Optional[str] = None, filters: Optional[List[FilterInstance]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> PostprocessingTemplate: - """Update an existing postprocessing template. - - Raises: - ValueError: If template not found or invalid filter_id - """ - if template_id not in self._templates: - raise ValueError(f"Postprocessing template not found: {template_id}") - - template = self._templates[template_id] + template = self.get(template_id) if name is not None: - for tid, t in self._templates.items(): - if tid != template_id and t.name == name: - raise ValueError(f"Postprocessing template with name '{name}' already exists") + self._check_name_unique(name, exclude_id=template_id) template.name = name if filters is not None: # Validate filter IDs @@ -194,28 +116,15 @@ class PostprocessingTemplateStore: template.filters = filters if description is not None: template.description = description + if tags is not None: + template.tags = tags - template.updated_at = datetime.utcnow() - + template.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated postprocessing template: {template_id}") return template - def delete_template(self, template_id: str) -> None: - """Delete a postprocessing template. - - Raises: - ValueError: If template not found or is referenced by a picture source - """ - if template_id not in self._templates: - raise ValueError(f"Postprocessing template not found: {template_id}") - - del self._templates[template_id] - self._save() - - logger.info(f"Deleted postprocessing template: {template_id}") - def resolve_filter_instances(self, filter_instances, _visited=None): """Recursively resolve filter instances, expanding filter_template references. diff --git a/server/src/wled_controller/storage/scene_preset.py b/server/src/wled_controller/storage/scene_preset.py index 3af63f8..7d9a4d1 100644 --- a/server/src/wled_controller/storage/scene_preset.py +++ b/server/src/wled_controller/storage/scene_preset.py @@ -1,7 +1,7 @@ """Scene preset data models — snapshot of target state.""" from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List @@ -42,16 +42,18 @@ class ScenePreset: id: str name: str description: str = "" + tags: List[str] = field(default_factory=list) targets: List[TargetSnapshot] = field(default_factory=list) order: int = 0 - created_at: datetime = field(default_factory=datetime.utcnow) - updated_at: datetime = field(default_factory=datetime.utcnow) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "description": self.description, + "tags": self.tags, "targets": [t.to_dict() for t in self.targets], "order": self.order, "created_at": self.created_at.isoformat(), @@ -64,8 +66,9 @@ class ScenePreset: id=data["id"], name=data["name"], description=data.get("description", ""), + tags=data.get("tags", []), targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])], order=data.get("order", 0), - created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) diff --git a/server/src/wled_controller/storage/scene_preset_store.py b/server/src/wled_controller/storage/scene_preset_store.py index 86e3a56..accce06 100644 --- a/server/src/wled_controller/storage/scene_preset_store.py +++ b/server/src/wled_controller/storage/scene_preset_store.py @@ -1,79 +1,40 @@ """Scene preset storage using JSON files.""" -import json -import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.scene_preset import ScenePreset, TargetSnapshot -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class ScenePresetStore: +class ScenePresetStore(BaseJsonStore[ScenePreset]): """Persistent storage for scene presets.""" + _json_key = "scene_presets" + _entity_name = "Scene preset" + def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._presets: Dict[str, ScenePreset] = {} - self._load() + super().__init__(file_path, ScenePreset.from_dict) - def _load(self) -> None: - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - presets_data = data.get("scene_presets", {}) - loaded = 0 - for preset_id, preset_dict in presets_data.items(): - try: - preset = ScenePreset.from_dict(preset_dict) - self._presets[preset_id] = preset - loaded += 1 - except Exception as e: - logger.error(f"Failed to load scene preset {preset_id}: {e}", exc_info=True) - - if loaded > 0: - logger.info(f"Loaded {loaded} scene presets from storage") - - except Exception as e: - logger.error(f"Failed to load scene presets from {self.file_path}: {e}") - raise - - logger.info(f"Scene preset store initialized with {len(self._presets)} presets") - - def _save(self) -> None: - try: - data = { - "version": "1.0.0", - "scene_presets": { - pid: p.to_dict() for pid, p in self._presets.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save scene presets to {self.file_path}: {e}") - raise + # Backward-compatible aliases + get_preset = BaseJsonStore.get + delete_preset = BaseJsonStore.delete def get_all_presets(self) -> List[ScenePreset]: - return sorted(self._presets.values(), key=lambda p: p.order) + """Get all presets sorted by order field.""" + return sorted(self._items.values(), key=lambda p: p.order) - def get_preset(self, preset_id: str) -> ScenePreset: - if preset_id not in self._presets: - raise ValueError(f"Scene preset not found: {preset_id}") - return self._presets[preset_id] + # Override get_all to also sort by order for consistency + def get_all(self) -> List[ScenePreset]: + return self.get_all_presets() def create_preset(self, preset: ScenePreset) -> ScenePreset: - for p in self._presets.values(): - if p.name == preset.name: - raise ValueError(f"Scene preset with name '{preset.name}' already exists") + self._check_name_unique(preset.name) - self._presets[preset.id] = preset + self._items[preset.id] = preset self._save() logger.info(f"Created scene preset: {preset.name} ({preset.id})") return preset @@ -85,16 +46,12 @@ class ScenePresetStore: description: Optional[str] = None, order: Optional[int] = None, targets: Optional[List[TargetSnapshot]] = None, + tags: Optional[List[str]] = None, ) -> ScenePreset: - if preset_id not in self._presets: - raise ValueError(f"Scene preset not found: {preset_id}") - - preset = self._presets[preset_id] + preset = self.get(preset_id) if name is not None: - for pid, p in self._presets.items(): - if pid != preset_id and p.name == name: - raise ValueError(f"Scene preset with name '{name}' already exists") + self._check_name_unique(name, exclude_id=preset_id) preset.name = name if description is not None: preset.description = description @@ -102,31 +59,20 @@ class ScenePresetStore: preset.order = order if targets is not None: preset.targets = targets + if tags is not None: + preset.tags = tags - preset.updated_at = datetime.utcnow() + preset.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated scene preset: {preset_id}") return preset def recapture_preset(self, preset_id: str, preset: ScenePreset) -> ScenePreset: """Replace snapshot data of an existing preset (recapture current state).""" - if preset_id not in self._presets: - raise ValueError(f"Scene preset not found: {preset_id}") + existing = self.get(preset_id) - existing = self._presets[preset_id] existing.targets = preset.targets - existing.updated_at = datetime.utcnow() + existing.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Recaptured scene preset: {preset_id}") return existing - - def delete_preset(self, preset_id: str) -> None: - if preset_id not in self._presets: - raise ValueError(f"Scene preset not found: {preset_id}") - - del self._presets[preset_id] - self._save() - logger.info(f"Deleted scene preset: {preset_id}") - - def count(self) -> int: - return len(self._presets) diff --git a/server/src/wled_controller/storage/sync_clock.py b/server/src/wled_controller/storage/sync_clock.py index 33341da..3927cf5 100644 --- a/server/src/wled_controller/storage/sync_clock.py +++ b/server/src/wled_controller/storage/sync_clock.py @@ -5,9 +5,9 @@ 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 dataclasses import dataclass, field from datetime import datetime -from typing import Optional +from typing import List, Optional @dataclass @@ -20,6 +20,7 @@ class SyncClock: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: return { @@ -27,6 +28,7 @@ class SyncClock: "name": self.name, "speed": self.speed, "description": self.description, + "tags": self.tags, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } @@ -38,6 +40,7 @@ class SyncClock: name=data["name"], speed=float(data.get("speed", 1.0)), description=data.get("description"), + tags=data.get("tags", []), created_at=datetime.fromisoformat(data["created_at"]), updated_at=datetime.fromisoformat(data["updated_at"]), ) diff --git a/server/src/wled_controller/storage/sync_clock_store.py b/server/src/wled_controller/storage/sync_clock_store.py index 6ff6c97..eacc090 100644 --- a/server/src/wled_controller/storage/sync_clock_store.py +++ b/server/src/wled_controller/storage/sync_clock_store.py @@ -1,95 +1,38 @@ """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 datetime import datetime, timezone +from typing import List, Optional +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.sync_clock import SyncClock -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class SyncClockStore: - """Persistent storage for synchronization clocks.""" +class SyncClockStore(BaseJsonStore[SyncClock]): + _json_key = "sync_clocks" + _entity_name = "Sync clock" def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._clocks: Dict[str, SyncClock] = {} - self._load() + super().__init__(file_path, SyncClock.from_dict) - 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] + # Backward-compatible aliases + get_all_clocks = BaseJsonStore.get_all + get_clock = BaseJsonStore.get + delete_clock = BaseJsonStore.delete def create_clock( self, name: str, speed: float = 1.0, description: Optional[str] = None, + tags: Optional[List[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") - + self._check_name_unique(name) cid = f"sc_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) clock = SyncClock( id=cid, @@ -98,11 +41,11 @@ class SyncClockStore: created_at=now, updated_at=now, description=description, + tags=tags or [], ) - self._clocks[cid] = clock + self._items[cid] = clock self._save() - logger.info(f"Created sync clock: {name} ({cid}, speed={clock.speed})") return clock @@ -112,35 +55,21 @@ class SyncClockStore: name: Optional[str] = None, speed: Optional[float] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> SyncClock: - if clock_id not in self._clocks: - raise ValueError(f"Sync clock not found: {clock_id}") - - clock = self._clocks[clock_id] + clock = self.get(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") + self._check_name_unique(name, exclude_id=clock_id) 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 + if tags is not None: + clock.tags = tags - clock.updated_at = datetime.utcnow() + clock.updated_at = datetime.now(timezone.utc) 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}") diff --git a/server/src/wled_controller/storage/template.py b/server/src/wled_controller/storage/template.py index 30e3561..7a1a00e 100644 --- a/server/src/wled_controller/storage/template.py +++ b/server/src/wled_controller/storage/template.py @@ -1,8 +1,8 @@ """Capture template data model.""" -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Dict, Optional +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional @dataclass @@ -16,6 +16,7 @@ class CaptureTemplate: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert template to dictionary. @@ -31,6 +32,7 @@ class CaptureTemplate: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, } @classmethod @@ -50,9 +52,10 @@ class CaptureTemplate: engine_config=data.get("engine_config", {}), created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) - else data.get("created_at", datetime.utcnow()), + else data.get("created_at", datetime.now(timezone.utc)), updated_at=datetime.fromisoformat(data["updated_at"]) if isinstance(data.get("updated_at"), str) - else data.get("updated_at", datetime.utcnow()), + else data.get("updated_at", datetime.now(timezone.utc)), description=data.get("description"), + tags=data.get("tags", []), ) diff --git a/server/src/wled_controller/storage/template_store.py b/server/src/wled_controller/storage/template_store.py index 2b64f6b..b0e8449 100644 --- a/server/src/wled_controller/storage/template_store.py +++ b/server/src/wled_controller/storage/template_store.py @@ -1,19 +1,18 @@ """Template storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path +from datetime import datetime, timezone from typing import Dict, List, Optional from wled_controller.core.capture_engines.factory import EngineRegistry +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.template import CaptureTemplate -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class TemplateStore: +class TemplateStore(BaseJsonStore[CaptureTemplate]): """Storage for capture templates. All templates are persisted to the JSON file. @@ -21,20 +20,21 @@ class TemplateStore: highest-priority available engine. """ - def __init__(self, file_path: str): - """Initialize template store. + _json_key = "templates" + _entity_name = "Capture template" - Args: - file_path: Path to templates JSON file - """ - self.file_path = Path(file_path) - self._templates: Dict[str, CaptureTemplate] = {} - self._load() + def __init__(self, file_path: str): + super().__init__(file_path, CaptureTemplate.from_dict) self._ensure_initial_template() + # Backward-compatible aliases + get_all_templates = BaseJsonStore.get_all + get_template = BaseJsonStore.get + delete_template = BaseJsonStore.delete + def _ensure_initial_template(self) -> None: """Auto-create a template if none exist, using the best available engine.""" - if self._templates: + if self._items: return best_engine = EngineRegistry.get_best_available_engine() @@ -44,7 +44,7 @@ class TemplateStore: engine_class = EngineRegistry.get_engine(best_engine) default_config = engine_class.get_default_config() - now = datetime.utcnow() + now = datetime.now(timezone.utc) template_id = f"tpl_{uuid.uuid4().hex[:8]}" template = CaptureTemplate( @@ -57,111 +57,22 @@ class TemplateStore: description=f"Default capture template using {best_engine.upper()} engine", ) - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Auto-created initial template: {template.name} ({template_id}, engine={best_engine})") - def _load(self) -> None: - """Load templates from file.""" - if not self.file_path.exists(): - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - templates_data = data.get("templates", {}) - loaded = 0 - for template_id, template_dict in templates_data.items(): - try: - template = CaptureTemplate.from_dict(template_dict) - self._templates[template_id] = template - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load template {template_id}: {e}", - exc_info=True - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} templates from storage") - - except Exception as e: - logger.error(f"Failed to load templates from {self.file_path}: {e}") - raise - - logger.info(f"Template store initialized with {len(self._templates)} templates") - - def _save(self) -> None: - """Save all templates to file.""" - try: - data = { - "version": "1.0.0", - "templates": { - template_id: template.to_dict() - for template_id, template in self._templates.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save templates to {self.file_path}: {e}") - raise - - def get_all_templates(self) -> List[CaptureTemplate]: - """Get all templates. - - Returns: - List of all templates - """ - return list(self._templates.values()) - - def get_template(self, template_id: str) -> CaptureTemplate: - """Get template by ID. - - Args: - template_id: Template ID - - Returns: - Template instance - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Template not found: {template_id}") - return self._templates[template_id] - def create_template( self, name: str, engine_type: str, engine_config: Dict[str, any], description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> CaptureTemplate: - """Create a new template. + self._check_name_unique(name) - Args: - name: Template name - engine_type: Engine type (mss, dxcam, wgc) - engine_config: Engine-specific configuration - description: Optional description - - Returns: - Created template - - Raises: - ValueError: If template with same name exists - """ - # Check for duplicate name - for template in self._templates.values(): - if template.name == name: - raise ValueError(f"Template with name '{name}' already exists") - - # Generate new ID template_id = f"tpl_{uuid.uuid4().hex[:8]}" - - # Create template - now = datetime.utcnow() + now = datetime.now(timezone.utc) template = CaptureTemplate( id=template_id, name=name, @@ -170,10 +81,10 @@ class TemplateStore: created_at=now, updated_at=now, description=description, + tags=tags or [], ) - # Store and save - self._templates[template_id] = template + self._items[template_id] = template self._save() logger.info(f"Created template: {name} ({template_id})") @@ -186,32 +97,12 @@ class TemplateStore: engine_type: Optional[str] = None, engine_config: Optional[Dict[str, any]] = None, description: Optional[str] = None, + tags: Optional[List[str]] = None, ) -> CaptureTemplate: - """Update an existing template. + template = self.get(template_id) - Args: - template_id: Template ID - name: New name (optional) - engine_type: New engine type (optional) - engine_config: New engine config (optional) - description: New description (optional) - - Returns: - Updated template - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Template not found: {template_id}") - - template = self._templates[template_id] - - # Update fields if name is not None: - for tid, t in self._templates.items(): - if tid != template_id and t.name == name: - raise ValueError(f"Template with name '{name}' already exists") + self._check_name_unique(name, exclude_id=template_id) template.name = name if engine_type is not None: template.engine_type = engine_type @@ -219,29 +110,11 @@ class TemplateStore: template.engine_config = engine_config if description is not None: template.description = description + if tags is not None: + template.tags = tags - template.updated_at = datetime.utcnow() - - # Save + template.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated template: {template_id}") return template - - def delete_template(self, template_id: str) -> None: - """Delete a template. - - Args: - template_id: Template ID - - Raises: - ValueError: If template not found - """ - if template_id not in self._templates: - raise ValueError(f"Template not found: {template_id}") - - # Remove and save - del self._templates[template_id] - self._save() - - logger.info(f"Deleted template: {template_id}") diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index e9f20e2..f97d2f6 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -11,7 +11,7 @@ parameters like brightness. Five types: """ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional @@ -25,6 +25,7 @@ class ValueSource: created_at: datetime updated_at: datetime description: Optional[str] = None + tags: List[str] = field(default_factory=list) def to_dict(self) -> dict: """Convert source to dictionary. Subclasses extend this.""" @@ -35,6 +36,7 @@ class ValueSource: "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, + "tags": self.tags, # Subclass fields default to None for forward compat "value": None, "waveform": None, @@ -58,26 +60,27 @@ class ValueSource: sid: str = data["id"] name: str = data["name"] description: str | None = data.get("description") + tags: list = data.get("tags", []) raw_created = data.get("created_at") created_at: datetime = ( datetime.fromisoformat(raw_created) if isinstance(raw_created, str) else raw_created if isinstance(raw_created, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) raw_updated = data.get("updated_at") updated_at: datetime = ( datetime.fromisoformat(raw_updated) if isinstance(raw_updated, str) else raw_updated if isinstance(raw_updated, datetime) - else datetime.utcnow() + else datetime.now(timezone.utc) ) if source_type == "animated": return AnimatedValueSource( id=sid, name=name, source_type="animated", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, waveform=data.get("waveform") or "sine", speed=float(data.get("speed") or 10.0), min_value=float(data.get("min_value") or 0.0), @@ -87,7 +90,7 @@ class ValueSource: if source_type == "audio": return AudioValueSource( id=sid, name=name, source_type="audio", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, audio_source_id=data.get("audio_source_id") or "", mode=data.get("mode") or "rms", sensitivity=float(data.get("sensitivity") or 1.0), @@ -100,7 +103,7 @@ class ValueSource: if source_type == "adaptive_time": return AdaptiveValueSource( id=sid, name=name, source_type="adaptive_time", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, schedule=data.get("schedule") or [], min_value=float(data.get("min_value") or 0.0), max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0, @@ -109,7 +112,7 @@ class ValueSource: if source_type == "adaptive_scene": return AdaptiveValueSource( id=sid, name=name, source_type="adaptive_scene", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, picture_source_id=data.get("picture_source_id") or "", scene_behavior=data.get("scene_behavior") or "complement", sensitivity=float(data.get("sensitivity") or 1.0), @@ -121,7 +124,7 @@ class ValueSource: # Default: "static" type return StaticValueSource( id=sid, name=name, source_type="static", - created_at=created_at, updated_at=updated_at, description=description, + created_at=created_at, updated_at=updated_at, description=description, tags=tags, value=float(data["value"]) if data.get("value") is not None else 1.0, ) diff --git a/server/src/wled_controller/storage/value_source_store.py b/server/src/wled_controller/storage/value_source_store.py index b3dcd44..fb896f0 100644 --- a/server/src/wled_controller/storage/value_source_store.py +++ b/server/src/wled_controller/storage/value_source_store.py @@ -1,11 +1,10 @@ """Value source storage using JSON files.""" -import json import uuid -from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional +from datetime import datetime, timezone +from typing import List, Optional +from wled_controller.storage.base_store import BaseJsonStore from wled_controller.storage.value_source import ( AdaptiveValueSource, AnimatedValueSource, @@ -13,74 +12,27 @@ from wled_controller.storage.value_source import ( StaticValueSource, ValueSource, ) -from wled_controller.utils import atomic_write_json, get_logger +from wled_controller.utils import get_logger logger = get_logger(__name__) -class ValueSourceStore: +class ValueSourceStore(BaseJsonStore[ValueSource]): """Persistent storage for value sources.""" + _json_key = "value_sources" + _entity_name = "Value source" + def __init__(self, file_path: str): - self.file_path = Path(file_path) - self._sources: Dict[str, ValueSource] = {} - self._load() + super().__init__(file_path, ValueSource.from_dict) - def _load(self) -> None: - if not self.file_path.exists(): - logger.info("Value source store file not found — starting empty") - return - - try: - with open(self.file_path, "r", encoding="utf-8") as f: - data = json.load(f) - - sources_data = data.get("value_sources", {}) - loaded = 0 - for source_id, source_dict in sources_data.items(): - try: - source = ValueSource.from_dict(source_dict) - self._sources[source_id] = source - loaded += 1 - except Exception as e: - logger.error( - f"Failed to load value source {source_id}: {e}", - exc_info=True, - ) - - if loaded > 0: - logger.info(f"Loaded {loaded} value sources from storage") - - except Exception as e: - logger.error(f"Failed to load value sources from {self.file_path}: {e}") - raise - - logger.info(f"Value source store initialized with {len(self._sources)} sources") - - def _save(self) -> None: - try: - data = { - "version": "1.0.0", - "value_sources": { - sid: source.to_dict() - for sid, source in self._sources.items() - }, - } - atomic_write_json(self.file_path, data) - except Exception as e: - logger.error(f"Failed to save value sources to {self.file_path}: {e}") - raise + # Backward-compatible aliases + get_all_sources = BaseJsonStore.get_all + get_source = BaseJsonStore.get + delete_source = BaseJsonStore.delete # ── CRUD ───────────────────────────────────────────────────────── - def get_all_sources(self) -> List[ValueSource]: - return list(self._sources.values()) - - def get_source(self, source_id: str) -> ValueSource: - if source_id not in self._sources: - raise ValueError(f"Value source not found: {source_id}") - return self._sources[source_id] - def create_source( self, name: str, @@ -99,30 +51,28 @@ class ValueSourceStore: picture_source_id: Optional[str] = None, scene_behavior: Optional[str] = None, auto_gain: Optional[bool] = None, + tags: Optional[List[str]] = None, ) -> ValueSource: - if not name or not name.strip(): - raise ValueError("Name is required") - if source_type not in ("static", "animated", "audio", "adaptive_time", "adaptive_scene"): raise ValueError(f"Invalid source type: {source_type}") - for source in self._sources.values(): - if source.name == name: - raise ValueError(f"Value source with name '{name}' already exists") + self._check_name_unique(name) sid = f"vs_{uuid.uuid4().hex[:8]}" - now = datetime.utcnow() + now = datetime.now(timezone.utc) + + common_tags = tags or [] if source_type == "static": source: ValueSource = StaticValueSource( id=sid, name=name, source_type="static", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=common_tags, value=value if value is not None else 1.0, ) elif source_type == "animated": source = AnimatedValueSource( id=sid, name=name, source_type="animated", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=common_tags, waveform=waveform or "sine", speed=speed if speed is not None else 10.0, min_value=min_value if min_value is not None else 0.0, @@ -131,7 +81,7 @@ class ValueSourceStore: elif source_type == "audio": source = AudioValueSource( id=sid, name=name, source_type="audio", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=common_tags, audio_source_id=audio_source_id or "", mode=mode or "rms", sensitivity=sensitivity if sensitivity is not None else 1.0, @@ -146,7 +96,7 @@ class ValueSourceStore: raise ValueError("Time of day schedule requires at least 2 points") source = AdaptiveValueSource( id=sid, name=name, source_type="adaptive_time", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=common_tags, schedule=schedule_data, min_value=min_value if min_value is not None else 0.0, max_value=max_value if max_value is not None else 1.0, @@ -154,7 +104,7 @@ class ValueSourceStore: elif source_type == "adaptive_scene": source = AdaptiveValueSource( id=sid, name=name, source_type="adaptive_scene", - created_at=now, updated_at=now, description=description, + created_at=now, updated_at=now, description=description, tags=common_tags, picture_source_id=picture_source_id or "", scene_behavior=scene_behavior or "complement", sensitivity=sensitivity if sensitivity is not None else 1.0, @@ -163,7 +113,7 @@ class ValueSourceStore: max_value=max_value if max_value is not None else 1.0, ) - self._sources[sid] = source + self._items[sid] = source self._save() logger.info(f"Created value source: {name} ({sid}, type={source_type})") @@ -187,20 +137,18 @@ class ValueSourceStore: picture_source_id: Optional[str] = None, scene_behavior: Optional[str] = None, auto_gain: Optional[bool] = None, + tags: Optional[List[str]] = None, ) -> ValueSource: - if source_id not in self._sources: - raise ValueError(f"Value source not found: {source_id}") - - source = self._sources[source_id] + source = self.get(source_id) if name is not None: - for other in self._sources.values(): - if other.id != source_id and other.name == name: - raise ValueError(f"Value source with name '{name}' already exists") + self._check_name_unique(name, exclude_id=source_id) source.name = name if description is not None: source.description = description + if tags is not None: + source.tags = tags if isinstance(source, StaticValueSource): if value is not None: @@ -247,17 +195,8 @@ class ValueSourceStore: if max_value is not None: source.max_value = max_value - source.updated_at = datetime.utcnow() + source.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated value source: {source_id}") return source - - def delete_source(self, source_id: str) -> None: - if source_id not in self._sources: - raise ValueError(f"Value source not found: {source_id}") - - del self._sources[source_id] - self._save() - - logger.info(f"Deleted value source: {source_id}") diff --git a/server/src/wled_controller/storage/wled_output_target.py b/server/src/wled_controller/storage/wled_output_target.py index e6fce93..f99bb4b 100644 --- a/server/src/wled_controller/storage/wled_output_target.py +++ b/server/src/wled_controller/storage/wled_output_target.py @@ -1,8 +1,8 @@ """LED output target — sends color strip sources to an LED device.""" from dataclasses import dataclass -from datetime import datetime -from typing import Optional +from datetime import datetime, timezone +from typing import List, Optional from wled_controller.storage.output_target import OutputTarget @@ -63,9 +63,10 @@ class WledOutputTarget(OutputTarget): brightness_value_source_id=None, fps=None, keepalive_interval=None, state_check_interval=None, min_brightness_threshold=None, adaptive_fps=None, protocol=None, - description=None, **_kwargs) -> None: + description=None, tags: Optional[List[str]] = None, + **_kwargs) -> None: """Apply mutable field updates for WLED targets.""" - super().update_fields(name=name, description=description) + super().update_fields(name=name, description=description, tags=tags) if device_id is not None: self.device_id = device_id if color_strip_source_id is not None: @@ -120,6 +121,7 @@ class WledOutputTarget(OutputTarget): adaptive_fps=data.get("adaptive_fps", False), protocol=data.get("protocol", "ddp"), description=data.get("description"), - created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), - updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), + tags=data.get("tags", []), + created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), + updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) diff --git a/server/src/wled_controller/templates/modals/audio-source-editor.html b/server/src/wled_controller/templates/modals/audio-source-editor.html index c897aaf..1140871 100644 --- a/server/src/wled_controller/templates/modals/audio-source-editor.html +++ b/server/src/wled_controller/templates/modals/audio-source-editor.html @@ -85,6 +85,15 @@
+ +
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/automation-editor.html b/server/src/wled_controller/templates/modals/automation-editor.html index d007883..8224696 100644 --- a/server/src/wled_controller/templates/modals/automation-editor.html +++ b/server/src/wled_controller/templates/modals/automation-editor.html @@ -85,6 +85,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/capture-template.html b/server/src/wled_controller/templates/modals/capture-template.html index 2ced879..f7e0fe4 100644 --- a/server/src/wled_controller/templates/modals/capture-template.html +++ b/server/src/wled_controller/templates/modals/capture-template.html @@ -34,6 +34,15 @@
+
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 37ac3cb..afd8e11 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -576,6 +576,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/device-settings.html b/server/src/wled_controller/templates/modals/device-settings.html index 25478dc..2712b30 100644 --- a/server/src/wled_controller/templates/modals/device-settings.html +++ b/server/src/wled_controller/templates/modals/device-settings.html @@ -136,6 +136,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/kc-editor.html b/server/src/wled_controller/templates/modals/kc-editor.html index c15b5f9..c31677e 100644 --- a/server/src/wled_controller/templates/modals/kc-editor.html +++ b/server/src/wled_controller/templates/modals/kc-editor.html @@ -80,6 +80,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/pattern-template.html b/server/src/wled_controller/templates/modals/pattern-template.html index 64b7eec..80973d1 100644 --- a/server/src/wled_controller/templates/modals/pattern-template.html +++ b/server/src/wled_controller/templates/modals/pattern-template.html @@ -56,6 +56,15 @@
+
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/pp-template.html b/server/src/wled_controller/templates/modals/pp-template.html index 1a152d5..49c3b7f 100644 --- a/server/src/wled_controller/templates/modals/pp-template.html +++ b/server/src/wled_controller/templates/modals/pp-template.html @@ -28,6 +28,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/scene-preset-editor.html b/server/src/wled_controller/templates/modals/scene-preset-editor.html index dd1289d..182de0e 100644 --- a/server/src/wled_controller/templates/modals/scene-preset-editor.html +++ b/server/src/wled_controller/templates/modals/scene-preset-editor.html @@ -37,6 +37,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index d8c1e38..c74bf8b 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -96,6 +96,15 @@ +
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/sync-clock-editor.html b/server/src/wled_controller/templates/modals/sync-clock-editor.html index 3850b5e..d6d1968 100644 --- a/server/src/wled_controller/templates/modals/sync-clock-editor.html +++ b/server/src/wled_controller/templates/modals/sync-clock-editor.html @@ -41,6 +41,15 @@ + +
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+ diff --git a/server/src/wled_controller/templates/modals/value-source-editor.html b/server/src/wled_controller/templates/modals/value-source-editor.html index 4229d5f..63c5b86 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -267,6 +267,15 @@ + +
+
+ + +
+ +
+