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 `
`; +} + +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 = ` +