Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
138
REVIEW.md
Normal file
138
REVIEW.md
Normal file
@@ -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 | |
|
||||
46
TODO.md
46
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -190,8 +190,11 @@ 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
|
||||
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]
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,22 +81,34 @@ 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:
|
||||
if not clock_id:
|
||||
source = self._color_strip_store.get_source(source_id)
|
||||
clock_id = getattr(source, "clock_id", None)
|
||||
if clock_id:
|
||||
@@ -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
|
||||
if entry.clock_id:
|
||||
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=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:
|
||||
if new_clock_id != old_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)
|
||||
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)
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -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,6 +75,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
return True
|
||||
|
||||
def start(self) -> None:
|
||||
with self._sub_lock:
|
||||
self._acquire_sub_streams()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
@@ -86,6 +93,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
with self._sub_lock:
|
||||
self._release_sub_streams()
|
||||
logger.info(f"CompositeColorStripStream stopped: {self._source_id}")
|
||||
|
||||
@@ -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,6 +128,7 @@ class CompositeColorStripStream(ColorStripStream):
|
||||
|
||||
# If layer composition changed, rebuild sub-streams
|
||||
if old_layer_ids != new_layer_ids:
|
||||
with self._sub_lock:
|
||||
self._release_sub_streams()
|
||||
self._acquire_sub_streams()
|
||||
logger.info(f"CompositeColorStripStream rebuilt sub-streams: {self._source_id}")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
for ws, ok in zip(clients, results):
|
||||
if not ok and ws in self._ws_clients:
|
||||
self._ws_clients.remove(ws)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,6 +61,7 @@ class MappedColorStripStream(ColorStripStream):
|
||||
return True
|
||||
|
||||
def start(self) -> None:
|
||||
with self._sub_lock:
|
||||
self._acquire_sub_streams()
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
@@ -72,6 +79,7 @@ class MappedColorStripStream(ColorStripStream):
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
with self._sub_lock:
|
||||
self._release_sub_streams()
|
||||
logger.info(f"MappedColorStripStream stopped: {self._source_id}")
|
||||
|
||||
@@ -82,6 +90,7 @@ 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
|
||||
with self._sub_lock:
|
||||
self._reconfigure_sub_streams()
|
||||
logger.debug(f"MappedColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
|
||||
@@ -100,6 +109,7 @@ class MappedColorStripStream(ColorStripStream):
|
||||
self._auto_size = False
|
||||
|
||||
if old_zone_ids != new_zone_ids:
|
||||
with self._sub_lock:
|
||||
self._release_sub_streams()
|
||||
self._acquire_sub_streams()
|
||||
logger.info(f"MappedColorStripStream rebuilt sub-streams: {self._source_id}")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +27,7 @@ class SyncClockManager:
|
||||
|
||||
def acquire(self, clock_id: str) -> SyncClockRuntime:
|
||||
"""Get or create a runtime for *clock_id* (ref-counted)."""
|
||||
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]}")
|
||||
@@ -39,6 +42,7 @@ class SyncClockManager:
|
||||
|
||||
def release(self, clock_id: str) -> None:
|
||||
"""Decrement ref count; destroy runtime when it reaches zero."""
|
||||
with self._lock:
|
||||
if clock_id not in self._ref_counts:
|
||||
return
|
||||
self._ref_counts[clock_id] -= 1
|
||||
@@ -50,11 +54,13 @@ class SyncClockManager:
|
||||
|
||||
def release_all_for(self, clock_id: str) -> None:
|
||||
"""Force-release all references to *clock_id* (used on delete)."""
|
||||
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)."""
|
||||
with self._lock:
|
||||
self._runtimes.clear()
|
||||
self._ref_counts.clear()
|
||||
|
||||
@@ -62,10 +68,12 @@ class SyncClockManager:
|
||||
|
||||
def get_runtime(self, clock_id: str) -> Optional[SyncClockRuntime]:
|
||||
"""Return an existing runtime or *None* (does not create one)."""
|
||||
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."""
|
||||
with self._lock:
|
||||
rt = self._runtimes.get(clock_id)
|
||||
if rt:
|
||||
return rt
|
||||
|
||||
@@ -44,6 +44,7 @@ class SyncClockRuntime:
|
||||
|
||||
Returns *real* (wall-clock) elapsed time, not speed-scaled.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._running:
|
||||
return self._offset
|
||||
return self._offset + (time.perf_counter() - self._epoch)
|
||||
|
||||
@@ -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,10 +514,11 @@ 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:
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
225
server/src/wled_controller/static/js/core/tag-input.js
Normal file
225
server/src/wled_controller/static/js/core/tag-input.js
Normal file
@@ -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 `<div class="card-tags">${tags.map(tag =>
|
||||
`<span class="card-tag">${_escapeHtml(tag)}</span>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
function _escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').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 = `
|
||||
<div class="tag-input-wrap">
|
||||
<div class="tag-input-chips"></div>
|
||||
<input type="text" class="tag-input-field" placeholder="${_escapeHtml(this._placeholder)}" autocomplete="off" spellcheck="false">
|
||||
<div class="tag-input-dropdown"></div>
|
||||
</div>
|
||||
`;
|
||||
this._chipsEl = this._container.querySelector('.tag-input-chips');
|
||||
this._inputEl = this._container.querySelector('.tag-input-field');
|
||||
this._dropdownEl = this._container.querySelector('.tag-input-dropdown');
|
||||
}
|
||||
|
||||
_renderChips() {
|
||||
this._chipsEl.innerHTML = this._tags.map((tag, i) =>
|
||||
`<span class="tag-chip">${_escapeHtml(tag)}<button type="button" class="tag-chip-remove" data-idx="${i}">×</button></span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
// Chip remove buttons
|
||||
this._chipsEl.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.tag-chip-remove');
|
||||
if (!btn) return;
|
||||
const idx = parseInt(btn.dataset.idx, 10);
|
||||
this._tags.splice(idx, 1);
|
||||
this._renderChips();
|
||||
});
|
||||
|
||||
// Input keydown
|
||||
this._inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab') {
|
||||
if (this._dropdownVisible && this._selectedIdx >= 0) {
|
||||
// Select from dropdown
|
||||
e.preventDefault();
|
||||
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
|
||||
if (items[this._selectedIdx]) {
|
||||
this._addTag(items[this._selectedIdx].dataset.tag);
|
||||
}
|
||||
} else if (this._inputEl.value.trim()) {
|
||||
e.preventDefault();
|
||||
this._addTag(this._inputEl.value);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && !this._inputEl.value && this._tags.length) {
|
||||
this._tags.pop();
|
||||
this._renderChips();
|
||||
} else if (e.key === 'ArrowDown' && this._dropdownVisible) {
|
||||
e.preventDefault();
|
||||
this._moveSelection(1);
|
||||
} else if (e.key === 'ArrowUp' && this._dropdownVisible) {
|
||||
e.preventDefault();
|
||||
this._moveSelection(-1);
|
||||
} else if (e.key === 'Escape') {
|
||||
this._hideDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Input typing → autocomplete
|
||||
this._inputEl.addEventListener('input', () => {
|
||||
this._updateDropdown();
|
||||
});
|
||||
|
||||
// Focus → show dropdown
|
||||
this._inputEl.addEventListener('focus', () => {
|
||||
this._updateDropdown();
|
||||
});
|
||||
|
||||
// Blur → hide (with delay so clicks register)
|
||||
this._inputEl.addEventListener('blur', () => {
|
||||
setTimeout(() => this._hideDropdown(), 200);
|
||||
});
|
||||
|
||||
// Dropdown click
|
||||
this._dropdownEl.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault(); // prevent blur
|
||||
const item = e.target.closest('.tag-dropdown-item');
|
||||
if (item) this._addTag(item.dataset.tag);
|
||||
});
|
||||
}
|
||||
|
||||
_addTag(raw) {
|
||||
const tag = raw.toLowerCase().trim().replace(/,/g, '');
|
||||
if (!tag || this._tags.includes(tag)) {
|
||||
this._inputEl.value = '';
|
||||
this._hideDropdown();
|
||||
return;
|
||||
}
|
||||
this._tags.push(tag);
|
||||
this._renderChips();
|
||||
this._inputEl.value = '';
|
||||
this._hideDropdown();
|
||||
invalidateTagsCache();
|
||||
}
|
||||
|
||||
async _updateDropdown() {
|
||||
const query = this._inputEl.value.toLowerCase().trim();
|
||||
const allTags = await fetchAllTags();
|
||||
|
||||
// Filter: exclude already-selected tags, match query
|
||||
const suggestions = allTags
|
||||
.filter(t => !this._tags.includes(t))
|
||||
.filter(t => !query || t.includes(query))
|
||||
.slice(0, 8);
|
||||
|
||||
if (!suggestions.length) {
|
||||
this._hideDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
this._dropdownEl.innerHTML = suggestions.map((tag, i) =>
|
||||
`<div class="tag-dropdown-item${i === 0 ? ' tag-dropdown-active' : ''}" data-tag="${_escapeHtml(tag)}">${_escapeHtml(tag)}</div>`
|
||||
).join('');
|
||||
this._dropdownEl.style.display = 'block';
|
||||
this._dropdownVisible = true;
|
||||
this._selectedIdx = 0;
|
||||
}
|
||||
|
||||
_hideDropdown() {
|
||||
this._dropdownEl.style.display = 'none';
|
||||
this._dropdownVisible = false;
|
||||
this._selectedIdx = -1;
|
||||
}
|
||||
|
||||
_moveSelection(delta) {
|
||||
const items = this._dropdownEl.querySelectorAll('.tag-dropdown-item');
|
||||
if (!items.length) return;
|
||||
items[this._selectedIdx]?.classList.remove('tag-dropdown-active');
|
||||
this._selectedIdx = Math.max(0, Math.min(items.length - 1, this._selectedIdx + delta));
|
||||
items[this._selectedIdx]?.classList.add('tag-dropdown-active');
|
||||
items[this._selectedIdx]?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { API_BASE, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -77,13 +78,12 @@ const _modal = new AdvancedCalibrationModal();
|
||||
|
||||
export async function showAdvancedCalibration(cssId) {
|
||||
try {
|
||||
const [cssResp, psResp] = await Promise.all([
|
||||
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
||||
const [cssSources, psResp] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
]);
|
||||
if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
|
||||
const source = await cssResp.json();
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {};
|
||||
const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
|
||||
|
||||
@@ -168,6 +168,7 @@ export async function saveAdvancedCalibration() {
|
||||
|
||||
if (resp.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
_modal.forceClose();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
|
||||
@@ -17,11 +17,18 @@ import { showToast, showConfirm, lockBody, unlockBody } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_MUSIC, getAudioSourceIcon, ICON_AUDIO_TEMPLATE, ICON_AUDIO_INPUT, ICON_AUDIO_LOOPBACK } from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { TagInput } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
let _audioSourceTagsInput = null;
|
||||
|
||||
class AudioSourceModal extends Modal {
|
||||
constructor() { super('audio-source-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('audio-source-name').value,
|
||||
@@ -31,6 +38,7 @@ class AudioSourceModal extends Modal {
|
||||
audioTemplate: document.getElementById('audio-source-audio-template').value,
|
||||
parent: document.getElementById('audio-source-parent').value,
|
||||
channel: document.getElementById('audio-source-channel').value,
|
||||
tags: JSON.stringify(_audioSourceTagsInput ? _audioSourceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,6 +94,11 @@ export async function showAudioSourceModal(sourceType, editData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_audioSourceTagsInput) { _audioSourceTagsInput.destroy(); _audioSourceTagsInput = null; }
|
||||
_audioSourceTagsInput = new TagInput(document.getElementById('audio-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
||||
|
||||
audioSourceModal.open();
|
||||
audioSourceModal.snapshot();
|
||||
}
|
||||
@@ -115,7 +128,7 @@ export async function saveAudioSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
const payload = { name, source_type: sourceType, description, tags: _audioSourceTagsInput ? _audioSourceTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'multichannel') {
|
||||
const deviceVal = document.getElementById('audio-source-device').value || '-1:1';
|
||||
|
||||
@@ -12,14 +12,21 @@ import { updateTabBadge } from './tabs.js';
|
||||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE, ICON_CLONE } from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { csScenes, createSceneCard } from './scene-presets.js';
|
||||
|
||||
let _automationTagsInput = null;
|
||||
|
||||
class AutomationEditorModal extends Modal {
|
||||
constructor() { super('automation-editor-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('automation-editor-name').value,
|
||||
@@ -29,6 +36,7 @@ class AutomationEditorModal extends Modal {
|
||||
scenePresetId: document.getElementById('automation-scene-id').value,
|
||||
deactivationMode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
|
||||
tags: JSON.stringify(_automationTagsInput ? _automationTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -204,7 +212,8 @@ function createAutomationCard(automation, sceneMap = new Map()) {
|
||||
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
|
||||
${lastActivityMeta}
|
||||
</div>
|
||||
<div class="stream-card-props">${condPills}</div>`,
|
||||
<div class="stream-card-props">${condPills}</div>
|
||||
${renderTagChips(automation.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneAutomation('${automation.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
|
||||
@@ -240,6 +249,8 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue('none');
|
||||
document.getElementById('automation-fallback-scene-group').style.display = 'none';
|
||||
|
||||
let _editorTags = [];
|
||||
|
||||
if (automationId) {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
|
||||
try {
|
||||
@@ -266,6 +277,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(deactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', automation.deactivation_scene_preset_id);
|
||||
_editorTags = automation.tags || [];
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
return;
|
||||
@@ -293,6 +305,7 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
if (_deactivationModeIconSelect) _deactivationModeIconSelect.setValue(cloneDeactMode);
|
||||
_onDeactivationModeChange();
|
||||
_initSceneSelector('automation-fallback-scene-id', cloneData.deactivation_scene_preset_id);
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||||
idInput.value = '';
|
||||
@@ -314,6 +327,12 @@ export async function openAutomationEditor(automationId, cloneData) {
|
||||
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||||
});
|
||||
|
||||
// Tags
|
||||
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
|
||||
_automationTagsInput = new TagInput(document.getElementById('automation-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_automationTagsInput.setValue(_editorTags);
|
||||
|
||||
automationModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -671,6 +690,7 @@ export async function saveAutomationEditor() {
|
||||
scene_preset_id: document.getElementById('automation-scene-id').value || null,
|
||||
deactivation_mode: document.getElementById('automation-deactivation-mode').value,
|
||||
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
|
||||
tags: _automationTagsInput ? _automationTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
const automationId = idInput.value;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
calibrationTestState, EDGE_TEST_COLORS, displaysCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
||||
import { colorStripSourcesCache, devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -231,13 +232,12 @@ export async function closeCalibrationModal() {
|
||||
|
||||
export async function showCSSCalibration(cssId) {
|
||||
try {
|
||||
const [cssResp, devicesResp] = await Promise.all([
|
||||
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
||||
fetchWithAuth('/devices'),
|
||||
const [cssSources, devices] = await Promise.all([
|
||||
colorStripSourcesCache.fetch(),
|
||||
devicesCache.fetch().catch(() => []),
|
||||
]);
|
||||
|
||||
if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const source = await cssResp.json();
|
||||
const source = cssSources.find(s => s.id === cssId);
|
||||
if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
|
||||
const calibration = source.calibration || {
|
||||
}
|
||||
|
||||
@@ -246,7 +246,6 @@ export async function showCSSCalibration(cssId) {
|
||||
document.getElementById('calibration-css-id').value = cssId;
|
||||
|
||||
// Populate device picker for edge test
|
||||
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
|
||||
const testDeviceSelect = document.getElementById('calibration-test-device');
|
||||
testDeviceSelect.innerHTML = '';
|
||||
devices.forEach(d => {
|
||||
@@ -940,6 +939,7 @@ export async function saveCalibration() {
|
||||
}
|
||||
if (response.ok) {
|
||||
showToast(t('calibration.saved'), 'success');
|
||||
if (cssMode) colorStripSourcesCache.invalidate();
|
||||
calibModal.forceClose();
|
||||
if (cssMode) {
|
||||
if (window.loadTargetsTab) window.loadTargetsTab();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { _cachedSyncClocks, audioSourcesCache, streamsCache } from '../core/state.js';
|
||||
import { _cachedSyncClocks, audioSourcesCache, streamsCache, colorStripSourcesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -16,15 +16,28 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { attachProcessPicker } from '../core/process-picker.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import {
|
||||
rgbArrayToHex, hexToRgbArray,
|
||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
||||
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
|
||||
} from './css-gradient-editor.js';
|
||||
|
||||
// Re-export for app.js window global bindings
|
||||
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
|
||||
|
||||
class CSSEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('css-editor-modal');
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
const type = document.getElementById('css-editor-type').value;
|
||||
return {
|
||||
@@ -39,7 +52,7 @@ class CSSEditorModal extends Modal {
|
||||
color: document.getElementById('css-editor-color').value,
|
||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
||||
led_count: document.getElementById('css-editor-led-count').value,
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
|
||||
animation_type: document.getElementById('css-editor-animation-type').value,
|
||||
cycle_colors: JSON.stringify(_colorCycleColors),
|
||||
effect_type: document.getElementById('css-editor-effect-type').value,
|
||||
@@ -67,12 +80,15 @@ class CSSEditorModal extends Modal {
|
||||
notification_filter_list: document.getElementById('css-editor-notification-filter-list').value,
|
||||
notification_app_colors: JSON.stringify(_notificationAppColors),
|
||||
clock_id: document.getElementById('css-editor-clock').value,
|
||||
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cssEditorModal = new CSSEditorModal();
|
||||
|
||||
let _cssTagsInput = null;
|
||||
|
||||
// ── EntitySelect instances for CSS editor ──
|
||||
let _cssPictureSourceEntitySelect = null;
|
||||
let _cssAudioSourceEntitySelect = null;
|
||||
@@ -272,13 +288,7 @@ function _gradientStripHTML(pts, w = 80, h = 16) {
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${stops});flex-shrink:0"></span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a gradient preview from _GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||||
*/
|
||||
function _gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
}
|
||||
/* gradientPresetStripHTML imported from css-gradient-editor.js */
|
||||
|
||||
/* ── Effect / audio palette IconSelect instances ─────────────── */
|
||||
|
||||
@@ -355,8 +365,8 @@ function _ensureGradientPresetIconSelect() {
|
||||
if (!sel) return;
|
||||
const items = [
|
||||
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
||||
...Object.entries(_GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: _gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
|
||||
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
||||
})),
|
||||
];
|
||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||
@@ -468,16 +478,7 @@ function _loadColorCycleState(css) {
|
||||
}
|
||||
|
||||
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
||||
function rgbArrayToHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||||
function hexToRgbArray(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
/* rgbArrayToHex / hexToRgbArray imported from css-gradient-editor.js */
|
||||
|
||||
/* ── Composite layer helpers ──────────────────────────────────── */
|
||||
|
||||
@@ -1090,7 +1091,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
${propsHtml}
|
||||
</div>`,
|
||||
</div>
|
||||
${renderTagChips(source.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneColorStrip('${source.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>
|
||||
@@ -1132,8 +1134,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
const sources = await streamsCache.fetch();
|
||||
|
||||
// Fetch all color strip sources for composite layer dropdowns
|
||||
const cssListResp = await fetchWithAuth('/color-strip-sources');
|
||||
const allCssSources = cssListResp.ok ? ((await cssListResp.json()).sources || []) : [];
|
||||
const allCssSources = await colorStripSourcesCache.fetch().catch(() => []);
|
||||
_compositeAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
|
||||
);
|
||||
@@ -1251,9 +1252,9 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-type-group').style.display = cssId ? 'none' : '';
|
||||
|
||||
if (cssId) {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load color strip source');
|
||||
const css = await resp.json();
|
||||
const cssSources = await colorStripSourcesCache.fetch();
|
||||
const css = cssSources.find(s => s.id === cssId);
|
||||
if (!css) throw new Error('Failed to load color strip source');
|
||||
|
||||
document.getElementById('css-editor-id').value = css.id;
|
||||
document.getElementById('css-editor-name').value = css.name;
|
||||
@@ -1328,6 +1329,15 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
|
||||
document.getElementById('css-editor-notification-effect').onchange = () => _autoGenerateCSSName();
|
||||
|
||||
document.getElementById('css-editor-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
|
||||
const _cssTags = cssId
|
||||
? ((await colorStripSourcesCache.fetch()).find(s => s.id === cssId)?.tags || [])
|
||||
: (cloneData ? (cloneData.tags || []) : []);
|
||||
_cssTagsInput = new TagInput(document.getElementById('css-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_cssTagsInput.setValue(_cssTags);
|
||||
|
||||
cssEditorModal.snapshot();
|
||||
cssEditorModal.open();
|
||||
setTimeout(() => document.getElementById('css-editor-name').focus(), 100);
|
||||
@@ -1374,13 +1384,14 @@ export async function saveCSSEditor() {
|
||||
};
|
||||
if (!cssId) payload.source_type = 'color_cycle';
|
||||
} else if (sourceType === 'gradient') {
|
||||
if (_gradientStops.length < 2) {
|
||||
const gStops = getGradientStops();
|
||||
if (gStops.length < 2) {
|
||||
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
name,
|
||||
stops: _gradientStops.map(s => ({
|
||||
stops: gStops.map(s => ({
|
||||
position: s.position,
|
||||
color: s.color,
|
||||
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
||||
@@ -1496,6 +1507,9 @@ export async function saveCSSEditor() {
|
||||
payload.clock_id = clockVal || null;
|
||||
}
|
||||
|
||||
// Tags
|
||||
payload.tags = _cssTagsInput ? _cssTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (cssId) {
|
||||
@@ -1516,6 +1530,7 @@ export async function saveCSSEditor() {
|
||||
}
|
||||
|
||||
showToast(cssId ? t('color_strip.updated') : t('color_strip.created'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
cssEditorModal.forceClose();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} catch (error) {
|
||||
@@ -1562,9 +1577,9 @@ export function copyEndpointUrl(btn) {
|
||||
|
||||
export async function cloneColorStrip(cssId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load color strip source');
|
||||
const css = await resp.json();
|
||||
const sources = await colorStripSourcesCache.fetch();
|
||||
const css = sources.find(s => s.id === cssId);
|
||||
if (!css) throw new Error('Color strip source not found');
|
||||
showCSSEditor(null, css);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -1585,6 +1600,7 @@ export async function deleteColorStrip(cssId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('color_strip.deleted'), 'success');
|
||||
colorStripSourcesCache.invalidate();
|
||||
if (window.loadTargetsTab) await window.loadTargetsTab();
|
||||
} else {
|
||||
const err = await response.json();
|
||||
@@ -1636,363 +1652,4 @@ export async function stopCSSOverlay(cssId) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
GRADIENT EDITOR
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/**
|
||||
* Internal state: array of stop objects.
|
||||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||
*/
|
||||
let _gradientStops = [];
|
||||
let _gradientSelectedIdx = -1;
|
||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||
|
||||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||
|
||||
function _gradientInterpolate(stops, pos) {
|
||||
if (!stops.length) return [128, 128, 128];
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
if (a.position <= pos && pos <= b.position) {
|
||||
const span = b.position - a.position;
|
||||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||||
const lc = a.colorRight || a.color;
|
||||
const rc = b.color;
|
||||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||||
}
|
||||
}
|
||||
return [128, 128, 128];
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientInit(stops) {
|
||||
_gradientStops = stops.map(s => ({
|
||||
position: parseFloat(s.position ?? 0),
|
||||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||
}));
|
||||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||||
_gradientDragging = null;
|
||||
_gradientSetupTrackClick();
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Presets ──────────────────────────────────────────────────── */
|
||||
|
||||
const _GRADIENT_PRESETS = {
|
||||
rainbow: [
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 0.17, color: [255, 165, 0] },
|
||||
{ position: 0.33, color: [255, 255, 0] },
|
||||
{ position: 0.5, color: [0, 255, 0] },
|
||||
{ position: 0.67, color: [0, 100, 255] },
|
||||
{ position: 0.83, color: [75, 0, 130] },
|
||||
{ position: 1.0, color: [148, 0, 211] },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0.0, color: [255, 60, 0] },
|
||||
{ position: 0.3, color: [255, 120, 20] },
|
||||
{ position: 0.6, color: [200, 40, 80] },
|
||||
{ position: 0.8, color: [120, 20, 120] },
|
||||
{ position: 1.0, color: [40, 10, 60] },
|
||||
],
|
||||
ocean: [
|
||||
{ position: 0.0, color: [0, 10, 40] },
|
||||
{ position: 0.3, color: [0, 60, 120] },
|
||||
{ position: 0.6, color: [0, 140, 180] },
|
||||
{ position: 0.8, color: [100, 220, 240] },
|
||||
{ position: 1.0, color: [200, 240, 255] },
|
||||
],
|
||||
forest: [
|
||||
{ position: 0.0, color: [0, 40, 0] },
|
||||
{ position: 0.3, color: [0, 100, 20] },
|
||||
{ position: 0.6, color: [60, 180, 30] },
|
||||
{ position: 0.8, color: [140, 220, 50] },
|
||||
{ position: 1.0, color: [220, 255, 80] },
|
||||
],
|
||||
fire: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.25, color: [80, 0, 0] },
|
||||
{ position: 0.5, color: [255, 40, 0] },
|
||||
{ position: 0.75, color: [255, 160, 0] },
|
||||
{ position: 1.0, color: [255, 255, 60] },
|
||||
],
|
||||
lava: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.3, color: [120, 0, 0] },
|
||||
{ position: 0.6, color: [255, 60, 0] },
|
||||
{ position: 0.8, color: [255, 160, 40] },
|
||||
{ position: 1.0, color: [255, 255, 120] },
|
||||
],
|
||||
aurora: [
|
||||
{ position: 0.0, color: [0, 20, 40] },
|
||||
{ position: 0.25, color: [0, 200, 100] },
|
||||
{ position: 0.5, color: [0, 100, 200] },
|
||||
{ position: 0.75, color: [120, 0, 200] },
|
||||
{ position: 1.0, color: [0, 200, 140] },
|
||||
],
|
||||
ice: [
|
||||
{ position: 0.0, color: [255, 255, 255] },
|
||||
{ position: 0.3, color: [180, 220, 255] },
|
||||
{ position: 0.6, color: [80, 160, 255] },
|
||||
{ position: 0.85, color: [20, 60, 180] },
|
||||
{ position: 1.0, color: [10, 20, 80] },
|
||||
],
|
||||
warm: [
|
||||
{ position: 0.0, color: [255, 255, 80] },
|
||||
{ position: 0.33, color: [255, 160, 0] },
|
||||
{ position: 0.67, color: [255, 60, 0] },
|
||||
{ position: 1.0, color: [160, 0, 0] },
|
||||
],
|
||||
cool: [
|
||||
{ position: 0.0, color: [0, 255, 200] },
|
||||
{ position: 0.33, color: [0, 120, 255] },
|
||||
{ position: 0.67, color: [60, 0, 255] },
|
||||
{ position: 1.0, color: [120, 0, 180] },
|
||||
],
|
||||
neon: [
|
||||
{ position: 0.0, color: [255, 0, 200] },
|
||||
{ position: 0.25, color: [0, 255, 255] },
|
||||
{ position: 0.5, color: [0, 255, 50] },
|
||||
{ position: 0.75, color: [255, 255, 0] },
|
||||
{ position: 1.0, color: [255, 0, 100] },
|
||||
],
|
||||
pastel: [
|
||||
{ position: 0.0, color: [255, 180, 180] },
|
||||
{ position: 0.2, color: [255, 220, 160] },
|
||||
{ position: 0.4, color: [255, 255, 180] },
|
||||
{ position: 0.6, color: [180, 255, 200] },
|
||||
{ position: 0.8, color: [180, 200, 255] },
|
||||
{ position: 1.0, color: [220, 180, 255] },
|
||||
],
|
||||
};
|
||||
|
||||
export function applyGradientPreset(key) {
|
||||
if (!key || !_GRADIENT_PRESETS[key]) return;
|
||||
gradientInit(_GRADIENT_PRESETS[key]);
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientRenderAll() {
|
||||
_gradientRenderCanvas();
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
}
|
||||
|
||||
function _gradientRenderCanvas() {
|
||||
const canvas = document.getElementById('gradient-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Sync canvas pixel width to its CSS display width
|
||||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const H = canvas.height;
|
||||
const imgData = ctx.createImageData(W, H);
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const pos = W > 1 ? x / (W - 1) : 0;
|
||||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const idx = (y * W + x) * 4;
|
||||
imgData.data[idx] = r;
|
||||
imgData.data[idx + 1] = g;
|
||||
imgData.data[idx + 2] = b;
|
||||
imgData.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
function _gradientRenderMarkers() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
marker.style.left = `${stop.position * 100}%`;
|
||||
marker.style.background = rgbArrayToHex(stop.color);
|
||||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||||
|
||||
marker.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_gradientSelectedIdx = idx;
|
||||
_gradientStartDrag(e, idx);
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
});
|
||||
|
||||
track.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected stop index and reflect it via CSS classes only —
|
||||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||
*/
|
||||
function _gradientSelectStop(idx) {
|
||||
_gradientSelectedIdx = idx;
|
||||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||
}
|
||||
|
||||
function _gradientRenderStopList() {
|
||||
const list = document.getElementById('gradient-stops-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
|
||||
const hasBidir = !!stop.colorRight;
|
||||
const rightColor = stop.colorRight || stop.color;
|
||||
|
||||
row.innerHTML = `
|
||||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||||
title="Left color">
|
||||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||||
<span class="gradient-stop-spacer"></span>
|
||||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||||
`;
|
||||
|
||||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||
|
||||
// Position
|
||||
const posInput = row.querySelector('.gradient-stop-pos');
|
||||
posInput.addEventListener('change', (e) => {
|
||||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
e.target.value = val.toFixed(2);
|
||||
_gradientStops[idx].position = val;
|
||||
gradientRenderAll();
|
||||
});
|
||||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||
|
||||
// Left color
|
||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||
const markers = document.querySelectorAll('.gradient-marker');
|
||||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Bidirectional toggle
|
||||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||
? null
|
||||
: [..._gradientStops[idx].color];
|
||||
_gradientRenderStopList();
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Right color
|
||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Remove
|
||||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_gradientStops.length > 2) {
|
||||
_gradientStops.splice(idx, 1);
|
||||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
}
|
||||
gradientRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientAddStop(position) {
|
||||
if (position === undefined) {
|
||||
// Find the largest gap between adjacent stops and place in the middle
|
||||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||
let maxGap = 0, gapMid = 0.5;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].position - sorted[i].position;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||||
}
|
||||
}
|
||||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||||
}
|
||||
position = Math.min(1, Math.max(0, position));
|
||||
const color = _gradientInterpolate(_gradientStops, position);
|
||||
_gradientStops.push({ position, color, colorRight: null });
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||
|
||||
function _gradientStartDrag(e, idx) {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||
|
||||
const onMove = (me) => {
|
||||
if (!_gradientDragging) return;
|
||||
const { trackRect } = _gradientDragging;
|
||||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||||
gradientRenderAll();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
_gradientDragging = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track || track._gradientClickBound) return;
|
||||
track._gradientClickBound = true;
|
||||
|
||||
track.addEventListener('click', (e) => {
|
||||
if (_gradientDragging) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
// Ignore clicks very close to an existing marker
|
||||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||||
if (!tooClose) {
|
||||
gradientAddStop(Math.round(pos * 100) / 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
/* Gradient editor moved to css-gradient-editor.js */
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Gradient stop editor — canvas preview, draggable markers, stop list, presets.
|
||||
*
|
||||
* Extracted from color-strips.js. Self-contained module that manages
|
||||
* gradient stops state and renders into the CSS editor modal DOM.
|
||||
*/
|
||||
|
||||
import { t } from '../core/i18n.js';
|
||||
|
||||
/* ── Color conversion utilities ───────────────────────────────── */
|
||||
|
||||
export function rgbArrayToHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||||
export function hexToRgbArray(hex) {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
|
||||
/* ── State ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Internal state: array of stop objects.
|
||||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||
*/
|
||||
let _gradientStops = [];
|
||||
let _gradientSelectedIdx = -1;
|
||||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||
|
||||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||||
export function getGradientStops() {
|
||||
return _gradientStops;
|
||||
}
|
||||
|
||||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||
|
||||
function _gradientInterpolate(stops, pos) {
|
||||
if (!stops.length) return [128, 128, 128];
|
||||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||||
|
||||
const last = sorted[sorted.length - 1];
|
||||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const a = sorted[i];
|
||||
const b = sorted[i + 1];
|
||||
if (a.position <= pos && pos <= b.position) {
|
||||
const span = b.position - a.position;
|
||||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||||
const lc = a.colorRight || a.color;
|
||||
const rc = b.color;
|
||||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||||
}
|
||||
}
|
||||
return [128, 128, 128];
|
||||
}
|
||||
|
||||
/* ── Init ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientInit(stops) {
|
||||
_gradientStops = stops.map(s => ({
|
||||
position: parseFloat(s.position ?? 0),
|
||||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||
}));
|
||||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||||
_gradientDragging = null;
|
||||
_gradientSetupTrackClick();
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Presets ──────────────────────────────────────────────────── */
|
||||
|
||||
export const GRADIENT_PRESETS = {
|
||||
rainbow: [
|
||||
{ position: 0.0, color: [255, 0, 0] },
|
||||
{ position: 0.17, color: [255, 165, 0] },
|
||||
{ position: 0.33, color: [255, 255, 0] },
|
||||
{ position: 0.5, color: [0, 255, 0] },
|
||||
{ position: 0.67, color: [0, 100, 255] },
|
||||
{ position: 0.83, color: [75, 0, 130] },
|
||||
{ position: 1.0, color: [148, 0, 211] },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0.0, color: [255, 60, 0] },
|
||||
{ position: 0.3, color: [255, 120, 20] },
|
||||
{ position: 0.6, color: [200, 40, 80] },
|
||||
{ position: 0.8, color: [120, 20, 120] },
|
||||
{ position: 1.0, color: [40, 10, 60] },
|
||||
],
|
||||
ocean: [
|
||||
{ position: 0.0, color: [0, 10, 40] },
|
||||
{ position: 0.3, color: [0, 60, 120] },
|
||||
{ position: 0.6, color: [0, 140, 180] },
|
||||
{ position: 0.8, color: [100, 220, 240] },
|
||||
{ position: 1.0, color: [200, 240, 255] },
|
||||
],
|
||||
forest: [
|
||||
{ position: 0.0, color: [0, 40, 0] },
|
||||
{ position: 0.3, color: [0, 100, 20] },
|
||||
{ position: 0.6, color: [60, 180, 30] },
|
||||
{ position: 0.8, color: [140, 220, 50] },
|
||||
{ position: 1.0, color: [220, 255, 80] },
|
||||
],
|
||||
fire: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.25, color: [80, 0, 0] },
|
||||
{ position: 0.5, color: [255, 40, 0] },
|
||||
{ position: 0.75, color: [255, 160, 0] },
|
||||
{ position: 1.0, color: [255, 255, 60] },
|
||||
],
|
||||
lava: [
|
||||
{ position: 0.0, color: [0, 0, 0] },
|
||||
{ position: 0.3, color: [120, 0, 0] },
|
||||
{ position: 0.6, color: [255, 60, 0] },
|
||||
{ position: 0.8, color: [255, 160, 40] },
|
||||
{ position: 1.0, color: [255, 255, 120] },
|
||||
],
|
||||
aurora: [
|
||||
{ position: 0.0, color: [0, 20, 40] },
|
||||
{ position: 0.25, color: [0, 200, 100] },
|
||||
{ position: 0.5, color: [0, 100, 200] },
|
||||
{ position: 0.75, color: [120, 0, 200] },
|
||||
{ position: 1.0, color: [0, 200, 140] },
|
||||
],
|
||||
ice: [
|
||||
{ position: 0.0, color: [255, 255, 255] },
|
||||
{ position: 0.3, color: [180, 220, 255] },
|
||||
{ position: 0.6, color: [80, 160, 255] },
|
||||
{ position: 0.85, color: [20, 60, 180] },
|
||||
{ position: 1.0, color: [10, 20, 80] },
|
||||
],
|
||||
warm: [
|
||||
{ position: 0.0, color: [255, 255, 80] },
|
||||
{ position: 0.33, color: [255, 160, 0] },
|
||||
{ position: 0.67, color: [255, 60, 0] },
|
||||
{ position: 1.0, color: [160, 0, 0] },
|
||||
],
|
||||
cool: [
|
||||
{ position: 0.0, color: [0, 255, 200] },
|
||||
{ position: 0.33, color: [0, 120, 255] },
|
||||
{ position: 0.67, color: [60, 0, 255] },
|
||||
{ position: 1.0, color: [120, 0, 180] },
|
||||
],
|
||||
neon: [
|
||||
{ position: 0.0, color: [255, 0, 200] },
|
||||
{ position: 0.25, color: [0, 255, 255] },
|
||||
{ position: 0.5, color: [0, 255, 50] },
|
||||
{ position: 0.75, color: [255, 255, 0] },
|
||||
{ position: 1.0, color: [255, 0, 100] },
|
||||
],
|
||||
pastel: [
|
||||
{ position: 0.0, color: [255, 180, 180] },
|
||||
{ position: 0.2, color: [255, 220, 160] },
|
||||
{ position: 0.4, color: [255, 255, 180] },
|
||||
{ position: 0.6, color: [180, 255, 200] },
|
||||
{ position: 0.8, color: [180, 200, 255] },
|
||||
{ position: 1.0, color: [220, 180, 255] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||||
*/
|
||||
export function gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||||
}
|
||||
|
||||
export function applyGradientPreset(key) {
|
||||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||||
gradientInit(GRADIENT_PRESETS[key]);
|
||||
}
|
||||
|
||||
/* ── Render ───────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientRenderAll() {
|
||||
_gradientRenderCanvas();
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
}
|
||||
|
||||
function _gradientRenderCanvas() {
|
||||
const canvas = document.getElementById('gradient-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Sync canvas pixel width to its CSS display width
|
||||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||
if (canvas.width !== W) canvas.width = W;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const H = canvas.height;
|
||||
const imgData = ctx.createImageData(W, H);
|
||||
|
||||
for (let x = 0; x < W; x++) {
|
||||
const pos = W > 1 ? x / (W - 1) : 0;
|
||||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||||
for (let y = 0; y < H; y++) {
|
||||
const idx = (y * W + x) * 4;
|
||||
imgData.data[idx] = r;
|
||||
imgData.data[idx + 1] = g;
|
||||
imgData.data[idx + 2] = b;
|
||||
imgData.data[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
}
|
||||
|
||||
function _gradientRenderMarkers() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
track.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
marker.style.left = `${stop.position * 100}%`;
|
||||
marker.style.background = rgbArrayToHex(stop.color);
|
||||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||||
|
||||
marker.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_gradientSelectedIdx = idx;
|
||||
_gradientStartDrag(e, idx);
|
||||
_gradientRenderMarkers();
|
||||
_gradientRenderStopList();
|
||||
});
|
||||
|
||||
track.appendChild(marker);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the selected stop index and reflect it via CSS classes only —
|
||||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||
*/
|
||||
function _gradientSelectStop(idx) {
|
||||
_gradientSelectedIdx = idx;
|
||||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||
}
|
||||
|
||||
function _gradientRenderStopList() {
|
||||
const list = document.getElementById('gradient-stops-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '';
|
||||
|
||||
_gradientStops.forEach((stop, idx) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||
|
||||
const hasBidir = !!stop.colorRight;
|
||||
const rightColor = stop.colorRight || stop.color;
|
||||
|
||||
row.innerHTML = `
|
||||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||||
title="Left color">
|
||||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||||
<span class="gradient-stop-spacer"></span>
|
||||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||||
`;
|
||||
|
||||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||
|
||||
// Position
|
||||
const posInput = row.querySelector('.gradient-stop-pos');
|
||||
posInput.addEventListener('change', (e) => {
|
||||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||
e.target.value = val.toFixed(2);
|
||||
_gradientStops[idx].position = val;
|
||||
gradientRenderAll();
|
||||
});
|
||||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||
|
||||
// Left color
|
||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||
const markers = document.querySelectorAll('.gradient-marker');
|
||||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Bidirectional toggle
|
||||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||
? null
|
||||
: [..._gradientStops[idx].color];
|
||||
_gradientRenderStopList();
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Right color
|
||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||
_gradientRenderCanvas();
|
||||
});
|
||||
|
||||
// Remove
|
||||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_gradientStops.length > 2) {
|
||||
_gradientStops.splice(idx, 1);
|
||||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
}
|
||||
gradientRenderAll();
|
||||
}
|
||||
});
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||
|
||||
export function gradientAddStop(position) {
|
||||
if (position === undefined) {
|
||||
// Find the largest gap between adjacent stops and place in the middle
|
||||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||
let maxGap = 0, gapMid = 0.5;
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const gap = sorted[i + 1].position - sorted[i].position;
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap;
|
||||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||||
}
|
||||
}
|
||||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||||
}
|
||||
position = Math.min(1, Math.max(0, position));
|
||||
const color = _gradientInterpolate(_gradientStops, position);
|
||||
_gradientStops.push({ position, color, colorRight: null });
|
||||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||
gradientRenderAll();
|
||||
}
|
||||
|
||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||
|
||||
function _gradientStartDrag(e, idx) {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track) return;
|
||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||
|
||||
const onMove = (me) => {
|
||||
if (!_gradientDragging) return;
|
||||
const { trackRect } = _gradientDragging;
|
||||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||||
gradientRenderAll();
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
_gradientDragging = null;
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
/* ── Track click → add stop ───────────────────────────────────── */
|
||||
|
||||
function _gradientSetupTrackClick() {
|
||||
const track = document.getElementById('gradient-markers-track');
|
||||
if (!track || track._gradientClickBound) return;
|
||||
track._gradientClickBound = true;
|
||||
|
||||
track.addEventListener('click', (e) => {
|
||||
if (_gradientDragging) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
// Ignore clicks very close to an existing marker
|
||||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||||
if (!tooClose) {
|
||||
gradientAddStop(Math.round(pos * 100) / 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Dashboard — real-time target status overview.
|
||||
*/
|
||||
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
|
||||
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
|
||||
@@ -418,27 +418,23 @@ export async function loadDashboard(forceFullRender = false) {
|
||||
|
||||
try {
|
||||
// Fire all requests in a single batch to avoid sequential RTTs
|
||||
const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/automations').catch(() => null),
|
||||
fetchWithAuth('/devices').catch(() => null),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
devicesCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states').catch(() => null),
|
||||
fetchWithAuth('/output-targets/batch/metrics').catch(() => null),
|
||||
loadScenePresets(),
|
||||
fetchWithAuth('/sync-clocks').catch(() => null),
|
||||
]);
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] };
|
||||
const automations = automationsData.automations || [];
|
||||
const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] };
|
||||
const devicesMap = {};
|
||||
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
|
||||
const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] };
|
||||
for (const d of devicesArr) { devicesMap[d.id] = d; }
|
||||
const cssSourceMap = {};
|
||||
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
|
||||
for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; }
|
||||
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
|
||||
const syncClocks = syncClocksData.clocks || [];
|
||||
|
||||
@@ -782,14 +778,13 @@ export async function dashboardStopTarget(targetId) {
|
||||
|
||||
export async function dashboardStopAll() {
|
||||
try {
|
||||
const [targetsResp, statesResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states'),
|
||||
]);
|
||||
const data = await targetsResp.json();
|
||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||
const states = statesData.states || {};
|
||||
const running = (data.targets || []).filter(t => states[t.id]?.processing);
|
||||
const running = allTargets.filter(t => states[t.id]?.processing);
|
||||
await Promise.all(running.map(t =>
|
||||
fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
|
||||
));
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
_discoveryCache, set_discoveryCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, escapeHtml } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
@@ -463,6 +464,7 @@ export async function handleAddDevice(event) {
|
||||
const result = await response.json();
|
||||
console.log('Device added successfully:', result);
|
||||
showToast(t('device_discovery.added'), 'success');
|
||||
devicesCache.invalidate();
|
||||
addDeviceModal.forceClose();
|
||||
if (typeof window.loadDevices === 'function') await window.loadDevices();
|
||||
if (!localStorage.getItem('deviceTutorialSeen')) {
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
_deviceBrightnessCache, updateDeviceBrightness,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice } from '../core/api.js';
|
||||
import { devicesCache } from '../core/state.js';
|
||||
import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode } from './device-discovery.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
|
||||
let _deviceTagsInput = null;
|
||||
|
||||
class DeviceSettingsModal extends Modal {
|
||||
constructor() { super('device-settings-modal'); }
|
||||
@@ -30,6 +34,7 @@ class DeviceSettingsModal extends Modal {
|
||||
send_latency: document.getElementById('settings-send-latency')?.value || '0',
|
||||
zones: JSON.stringify(_getCheckedZones('settings-zone-list')),
|
||||
zoneMode: _getZoneMode('settings-zone-mode'),
|
||||
tags: JSON.stringify(_deviceTagsInput ? _deviceTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,7 +130,8 @@ export function createDeviceCard(device) {
|
||||
onchange="saveCardBrightness('${device.id}', this.value)"
|
||||
title="${_deviceBrightnessCache[device.id] != null ? Math.round(_deviceBrightnessCache[device.id] / 255 * 100) + '%' : '...'}"
|
||||
${_deviceBrightnessCache[device.id] == null ? 'disabled' : ''}>
|
||||
</div>` : ''}`,
|
||||
</div>` : ''}
|
||||
${renderTagChips(device.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
${ICON_SETTINGS}
|
||||
@@ -165,6 +171,7 @@ export async function removeDevice(deviceId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('device.removed'), 'success');
|
||||
devicesCache.invalidate();
|
||||
window.loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
@@ -323,6 +330,13 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_deviceTagsInput) _deviceTagsInput.destroy();
|
||||
_deviceTagsInput = new TagInput(document.getElementById('device-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_deviceTagsInput.setValue(device.tags || []);
|
||||
|
||||
settingsModal.snapshot();
|
||||
settingsModal.open();
|
||||
|
||||
@@ -338,7 +352,7 @@ export async function showSettings(deviceId) {
|
||||
}
|
||||
|
||||
export function isSettingsDirty() { return settingsModal.isDirty(); }
|
||||
export function forceCloseDeviceSettingsModal() { settingsModal.forceClose(); }
|
||||
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
|
||||
export function closeDeviceSettingsModal() { settingsModal.close(); }
|
||||
|
||||
export async function saveDeviceSettings() {
|
||||
@@ -356,6 +370,7 @@ export async function saveDeviceSettings() {
|
||||
name, url,
|
||||
auto_shutdown: document.getElementById('settings-auto-shutdown').checked,
|
||||
state_check_interval: parseInt(document.getElementById('settings-health-interval').value, 10) || 30,
|
||||
tags: _deviceTagsInput ? _deviceTagsInput.getValue() : [],
|
||||
};
|
||||
const ledCountInput = document.getElementById('settings-led-count');
|
||||
if (settingsModal.capabilities.includes('manual_led_count') && ledCountInput.value) {
|
||||
@@ -386,6 +401,7 @@ export async function saveDeviceSettings() {
|
||||
}
|
||||
|
||||
showToast(t('settings.saved'), 'success');
|
||||
devicesCache.invalidate();
|
||||
settingsModal.forceClose();
|
||||
window.loadDevices();
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
kcWebSockets,
|
||||
PATTERN_RECT_BORDERS,
|
||||
_cachedValueSources, valueSourcesCache, streamsCache,
|
||||
outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -21,9 +22,12 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _kcTagsInput = null;
|
||||
|
||||
class KCEditorModal extends Modal {
|
||||
constructor() {
|
||||
super('kc-editor-modal');
|
||||
@@ -38,6 +42,7 @@ class KCEditorModal extends Modal {
|
||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||
patternTemplateId: document.getElementById('kc-editor-pattern-template').value,
|
||||
brightness_vs: document.getElementById('kc-editor-brightness-vs').value,
|
||||
tags: JSON.stringify(_kcTagsInput ? _kcTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -228,6 +233,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
|
||||
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(target.tags)}
|
||||
<div class="brightness-control" data-kc-brightness-wrap="${target.id}">
|
||||
<input type="range" class="brightness-slider" min="0" max="255"
|
||||
value="${brightnessInt}" data-kc-brightness="${target.id}"
|
||||
@@ -503,12 +509,11 @@ function _populateKCBrightnessVsDropdown(selectedId = '') {
|
||||
export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load sources, pattern templates, and value sources in parallel
|
||||
const [sources, patResp, valueSources] = await Promise.all([
|
||||
const [sources, patTemplates, valueSources] = await Promise.all([
|
||||
streamsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
patternTemplatesCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('kc-editor-source');
|
||||
@@ -538,10 +543,12 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_ensureSourceEntitySelect(sources);
|
||||
_ensurePatternEntitySelect(patTemplates);
|
||||
|
||||
let _editorTags = [];
|
||||
if (targetId) {
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
const kcSettings = target.key_colors_settings || {};
|
||||
|
||||
document.getElementById('kc-editor-id').value = target.id;
|
||||
@@ -557,6 +564,7 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
_populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || '');
|
||||
document.getElementById('kc-editor-title').innerHTML = `${ICON_PALETTE} ${t('kc.edit')}`;
|
||||
} else if (cloneData) {
|
||||
_editorTags = cloneData.tags || [];
|
||||
const kcSettings = cloneData.key_colors_settings || {};
|
||||
document.getElementById('kc-editor-id').value = '';
|
||||
document.getElementById('kc-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
@@ -593,6 +601,13 @@ export async function showKCEditor(targetId = null, cloneData = null) {
|
||||
patSelect.onchange = () => _autoGenerateKCName();
|
||||
if (!targetId && !cloneData) _autoGenerateKCName();
|
||||
|
||||
// Tags
|
||||
if (_kcTagsInput) _kcTagsInput.destroy();
|
||||
_kcTagsInput = new TagInput(document.getElementById('kc-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_kcTagsInput.setValue(_editorTags);
|
||||
|
||||
kcEditorModal.snapshot();
|
||||
kcEditorModal.open();
|
||||
|
||||
@@ -614,6 +629,7 @@ export async function closeKCEditorModal() {
|
||||
}
|
||||
|
||||
export function forceCloseKCEditorModal() {
|
||||
if (_kcTagsInput) { _kcTagsInput.destroy(); _kcTagsInput = null; }
|
||||
kcEditorModal.forceClose();
|
||||
set_kcNameManuallyEdited(false);
|
||||
}
|
||||
@@ -641,6 +657,7 @@ export async function saveKCEditor() {
|
||||
const payload = {
|
||||
name,
|
||||
picture_source_id: sourceId,
|
||||
tags: _kcTagsInput ? _kcTagsInput.getValue() : [],
|
||||
key_colors_settings: {
|
||||
fps,
|
||||
interpolation_mode: interpolation,
|
||||
@@ -671,6 +688,7 @@ export async function saveKCEditor() {
|
||||
}
|
||||
|
||||
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
kcEditorModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
@@ -683,9 +701,9 @@ export async function saveKCEditor() {
|
||||
|
||||
export async function cloneKCTarget(targetId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/output-targets/${targetId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
const targets = await outputTargetsCache.fetch();
|
||||
const target = targets.find(t => t.id === targetId);
|
||||
if (!target) throw new Error('Target not found');
|
||||
showKCEditor(null, target);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -704,6 +722,7 @@ export async function deleteKCTarget(targetId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('kc.deleted'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
|
||||
@@ -16,14 +16,17 @@ import {
|
||||
streamsCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||
import { patternTemplatesCache } from '../core/state.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { Modal } from '../core/modal.js';
|
||||
import { getPictureSourceIcon, ICON_PATTERN_TEMPLATE, ICON_CLONE, ICON_EDIT } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
|
||||
let _patternBgEntitySelect = null;
|
||||
let _patternTagsInput = null;
|
||||
|
||||
class PatternTemplateModal extends Modal {
|
||||
constructor() {
|
||||
@@ -35,10 +38,12 @@ class PatternTemplateModal extends Modal {
|
||||
name: document.getElementById('pattern-template-name').value,
|
||||
description: document.getElementById('pattern-template-description').value,
|
||||
rectangles: JSON.stringify(patternEditorRects),
|
||||
tags: JSON.stringify(_patternTagsInput ? _patternTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
setPatternEditorRects([]);
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternEditorBgImage(null);
|
||||
@@ -70,7 +75,8 @@ export function createPatternTemplateCard(pt) {
|
||||
${desc}
|
||||
<div class="stream-card-props">
|
||||
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||
</div>`,
|
||||
</div>
|
||||
${renderTagChips(pt.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="clonePatternTemplate('${pt.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||
@@ -109,6 +115,8 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorSelectedIdx(-1);
|
||||
setPatternCanvasDragMode(null);
|
||||
|
||||
let _editorTags = [];
|
||||
|
||||
if (templateId) {
|
||||
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
@@ -119,12 +127,14 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
document.getElementById('pattern-template-description').value = tmpl.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.edit')}`;
|
||||
setPatternEditorRects((tmpl.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = tmpl.tags || [];
|
||||
} else if (cloneData) {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
document.getElementById('pattern-template-description').value = cloneData.description || '';
|
||||
document.getElementById('pattern-template-modal-title').innerHTML = `${ICON_PATTERN_TEMPLATE} ${t('pattern.add')}`;
|
||||
setPatternEditorRects((cloneData.rectangles || []).map(r => ({ ...r })));
|
||||
_editorTags = cloneData.tags || [];
|
||||
} else {
|
||||
document.getElementById('pattern-template-id').value = '';
|
||||
document.getElementById('pattern-template-name').value = '';
|
||||
@@ -133,6 +143,11 @@ export async function showPatternTemplateEditor(templateId = null, cloneData = n
|
||||
setPatternEditorRects([]);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_patternTagsInput) { _patternTagsInput.destroy(); _patternTagsInput = null; }
|
||||
_patternTagsInput = new TagInput(document.getElementById('pattern-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_patternTagsInput.setValue(_editorTags);
|
||||
|
||||
patternModal.snapshot();
|
||||
|
||||
renderPatternRectList();
|
||||
@@ -177,6 +192,7 @@ export async function savePatternTemplate() {
|
||||
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
})),
|
||||
description: description || null,
|
||||
tags: _patternTagsInput ? _patternTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -197,6 +213,7 @@ export async function savePatternTemplate() {
|
||||
}
|
||||
|
||||
showToast(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
patternModal.forceClose();
|
||||
// Use window.* to avoid circular import with targets.js
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
@@ -209,9 +226,9 @@ export async function savePatternTemplate() {
|
||||
|
||||
export async function clonePatternTemplate(templateId) {
|
||||
try {
|
||||
const resp = await fetchWithAuth(`/pattern-templates/${templateId}`);
|
||||
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||
const tmpl = await resp.json();
|
||||
const templates = await patternTemplatesCache.fetch();
|
||||
const tmpl = templates.find(t => t.id === templateId);
|
||||
if (!tmpl) throw new Error('Pattern template not found');
|
||||
showPatternTemplateEditor(null, tmpl);
|
||||
} catch (error) {
|
||||
if (error.isAuth) return;
|
||||
@@ -229,6 +246,7 @@ export async function deletePatternTemplate(templateId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('pattern.deleted'), 'success');
|
||||
patternTemplatesCache.invalidate();
|
||||
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
|
||||
@@ -11,15 +11,20 @@ import { CardSection } from '../core/card-sections.js';
|
||||
import {
|
||||
ICON_SCENE, ICON_CAPTURE, ICON_START, ICON_EDIT, ICON_REFRESH, ICON_TARGET, ICON_CLONE,
|
||||
} from '../core/icons.js';
|
||||
import { scenePresetsCache } from '../core/state.js';
|
||||
import { scenePresetsCache, outputTargetsCache } from '../core/state.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { cardColorStyle, cardColorButton } from '../core/card-colors.js';
|
||||
import { EntityPalette } from '../core/entity-palette.js';
|
||||
|
||||
let _editingId = null;
|
||||
let _allTargets = []; // fetched on capture open
|
||||
let _sceneTagsInput = null;
|
||||
|
||||
class ScenePresetEditorModal extends Modal {
|
||||
constructor() { super('scene-preset-editor-modal'); }
|
||||
onForceClose() {
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
}
|
||||
snapshotValues() {
|
||||
const items = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId).sort().join(',');
|
||||
@@ -27,6 +32,7 @@ class ScenePresetEditorModal extends Modal {
|
||||
name: document.getElementById('scene-preset-editor-name').value,
|
||||
description: document.getElementById('scene-preset-editor-description').value,
|
||||
targets: items,
|
||||
tags: JSON.stringify(_sceneTagsInput ? _sceneTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -61,6 +67,7 @@ export function createSceneCard(preset) {
|
||||
${meta.map(m => `<span class="stream-card-prop">${m}</span>`).join('')}
|
||||
${updated ? `<span class="stream-card-prop">${updated}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(preset.tags)}
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="cloneScenePreset('${preset.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editScenePreset('${preset.id}')" title="${t('scenes.edit')}">${ICON_EDIT}</button>
|
||||
@@ -129,15 +136,15 @@ export async function openScenePresetCapture() {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue([]);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
@@ -164,10 +171,7 @@ export async function editScenePreset(presetId) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
|
||||
// Pre-add targets already in the preset
|
||||
const presetTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
@@ -181,10 +185,13 @@ export async function editScenePreset(presetId) {
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue(preset.tags || []);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
@@ -202,6 +209,8 @@ export async function saveScenePreset() {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = _sceneTagsInput ? _sceneTagsInput.getValue() : [];
|
||||
|
||||
try {
|
||||
let resp;
|
||||
if (_editingId) {
|
||||
@@ -209,14 +218,14 @@ export async function saveScenePreset() {
|
||||
.map(el => el.dataset.targetId);
|
||||
resp = await fetchWithAuth(`/scene-presets/${_editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description, target_ids }),
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
} else {
|
||||
const target_ids = [...document.querySelectorAll('#scene-target-list .scene-target-item')]
|
||||
.map(el => el.dataset.targetId);
|
||||
resp = await fetchWithAuth('/scene-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description, target_ids }),
|
||||
body: JSON.stringify({ name, description, target_ids, tags }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -367,10 +376,7 @@ export async function cloneScenePreset(presetId) {
|
||||
selectorGroup.style.display = '';
|
||||
targetList.innerHTML = '';
|
||||
try {
|
||||
const resp = await fetchWithAuth('/output-targets');
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_allTargets = data.targets || [];
|
||||
_allTargets = await outputTargetsCache.fetch().catch(() => []);
|
||||
|
||||
// Pre-add targets from the cloned preset
|
||||
const clonedTargetIds = (preset.targets || []).map(pt => pt.target_id || pt.id);
|
||||
@@ -384,10 +390,13 @@ export async function cloneScenePreset(presetId) {
|
||||
targetList.appendChild(item);
|
||||
}
|
||||
_refreshTargetSelect();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (_sceneTagsInput) { _sceneTagsInput.destroy(); _sceneTagsInput = null; }
|
||||
_sceneTagsInput = new TagInput(document.getElementById('scene-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_sceneTagsInput.setValue(preset.tags || []);
|
||||
|
||||
scenePresetModal.open();
|
||||
scenePresetModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -48,10 +48,17 @@ import {
|
||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import * as P from '../core/icon-paths.js';
|
||||
|
||||
// ── TagInput instances for modals ──
|
||||
let _captureTemplateTagsInput = null;
|
||||
let _streamTagsInput = null;
|
||||
let _ppTemplateTagsInput = null;
|
||||
let _audioTemplateTagsInput = null;
|
||||
|
||||
// ── Card section instances ──
|
||||
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
|
||||
const csRawTemplates = new CardSection('raw-templates', { titleKey: 'templates.title', gridClass: 'templates-grid', addCardOnclick: "showAddTemplateModal()", keyAttr: 'data-template-id' });
|
||||
@@ -77,6 +84,7 @@ class CaptureTemplateModal extends Modal {
|
||||
name: document.getElementById('template-name').value,
|
||||
description: document.getElementById('template-description').value,
|
||||
engine: document.getElementById('template-engine').value,
|
||||
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
document.querySelectorAll('[data-config-key]').forEach(field => {
|
||||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||||
@@ -85,6 +93,7 @@ class CaptureTemplateModal extends Modal {
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
setCurrentEditingTemplateId(null);
|
||||
set_templateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -104,10 +113,12 @@ class StreamEditorModal extends Modal {
|
||||
source: document.getElementById('stream-source').value,
|
||||
ppTemplate: document.getElementById('stream-pp-template').value,
|
||||
imageSource: document.getElementById('stream-image-source').value,
|
||||
tags: JSON.stringify(_streamTagsInput ? _streamTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
document.getElementById('stream-type').disabled = false;
|
||||
set_streamNameManuallyEdited(false);
|
||||
}
|
||||
@@ -121,10 +132,12 @@ class PPTemplateEditorModal extends Modal {
|
||||
name: document.getElementById('pp-template-name').value,
|
||||
description: document.getElementById('pp-template-description').value,
|
||||
filters: JSON.stringify(_modalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
|
||||
tags: JSON.stringify(_ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
set_modalFilters([]);
|
||||
set_ppTemplateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -138,6 +151,7 @@ class AudioTemplateModal extends Modal {
|
||||
name: document.getElementById('audio-template-name').value,
|
||||
description: document.getElementById('audio-template-description').value,
|
||||
engine: document.getElementById('audio-template-engine').value,
|
||||
tags: JSON.stringify(_audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : []),
|
||||
};
|
||||
document.querySelectorAll('#audio-engine-config-fields [data-config-key]').forEach(field => {
|
||||
vals['cfg_' + field.dataset.configKey] = field.value;
|
||||
@@ -146,6 +160,7 @@ class AudioTemplateModal extends Modal {
|
||||
}
|
||||
|
||||
onForceClose() {
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
setCurrentEditingAudioTemplateId(null);
|
||||
set_audioTemplateNameManuallyEdited(false);
|
||||
}
|
||||
@@ -194,6 +209,11 @@ export async function showAddTemplateModal(cloneData = null) {
|
||||
populateEngineConfig(cloneData.engine_config);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
templateModal.open();
|
||||
templateModal.snapshot();
|
||||
}
|
||||
@@ -221,6 +241,11 @@ export async function editTemplate(templateId) {
|
||||
if (testResults) testResults.style.display = 'none';
|
||||
document.getElementById('template-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
|
||||
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_captureTemplateTagsInput.setValue(template.tags || []);
|
||||
|
||||
templateModal.open();
|
||||
templateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -611,7 +636,7 @@ export async function saveTemplate() {
|
||||
const description = document.getElementById('template-description').value.trim();
|
||||
const engineConfig = collectEngineConfig();
|
||||
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
@@ -813,6 +838,11 @@ export async function showAddAudioTemplateModal(cloneData = null) {
|
||||
populateAudioEngineConfig(cloneData.engine_config);
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
}
|
||||
@@ -836,6 +866,11 @@ export async function editAudioTemplate(templateId) {
|
||||
|
||||
document.getElementById('audio-template-error').style.display = 'none';
|
||||
|
||||
// Tags
|
||||
if (_audioTemplateTagsInput) { _audioTemplateTagsInput.destroy(); _audioTemplateTagsInput = null; }
|
||||
_audioTemplateTagsInput = new TagInput(document.getElementById('audio-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_audioTemplateTagsInput.setValue(template.tags || []);
|
||||
|
||||
audioTemplateModal.open();
|
||||
audioTemplateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -861,7 +896,7 @@ export async function saveAudioTemplate() {
|
||||
const description = document.getElementById('audio-template-description').value.trim();
|
||||
const engineConfig = collectAudioEngineConfig();
|
||||
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null };
|
||||
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _audioTemplateTagsInput ? _audioTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
@@ -1235,6 +1270,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
|
||||
</div>
|
||||
${detailsHtml}
|
||||
${renderTagChips(stream.tags)}
|
||||
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">${ICON_TEST}</button>
|
||||
@@ -1261,6 +1297,7 @@ function renderPictureSourcesList(streams) {
|
||||
<span class="stream-card-prop" title="${t('templates.engine')}">${getEngineIcon(template.engine_type)} ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(template.tags)}
|
||||
${configEntries.length > 0 ? `
|
||||
<div class="template-config-collapse">
|
||||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('templates.config.show')}</button>
|
||||
@@ -1302,7 +1339,8 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
${filterChainHtml}`,
|
||||
${filterChainHtml}
|
||||
${renderTagChips(tmpl.tags)}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestPPTemplateModal('${tmpl.id}')" title="${t('postprocessing.test.title')}">${ICON_TEST}</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="clonePPTemplate('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||
@@ -1367,6 +1405,7 @@ function renderPictureSourcesList(streams) {
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${renderTagChips(src.tags)}
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="testAudioSource('${src.id}')" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
||||
@@ -1392,6 +1431,7 @@ function renderPictureSourcesList(streams) {
|
||||
<span class="stream-card-prop" title="${t('audio_template.engine')}">${ICON_AUDIO_TEMPLATE} ${template.engine_type.toUpperCase()}</span>
|
||||
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('audio_template.config.show')}">${ICON_WRENCH} ${configEntries.length}</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(template.tags)}
|
||||
${configEntries.length > 0 ? `
|
||||
<div class="template-config-collapse">
|
||||
<button type="button" class="template-config-toggle" onclick="this.parentElement.classList.toggle('open')">${t('audio_template.config.show')}</button>
|
||||
@@ -1563,6 +1603,12 @@ export async function showAddStreamModal(presetType, cloneData = null) {
|
||||
}
|
||||
|
||||
_showStreamModalLoading(false);
|
||||
|
||||
// Tags
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_streamTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
streamModal.snapshot();
|
||||
}
|
||||
|
||||
@@ -1616,6 +1662,12 @@ export async function editStream(streamId) {
|
||||
}
|
||||
|
||||
_showStreamModalLoading(false);
|
||||
|
||||
// Tags
|
||||
if (_streamTagsInput) { _streamTagsInput.destroy(); _streamTagsInput = null; }
|
||||
_streamTagsInput = new TagInput(document.getElementById('stream-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_streamTagsInput.setValue(stream.tags || []);
|
||||
|
||||
streamModal.snapshot();
|
||||
} catch (error) {
|
||||
console.error('Error loading stream:', error);
|
||||
@@ -1772,7 +1824,7 @@ export async function saveStream() {
|
||||
|
||||
if (!name) { showToast(t('streams.error.required'), 'error'); return; }
|
||||
|
||||
const payload = { name, description: description || null };
|
||||
const payload = { name, description: description || null, tags: _streamTagsInput ? _streamTagsInput.getValue() : [] };
|
||||
if (!streamId) payload.stream_type = streamType;
|
||||
|
||||
if (streamType === 'raw') {
|
||||
@@ -2429,6 +2481,11 @@ export async function showAddPPTemplateModal(cloneData = null) {
|
||||
document.getElementById('pp-template-description').value = cloneData.description || '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_ppTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
|
||||
|
||||
ppTemplateModal.open();
|
||||
ppTemplateModal.snapshot();
|
||||
}
|
||||
@@ -2455,6 +2512,11 @@ export async function editPPTemplate(templateId) {
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
// Tags
|
||||
if (_ppTemplateTagsInput) { _ppTemplateTagsInput.destroy(); _ppTemplateTagsInput = null; }
|
||||
_ppTemplateTagsInput = new TagInput(document.getElementById('pp-template-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_ppTemplateTagsInput.setValue(tmpl.tags || []);
|
||||
|
||||
ppTemplateModal.open();
|
||||
ppTemplateModal.snapshot();
|
||||
} catch (error) {
|
||||
@@ -2471,7 +2533,7 @@ export async function savePPTemplate() {
|
||||
|
||||
if (!name) { showToast(t('postprocessing.error.required'), 'error'); return; }
|
||||
|
||||
const payload = { name, filters: collectFilters(), description: description || null };
|
||||
const payload = { name, filters: collectFilters(), description: description || null, tags: _ppTemplateTagsInput ? _ppTemplateTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
let response;
|
||||
|
||||
@@ -9,18 +9,26 @@ import { Modal } from '../core/modal.js';
|
||||
import { showToast, showConfirm } from '../core/ui.js';
|
||||
import { ICON_CLOCK, ICON_CLONE, ICON_EDIT, ICON_START, ICON_PAUSE } from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
|
||||
// ── Modal ──
|
||||
|
||||
let _syncClockTagsInput = null;
|
||||
|
||||
class SyncClockModal extends Modal {
|
||||
constructor() { super('sync-clock-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
return {
|
||||
name: document.getElementById('sync-clock-name').value,
|
||||
speed: document.getElementById('sync-clock-speed').value,
|
||||
description: document.getElementById('sync-clock-description').value,
|
||||
tags: JSON.stringify(_syncClockTagsInput ? _syncClockTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,6 +56,11 @@ export async function showSyncClockModal(editData) {
|
||||
document.getElementById('sync-clock-description').value = '';
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (_syncClockTagsInput) { _syncClockTagsInput.destroy(); _syncClockTagsInput = null; }
|
||||
_syncClockTagsInput = new TagInput(document.getElementById('sync-clock-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_syncClockTagsInput.setValue(isEdit ? (editData.tags || []) : []);
|
||||
|
||||
syncClockModal.open();
|
||||
syncClockModal.snapshot();
|
||||
}
|
||||
@@ -69,7 +82,7 @@ export async function saveSyncClock() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, speed, description };
|
||||
const payload = { name, speed, description, tags: _syncClockTagsInput ? _syncClockTagsInput.getValue() : [] };
|
||||
|
||||
try {
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
@@ -199,6 +212,7 @@ export function createSyncClockCard(clock) {
|
||||
<span class="stream-card-prop">${statusIcon} ${statusLabel}</span>
|
||||
<span class="stream-card-prop">${ICON_CLOCK} ${clock.speed}x</span>
|
||||
</div>
|
||||
${renderTagChips(clock.tags)}
|
||||
${clock.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(clock.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); ${toggleAction}" title="${toggleTitle}">${clock.is_running ? ICON_PAUSE : ICON_START}</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ledPreviewWebSockets,
|
||||
_cachedValueSources, valueSourcesCache,
|
||||
streamsCache, audioSourcesCache, syncClocksCache,
|
||||
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '../core/icons.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { CardSection } from '../core/card-sections.js';
|
||||
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
||||
|
||||
@@ -140,6 +142,7 @@ function _updateSubTabCounts(subTabs) {
|
||||
|
||||
// --- Editor state ---
|
||||
let _editorCssSources = []; // populated when editor opens
|
||||
let _targetTagsInput = null;
|
||||
|
||||
class TargetEditorModal extends Modal {
|
||||
constructor() {
|
||||
@@ -157,6 +160,7 @@ class TargetEditorModal extends Modal {
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
||||
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
|
||||
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -311,14 +315,12 @@ function _ensureTargetEntitySelects() {
|
||||
export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
try {
|
||||
// Load devices, CSS sources, and value sources for dropdowns
|
||||
const [devicesResp, cssResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/color-strip-sources'),
|
||||
const [devices, cssSources] = await Promise.all([
|
||||
devicesCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch(),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
|
||||
set_targetEditorDevices(devices);
|
||||
_editorCssSources = cssSources;
|
||||
|
||||
@@ -335,11 +337,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
deviceSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
let _editorTags = [];
|
||||
if (targetId) {
|
||||
// Editing existing target
|
||||
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
||||
if (!resp.ok) throw new Error('Failed to load target');
|
||||
const target = await resp.json();
|
||||
_editorTags = target.tags || [];
|
||||
|
||||
document.getElementById('target-editor-id').value = target.id;
|
||||
document.getElementById('target-editor-name').value = target.name;
|
||||
@@ -362,6 +366,7 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
||||
} else if (cloneData) {
|
||||
// Cloning — create mode but pre-filled from clone data
|
||||
_editorTags = cloneData.tags || [];
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
||||
deviceSelect.value = cloneData.device_id || '';
|
||||
@@ -420,6 +425,13 @@ export async function showTargetEditor(targetId = null, cloneData = null) {
|
||||
_updateFpsRecommendation();
|
||||
_updateBrightnessThresholdVisibility();
|
||||
|
||||
// Tags
|
||||
if (_targetTagsInput) _targetTagsInput.destroy();
|
||||
_targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
|
||||
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
||||
});
|
||||
_targetTagsInput.setValue(_editorTags);
|
||||
|
||||
targetEditorModal.snapshot();
|
||||
targetEditorModal.open();
|
||||
|
||||
@@ -440,6 +452,7 @@ export async function closeTargetEditorModal() {
|
||||
}
|
||||
|
||||
export function forceCloseTargetEditorModal() {
|
||||
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
||||
targetEditorModal.forceClose();
|
||||
}
|
||||
|
||||
@@ -473,6 +486,7 @@ export async function saveTargetEditor() {
|
||||
keepalive_interval: standbyInterval,
|
||||
adaptive_fps: adaptiveFps,
|
||||
protocol,
|
||||
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -496,6 +510,7 @@ export async function saveTargetEditor() {
|
||||
}
|
||||
|
||||
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
targetEditorModal.forceClose();
|
||||
await loadTargetsTab();
|
||||
} catch (error) {
|
||||
@@ -546,41 +561,26 @@ export async function loadTargetsTab() {
|
||||
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, CSS sources, pattern templates in parallel;
|
||||
// use DataCache for picture sources, audio sources, value sources, sync clocks
|
||||
const [devicesResp, targetsResp, cssResp, patResp, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||
fetchWithAuth('/devices'),
|
||||
fetchWithAuth('/output-targets'),
|
||||
fetchWithAuth('/color-strip-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
// Fetch all entities via DataCache
|
||||
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
||||
devicesCache.fetch().catch(() => []),
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
colorStripSourcesCache.fetch().catch(() => []),
|
||||
patternTemplatesCache.fetch().catch(() => []),
|
||||
streamsCache.fetch().catch(() => []),
|
||||
valueSourcesCache.fetch().catch(() => []),
|
||||
audioSourcesCache.fetch().catch(() => []),
|
||||
syncClocksCache.fetch().catch(() => []),
|
||||
]);
|
||||
|
||||
const devicesData = await devicesResp.json();
|
||||
const devices = devicesData.devices || [];
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
|
||||
let colorStripSourceMap = {};
|
||||
if (cssResp && cssResp.ok) {
|
||||
const cssData = await cssResp.json();
|
||||
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
|
||||
}
|
||||
cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });
|
||||
|
||||
let pictureSourceMap = {};
|
||||
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
|
||||
|
||||
let patternTemplates = [];
|
||||
let patternTemplateMap = {};
|
||||
if (patResp && patResp.ok) {
|
||||
const patData = await patResp.json();
|
||||
patternTemplates = patData.templates || [];
|
||||
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
||||
}
|
||||
|
||||
let valueSourceMap = {};
|
||||
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
|
||||
@@ -959,6 +959,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
||||
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
||||
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
||||
</div>
|
||||
${renderTagChips(target.tags)}
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
@@ -1082,15 +1083,14 @@ export async function stopAllKCTargets() {
|
||||
|
||||
async function _stopAllByType(targetType) {
|
||||
try {
|
||||
const [targetsResp, statesResp] = await Promise.all([
|
||||
fetchWithAuth('/output-targets'),
|
||||
const [allTargets, statesResp] = await Promise.all([
|
||||
outputTargetsCache.fetch().catch(() => []),
|
||||
fetchWithAuth('/output-targets/batch/states'),
|
||||
]);
|
||||
const data = await targetsResp.json();
|
||||
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
||||
const states = statesData.states || {};
|
||||
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
|
||||
const running = (data.targets || []).filter(t => typeMatch(t) && states[t.id]?.processing);
|
||||
const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
|
||||
if (!running.length) {
|
||||
showToast(t('targets.stop_all.none_running'), 'info');
|
||||
return;
|
||||
@@ -1156,6 +1156,7 @@ export async function deleteTarget(targetId) {
|
||||
});
|
||||
if (response.ok) {
|
||||
showToast(t('targets.deleted'), 'success');
|
||||
outputTargetsCache.invalidate();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(error.detail || t('target.error.delete_failed'), 'error');
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH,
|
||||
} from '../core/icons.js';
|
||||
import { wrapCard } from '../core/card-colors.js';
|
||||
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
||||
import { IconSelect } from '../core/icon-select.js';
|
||||
import { EntitySelect } from '../core/entity-palette.js';
|
||||
import { loadPictureSources } from './streams.js';
|
||||
@@ -31,10 +32,15 @@ export { getValueSourceIcon };
|
||||
// ── EntitySelect instances for value source editor ──
|
||||
let _vsAudioSourceEntitySelect = null;
|
||||
let _vsPictureSourceEntitySelect = null;
|
||||
let _vsTagsInput = null;
|
||||
|
||||
class ValueSourceModal extends Modal {
|
||||
constructor() { super('value-source-modal'); }
|
||||
|
||||
onForceClose() {
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
}
|
||||
|
||||
snapshotValues() {
|
||||
const type = document.getElementById('value-source-type').value;
|
||||
return {
|
||||
@@ -58,6 +64,7 @@ class ValueSourceModal extends Modal {
|
||||
sceneSensitivity: document.getElementById('value-source-scene-sensitivity').value,
|
||||
sceneSmoothing: document.getElementById('value-source-scene-smoothing').value,
|
||||
schedule: JSON.stringify(_getScheduleFromUI()),
|
||||
tags: JSON.stringify(_vsTagsInput ? _vsTagsInput.getValue() : []),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -241,6 +248,11 @@ export async function showValueSourceModal(editData) {
|
||||
document.getElementById('value-source-mode').onchange = () => _autoGenerateVSName();
|
||||
document.getElementById('value-source-picture-source').onchange = () => _autoGenerateVSName();
|
||||
|
||||
// Tags
|
||||
if (_vsTagsInput) { _vsTagsInput.destroy(); _vsTagsInput = null; }
|
||||
_vsTagsInput = new TagInput(document.getElementById('value-source-tags-container'), { placeholder: t('tags.placeholder') });
|
||||
_vsTagsInput.setValue(editData ? (editData.tags || []) : []);
|
||||
|
||||
valueSourceModal.open();
|
||||
valueSourceModal.snapshot();
|
||||
}
|
||||
@@ -293,7 +305,7 @@ export async function saveValueSource() {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, source_type: sourceType, description };
|
||||
const payload = { name, source_type: sourceType, description, tags: _vsTagsInput ? _vsTagsInput.getValue() : [] };
|
||||
|
||||
if (sourceType === 'static') {
|
||||
payload.value = parseFloat(document.getElementById('value-source-value').value);
|
||||
@@ -648,6 +660,7 @@ export function createValueSourceCard(src) {
|
||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
||||
</div>
|
||||
<div class="stream-card-props">${propsHtml}</div>
|
||||
${renderTagChips(src.tags)}
|
||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||
actions: `
|
||||
<button class="btn btn-icon btn-secondary" onclick="testValueSource('${src.id}')" title="${t('value_source.test')}">${ICON_TEST}</button>
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"palette.search": "Search…",
|
||||
"section.filter.placeholder": "Filter...",
|
||||
"section.filter.reset": "Clear filter",
|
||||
"tags.label": "Tags",
|
||||
"tags.hint": "Assign tags for grouping and filtering cards",
|
||||
"tags.placeholder": "Add tag...",
|
||||
"section.expand_all": "Expand all sections",
|
||||
"section.collapse_all": "Collapse all sections",
|
||||
"streams.title": "Sources",
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"palette.search": "Поиск…",
|
||||
"section.filter.placeholder": "Фильтр...",
|
||||
"section.filter.reset": "Очистить фильтр",
|
||||
"tags.label": "Теги",
|
||||
"tags.hint": "Назначьте теги для группировки и фильтрации карточек",
|
||||
"tags.placeholder": "Добавить тег...",
|
||||
"section.expand_all": "Развернуть все секции",
|
||||
"section.collapse_all": "Свернуть все секции",
|
||||
"streams.title": "Источники",
|
||||
|
||||
@@ -332,6 +332,9 @@
|
||||
"palette.search": "搜索…",
|
||||
"section.filter.placeholder": "筛选...",
|
||||
"section.filter.reset": "清除筛选",
|
||||
"tags.label": "标签",
|
||||
"tags.hint": "为卡片分配标签以进行分组和筛选",
|
||||
"tags.placeholder": "添加标签...",
|
||||
"section.expand_all": "全部展开",
|
||||
"section.collapse_all": "全部折叠",
|
||||
"streams.title": "源",
|
||||
|
||||
@@ -5,9 +5,9 @@ An AudioSource represents a reusable audio input configuration:
|
||||
MonoAudioSource — extracts a single channel from a multichannel source
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,6 +20,7 @@ class AudioSource:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert source to dictionary. Subclasses extend this."""
|
||||
@@ -30,6 +31,7 @@ class AudioSource:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
# Subclass fields default to None for forward compat
|
||||
"device_index": None,
|
||||
"is_loopback": None,
|
||||
@@ -45,26 +47,27 @@ class AudioSource:
|
||||
sid: str = data["id"]
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
tags: list = data.get("tags", [])
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
if source_type == "mono":
|
||||
return MonoAudioSource(
|
||||
id=sid, name=name, source_type="mono",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
channel=data.get("channel") or "mono",
|
||||
)
|
||||
@@ -72,7 +75,7 @@ class AudioSource:
|
||||
# Default: multichannel
|
||||
return MultichannelAudioSource(
|
||||
id=sid, name=name, source_type="multichannel",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
device_index=int(data.get("device_index", -1)),
|
||||
is_loopback=bool(data.get("is_loopback", True)),
|
||||
audio_template_id=data.get("audio_template_id"),
|
||||
|
||||
@@ -1,87 +1,36 @@
|
||||
"""Audio source storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from wled_controller.storage.audio_source import (
|
||||
AudioSource,
|
||||
MonoAudioSource,
|
||||
MultichannelAudioSource,
|
||||
)
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AudioSourceStore:
|
||||
class AudioSourceStore(BaseJsonStore[AudioSource]):
|
||||
"""Persistent storage for audio sources."""
|
||||
|
||||
_json_key = "audio_sources"
|
||||
_entity_name = "Audio source"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._sources: Dict[str, AudioSource] = {}
|
||||
self._load()
|
||||
super().__init__(file_path, AudioSource.from_dict)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info("Audio source store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
sources_data = data.get("audio_sources", {})
|
||||
loaded = 0
|
||||
for source_id, source_dict in sources_data.items():
|
||||
try:
|
||||
source = AudioSource.from_dict(source_dict)
|
||||
self._sources[source_id] = source
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load audio source {source_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} audio sources from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load audio sources from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Audio source store initialized with {len(self._sources)} sources")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"audio_sources": {
|
||||
sid: source.to_dict()
|
||||
for sid, source in self._sources.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save audio sources to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
# ── CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_all_sources(self) -> List[AudioSource]:
|
||||
return list(self._sources.values())
|
||||
# Backward-compatible aliases
|
||||
get_all_sources = BaseJsonStore.get_all
|
||||
get_source = BaseJsonStore.get
|
||||
|
||||
def get_mono_sources(self) -> List[MonoAudioSource]:
|
||||
"""Return only mono audio sources (for CSS dropdown)."""
|
||||
return [s for s in self._sources.values() if isinstance(s, MonoAudioSource)]
|
||||
|
||||
def get_source(self, source_id: str) -> AudioSource:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Audio source not found: {source_id}")
|
||||
return self._sources[source_id]
|
||||
return [s for s in self._items.values() if isinstance(s, MonoAudioSource)]
|
||||
|
||||
def create_source(
|
||||
self,
|
||||
@@ -93,25 +42,21 @@ class AudioSourceStore:
|
||||
channel: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
audio_template_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioSource:
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
self._check_name_unique(name)
|
||||
|
||||
if source_type not in ("multichannel", "mono"):
|
||||
raise ValueError(f"Invalid source type: {source_type}")
|
||||
|
||||
for source in self._sources.values():
|
||||
if source.name == name:
|
||||
raise ValueError(f"Audio source with name '{name}' already exists")
|
||||
|
||||
sid = f"as_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if source_type == "mono":
|
||||
if not audio_source_id:
|
||||
raise ValueError("Mono sources require audio_source_id")
|
||||
# Validate parent exists and is multichannel
|
||||
parent = self._sources.get(audio_source_id)
|
||||
parent = self._items.get(audio_source_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {audio_source_id}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
@@ -119,20 +64,20 @@ class AudioSourceStore:
|
||||
|
||||
source = MonoAudioSource(
|
||||
id=sid, name=name, source_type="mono",
|
||||
created_at=now, updated_at=now, description=description,
|
||||
created_at=now, updated_at=now, description=description, tags=tags or [],
|
||||
audio_source_id=audio_source_id,
|
||||
channel=channel or "mono",
|
||||
)
|
||||
else:
|
||||
source = MultichannelAudioSource(
|
||||
id=sid, name=name, source_type="multichannel",
|
||||
created_at=now, updated_at=now, description=description,
|
||||
created_at=now, updated_at=now, description=description, tags=tags or [],
|
||||
device_index=device_index if device_index is not None else -1,
|
||||
is_loopback=bool(is_loopback) if is_loopback is not None else True,
|
||||
audio_template_id=audio_template_id,
|
||||
)
|
||||
|
||||
self._sources[sid] = source
|
||||
self._items[sid] = source
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created audio source: {name} ({sid}, type={source_type})")
|
||||
@@ -148,20 +93,18 @@ class AudioSourceStore:
|
||||
channel: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
audio_template_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioSource:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Audio source not found: {source_id}")
|
||||
|
||||
source = self._sources[source_id]
|
||||
source = self.get(source_id)
|
||||
|
||||
if name is not None:
|
||||
for other in self._sources.values():
|
||||
if other.id != source_id and other.name == name:
|
||||
raise ValueError(f"Audio source with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=source_id)
|
||||
source.name = name
|
||||
|
||||
if description is not None:
|
||||
source.description = description
|
||||
if tags is not None:
|
||||
source.tags = tags
|
||||
|
||||
if isinstance(source, MultichannelAudioSource):
|
||||
if device_index is not None:
|
||||
@@ -172,7 +115,7 @@ class AudioSourceStore:
|
||||
source.audio_template_id = audio_template_id
|
||||
elif isinstance(source, MonoAudioSource):
|
||||
if audio_source_id is not None:
|
||||
parent = self._sources.get(audio_source_id)
|
||||
parent = self._items.get(audio_source_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent audio source not found: {audio_source_id}")
|
||||
if not isinstance(parent, MultichannelAudioSource):
|
||||
@@ -181,27 +124,27 @@ class AudioSourceStore:
|
||||
if channel is not None:
|
||||
source.channel = channel
|
||||
|
||||
source.updated_at = datetime.utcnow()
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated audio source: {source_id}")
|
||||
return source
|
||||
|
||||
def delete_source(self, source_id: str) -> None:
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Audio source not found: {source_id}")
|
||||
if source_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {source_id}")
|
||||
|
||||
source = self._sources[source_id]
|
||||
source = self._items[source_id]
|
||||
|
||||
# Prevent deleting multichannel sources referenced by mono sources
|
||||
if isinstance(source, MultichannelAudioSource):
|
||||
for other in self._sources.values():
|
||||
for other in self._items.values():
|
||||
if isinstance(other, MonoAudioSource) and other.audio_source_id == source_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete '{source.name}': referenced by mono source '{other.name}'"
|
||||
)
|
||||
|
||||
del self._sources[source_id]
|
||||
del self._items[source_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted audio source: {source_id}")
|
||||
@@ -231,4 +174,3 @@ class AudioSourceStore:
|
||||
return parent.device_index, parent.is_loopback, source.channel, parent.audio_template_id
|
||||
|
||||
raise ValueError(f"Audio source {source_id} is not a valid audio source")
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Audio capture template data model."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -16,6 +16,7 @@ class AudioCaptureTemplate:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert template to dictionary."""
|
||||
@@ -27,6 +28,7 @@ class AudioCaptureTemplate:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -39,9 +41,10 @@ class AudioCaptureTemplate:
|
||||
engine_config=data.get("engine_config", {}),
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.utcnow()),
|
||||
else data.get("created_at", datetime.now(timezone.utc)),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"])
|
||||
if isinstance(data.get("updated_at"), str)
|
||||
else data.get("updated_at", datetime.utcnow()),
|
||||
else data.get("updated_at", datetime.now(timezone.utc)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Audio template storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.core.audio.factory import AudioEngineRegistry
|
||||
from wled_controller.storage.audio_template import AudioCaptureTemplate
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AudioTemplateStore:
|
||||
class AudioTemplateStore(BaseJsonStore[AudioCaptureTemplate]):
|
||||
"""Storage for audio capture templates.
|
||||
|
||||
All templates are persisted to the JSON file.
|
||||
@@ -21,15 +20,20 @@ class AudioTemplateStore:
|
||||
highest-priority available engine.
|
||||
"""
|
||||
|
||||
_json_key = "templates"
|
||||
_entity_name = "Audio capture template"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._templates: Dict[str, AudioCaptureTemplate] = {}
|
||||
self._load()
|
||||
super().__init__(file_path, AudioCaptureTemplate.from_dict)
|
||||
self._ensure_initial_template()
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseJsonStore.get_all
|
||||
get_template = BaseJsonStore.get
|
||||
|
||||
def _ensure_initial_template(self) -> None:
|
||||
"""Auto-create a template if none exist, using the best available engine."""
|
||||
if self._templates:
|
||||
if self._items:
|
||||
return
|
||||
|
||||
best_engine = AudioEngineRegistry.get_best_available_engine()
|
||||
@@ -39,7 +43,7 @@ class AudioTemplateStore:
|
||||
|
||||
engine_class = AudioEngineRegistry.get_engine(best_engine)
|
||||
default_config = engine_class.get_default_config()
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template_id = f"atpl_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
template = AudioCaptureTemplate(
|
||||
@@ -52,71 +56,17 @@ class AudioTemplateStore:
|
||||
description=f"Default audio template using {best_engine.upper()} engine",
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(
|
||||
f"Auto-created initial audio template: {template.name} "
|
||||
f"({template_id}, engine={best_engine})"
|
||||
)
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load templates from file."""
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
templates_data = data.get("templates", {})
|
||||
loaded = 0
|
||||
for template_id, template_dict in templates_data.items():
|
||||
try:
|
||||
template = AudioCaptureTemplate.from_dict(template_dict)
|
||||
self._templates[template_id] = template
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load audio template {template_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} audio templates from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load audio templates from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Audio template store initialized with {len(self._templates)} templates")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all templates to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"templates": {
|
||||
template_id: template.to_dict()
|
||||
for template_id, template in self._templates.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save audio templates to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_templates(self) -> List[AudioCaptureTemplate]:
|
||||
return list(self._templates.values())
|
||||
|
||||
def get_template(self, template_id: str) -> AudioCaptureTemplate:
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Audio template not found: {template_id}")
|
||||
return self._templates[template_id]
|
||||
|
||||
def get_default_template_id(self) -> Optional[str]:
|
||||
"""Return the ID of the first template, or None if none exist."""
|
||||
if self._templates:
|
||||
return next(iter(self._templates))
|
||||
if self._items:
|
||||
return next(iter(self._items))
|
||||
return None
|
||||
|
||||
def create_template(
|
||||
@@ -125,13 +75,12 @@ class AudioTemplateStore:
|
||||
engine_type: str,
|
||||
engine_config: Dict[str, any],
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioCaptureTemplate:
|
||||
for template in self._templates.values():
|
||||
if template.name == name:
|
||||
raise ValueError(f"Audio template with name '{name}' already exists")
|
||||
self._check_name_unique(name)
|
||||
|
||||
template_id = f"atpl_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template = AudioCaptureTemplate(
|
||||
id=template_id,
|
||||
name=name,
|
||||
@@ -140,9 +89,10 @@ class AudioTemplateStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(f"Created audio template: {name} ({template_id})")
|
||||
return template
|
||||
@@ -154,16 +104,12 @@ class AudioTemplateStore:
|
||||
engine_type: Optional[str] = None,
|
||||
engine_config: Optional[Dict[str, any]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AudioCaptureTemplate:
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Audio template not found: {template_id}")
|
||||
|
||||
template = self._templates[template_id]
|
||||
template = self.get(template_id)
|
||||
|
||||
if name is not None:
|
||||
for tid, t in self._templates.items():
|
||||
if tid != template_id and t.name == name:
|
||||
raise ValueError(f"Audio template with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if engine_type is not None:
|
||||
template.engine_type = engine_type
|
||||
@@ -171,8 +117,10 @@ class AudioTemplateStore:
|
||||
template.engine_config = engine_config
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
logger.info(f"Updated audio template: {template_id}")
|
||||
return template
|
||||
@@ -187,8 +135,8 @@ class AudioTemplateStore:
|
||||
Raises:
|
||||
ValueError: If template not found or still referenced
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Audio template not found: {template_id}")
|
||||
if template_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {template_id}")
|
||||
|
||||
# Check if any multichannel audio source references this template
|
||||
if audio_source_store is not None:
|
||||
@@ -203,6 +151,6 @@ class AudioTemplateStore:
|
||||
f"referenced by audio source '{source.name}' ({source.id})"
|
||||
)
|
||||
|
||||
del self._templates[template_id]
|
||||
del self._items[template_id]
|
||||
self._save()
|
||||
logger.info(f"Deleted audio template: {template_id}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Automation and Condition data models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ class Automation:
|
||||
deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -215,6 +216,7 @@ class Automation:
|
||||
"scene_preset_id": self.scene_preset_id,
|
||||
"deactivation_mode": self.deactivation_mode,
|
||||
"deactivation_scene_preset_id": self.deactivation_scene_preset_id,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -237,6 +239,7 @@ class Automation:
|
||||
scene_preset_id=data.get("scene_preset_id"),
|
||||
deactivation_mode=data.get("deactivation_mode", "none"),
|
||||
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
tags=data.get("tags", []),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
|
||||
@@ -1,72 +1,27 @@
|
||||
"""Automation storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.automation import Automation, Condition
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class AutomationStore:
|
||||
"""Persistent storage for automations."""
|
||||
class AutomationStore(BaseJsonStore[Automation]):
|
||||
_json_key = "automations"
|
||||
_entity_name = "Automation"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._automations: Dict[str, Automation] = {}
|
||||
self._load()
|
||||
super().__init__(file_path, Automation.from_dict)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
automations_data = data.get("automations", {})
|
||||
loaded = 0
|
||||
for auto_id, auto_dict in automations_data.items():
|
||||
try:
|
||||
automation = Automation.from_dict(auto_dict)
|
||||
self._automations[auto_id] = automation
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load automation {auto_id}: {e}", exc_info=True)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} automations from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load automations from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Automation store initialized with {len(self._automations)} automations")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"automations": {
|
||||
aid: a.to_dict() for aid, a in self._automations.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save automations to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_automations(self) -> List[Automation]:
|
||||
return list(self._automations.values())
|
||||
|
||||
def get_automation(self, automation_id: str) -> Automation:
|
||||
if automation_id not in self._automations:
|
||||
raise ValueError(f"Automation not found: {automation_id}")
|
||||
return self._automations[automation_id]
|
||||
# Backward-compatible aliases
|
||||
get_all_automations = BaseJsonStore.get_all
|
||||
get_automation = BaseJsonStore.get
|
||||
delete_automation = BaseJsonStore.delete
|
||||
|
||||
def create_automation(
|
||||
self,
|
||||
@@ -77,13 +32,14 @@ class AutomationStore:
|
||||
scene_preset_id: Optional[str] = None,
|
||||
deactivation_mode: str = "none",
|
||||
deactivation_scene_preset_id: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Automation:
|
||||
for a in self._automations.values():
|
||||
for a in self._items.values():
|
||||
if a.name == name:
|
||||
raise ValueError(f"Automation with name '{name}' already exists")
|
||||
|
||||
automation_id = f"auto_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
automation = Automation(
|
||||
id=automation_id,
|
||||
@@ -96,11 +52,11 @@ class AutomationStore:
|
||||
deactivation_scene_preset_id=deactivation_scene_preset_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._automations[automation_id] = automation
|
||||
self._items[automation_id] = automation
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created automation: {name} ({automation_id})")
|
||||
return automation
|
||||
|
||||
@@ -114,16 +70,12 @@ class AutomationStore:
|
||||
scene_preset_id: str = "__unset__",
|
||||
deactivation_mode: Optional[str] = None,
|
||||
deactivation_scene_preset_id: str = "__unset__",
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Automation:
|
||||
if automation_id not in self._automations:
|
||||
raise ValueError(f"Automation not found: {automation_id}")
|
||||
|
||||
automation = self._automations[automation_id]
|
||||
automation = self.get(automation_id)
|
||||
|
||||
if name is not None:
|
||||
for aid, a in self._automations.items():
|
||||
if aid != automation_id and a.name == name:
|
||||
raise ValueError(f"Automation with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=automation_id)
|
||||
automation.name = name
|
||||
if enabled is not None:
|
||||
automation.enabled = enabled
|
||||
@@ -137,21 +89,10 @@ class AutomationStore:
|
||||
automation.deactivation_mode = deactivation_mode
|
||||
if deactivation_scene_preset_id != "__unset__":
|
||||
automation.deactivation_scene_preset_id = deactivation_scene_preset_id
|
||||
if tags is not None:
|
||||
automation.tags = tags
|
||||
|
||||
automation.updated_at = datetime.utcnow()
|
||||
automation.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated automation: {automation_id}")
|
||||
return automation
|
||||
|
||||
def delete_automation(self, automation_id: str) -> None:
|
||||
if automation_id not in self._automations:
|
||||
raise ValueError(f"Automation not found: {automation_id}")
|
||||
|
||||
del self._automations[automation_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted automation: {automation_id}")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._automations)
|
||||
|
||||
115
server/src/wled_controller/storage/base_store.py
Normal file
115
server/src/wled_controller/storage/base_store.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Base class for JSON entity stores — eliminates boilerplate across 12+ stores."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Generic, List, TypeVar
|
||||
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
T = TypeVar("T")
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseJsonStore(Generic[T]):
|
||||
"""JSON-file-backed entity store with common CRUD helpers.
|
||||
|
||||
Provides:
|
||||
- ``_load()`` / ``_save()``: atomic JSON file I/O
|
||||
- ``get_all()`` / ``get(id)`` / ``delete(id)`` / ``count()``: read/delete
|
||||
- ``_check_name_unique(name, exclude_id)``: duplicate-name guard
|
||||
|
||||
Subclasses must set class attributes:
|
||||
- ``_json_key``: root key in JSON file (e.g. ``"sync_clocks"``)
|
||||
- ``_entity_name``: human label for errors (e.g. ``"Sync clock"``)
|
||||
- ``_version``: schema version string (default ``"1.0.0"``)
|
||||
"""
|
||||
|
||||
_json_key: str
|
||||
_entity_name: str
|
||||
_version: str = "1.0.0"
|
||||
|
||||
def __init__(self, file_path: str, deserializer: Callable[[dict], T]):
|
||||
self.file_path = Path(file_path)
|
||||
self._items: Dict[str, T] = {}
|
||||
self._deserializer = deserializer
|
||||
self._load()
|
||||
|
||||
# ── I/O ────────────────────────────────────────────────────────
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info(f"{self._entity_name} store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
items_data = data.get(self._json_key, {})
|
||||
loaded = 0
|
||||
for item_id, item_dict in items_data.items():
|
||||
try:
|
||||
self._items[item_id] = self._deserializer(item_dict)
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load {self._entity_name} {item_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} {self._json_key} from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"{self._entity_name} store initialized with {len(self._items)} items"
|
||||
)
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": self._version,
|
||||
self._json_key: {
|
||||
item_id: item.to_dict()
|
||||
for item_id, item in self._items.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save {self._json_key} to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
# ── Common CRUD ────────────────────────────────────────────────
|
||||
|
||||
def get_all(self) -> List[T]:
|
||||
return list(self._items.values())
|
||||
|
||||
def get(self, item_id: str) -> T:
|
||||
if item_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {item_id}")
|
||||
return self._items[item_id]
|
||||
|
||||
def delete(self, item_id: str) -> None:
|
||||
if item_id not in self._items:
|
||||
raise ValueError(f"{self._entity_name} not found: {item_id}")
|
||||
del self._items[item_id]
|
||||
self._save()
|
||||
logger.info(f"Deleted {self._entity_name}: {item_id}")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
def _check_name_unique(self, name: str, exclude_id: str = None) -> None:
|
||||
"""Raise ValueError if *name* is empty or already taken."""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
for item_id, item in self._items.items():
|
||||
if item_id != exclude_id and getattr(item, "name", None) == name:
|
||||
raise ValueError(
|
||||
f"{self._entity_name} with name '{name}' already exists"
|
||||
)
|
||||
@@ -16,8 +16,8 @@ Current types:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.capture.calibration import (
|
||||
CalibrationConfig,
|
||||
@@ -37,6 +37,7 @@ class ColorStripSource:
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
clock_id: Optional[str] = None # optional SyncClock reference
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def sharable(self) -> bool:
|
||||
@@ -57,6 +58,7 @@ class ColorStripSource:
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"clock_id": self.clock_id,
|
||||
"tags": self.tags,
|
||||
# Subclass fields default to None for forward compat
|
||||
"picture_source_id": None,
|
||||
"fps": None,
|
||||
@@ -102,20 +104,21 @@ class ColorStripSource:
|
||||
description: str | None = data.get("description")
|
||||
|
||||
clock_id: str | None = data.get("clock_id")
|
||||
tags: list = data.get("tags", [])
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
calibration_data = data.get("calibration")
|
||||
@@ -134,7 +137,7 @@ class ColorStripSource:
|
||||
return StaticColorStripSource(
|
||||
id=sid, name=name, source_type="static",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, color=color,
|
||||
clock_id=clock_id, tags=tags, color=color,
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
|
||||
@@ -144,7 +147,7 @@ class ColorStripSource:
|
||||
return GradientColorStripSource(
|
||||
id=sid, name=name, source_type="gradient",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, stops=stops,
|
||||
clock_id=clock_id, tags=tags, stops=stops,
|
||||
animation=data.get("animation"),
|
||||
)
|
||||
|
||||
@@ -154,14 +157,14 @@ class ColorStripSource:
|
||||
return ColorCycleColorStripSource(
|
||||
id=sid, name=name, source_type="color_cycle",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, colors=colors,
|
||||
clock_id=clock_id, tags=tags, colors=colors,
|
||||
)
|
||||
|
||||
if source_type == "composite":
|
||||
return CompositeColorStripSource(
|
||||
id=sid, name=name, source_type="composite",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, layers=data.get("layers") or [],
|
||||
clock_id=clock_id, tags=tags, layers=data.get("layers") or [],
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
@@ -169,7 +172,7 @@ class ColorStripSource:
|
||||
return MappedColorStripSource(
|
||||
id=sid, name=name, source_type="mapped",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, zones=data.get("zones") or [],
|
||||
clock_id=clock_id, tags=tags, zones=data.get("zones") or [],
|
||||
led_count=data.get("led_count") or 0,
|
||||
)
|
||||
|
||||
@@ -181,7 +184,7 @@ class ColorStripSource:
|
||||
return AudioColorStripSource(
|
||||
id=sid, name=name, source_type="audio",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||
clock_id=clock_id, tags=tags, visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||
audio_source_id=data.get("audio_source_id") or "",
|
||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||
smoothing=float(data.get("smoothing") or 0.3),
|
||||
@@ -201,7 +204,7 @@ class ColorStripSource:
|
||||
return EffectColorStripSource(
|
||||
id=sid, name=name, source_type="effect",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, effect_type=data.get("effect_type") or "fire",
|
||||
clock_id=clock_id, tags=tags, effect_type=data.get("effect_type") or "fire",
|
||||
palette=data.get("palette") or "fire",
|
||||
color=color,
|
||||
intensity=float(data.get("intensity") or 1.0),
|
||||
@@ -218,7 +221,7 @@ class ColorStripSource:
|
||||
return ApiInputColorStripSource(
|
||||
id=sid, name=name, source_type="api_input",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id, led_count=data.get("led_count") or 0,
|
||||
clock_id=clock_id, tags=tags, led_count=data.get("led_count") or 0,
|
||||
fallback_color=fallback_color,
|
||||
timeout=float(data.get("timeout") or 5.0),
|
||||
)
|
||||
@@ -231,7 +234,7 @@ class ColorStripSource:
|
||||
return NotificationColorStripSource(
|
||||
id=sid, name=name, source_type="notification",
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
clock_id=clock_id,
|
||||
clock_id=clock_id, tags=tags,
|
||||
notification_effect=data.get("notification_effect") or "flash",
|
||||
duration_ms=int(data.get("duration_ms") or 1500),
|
||||
default_color=data.get("default_color") or "#FFFFFF",
|
||||
@@ -243,6 +246,7 @@ class ColorStripSource:
|
||||
|
||||
# Shared picture-type field extraction
|
||||
_picture_kwargs = dict(
|
||||
tags=tags,
|
||||
fps=data.get("fps") or 30,
|
||||
brightness=data["brightness"] if data.get("brightness") is not None else 1.0,
|
||||
saturation=data["saturation"] if data.get("saturation") is not None else 1.0,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Color strip source storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
AdvancedPictureColorStripSource,
|
||||
ApiInputColorStripSource,
|
||||
@@ -21,73 +20,27 @@ from wled_controller.storage.color_strip_source import (
|
||||
PictureColorStripSource,
|
||||
StaticColorStripSource,
|
||||
)
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ColorStripStore:
|
||||
class ColorStripStore(BaseJsonStore[ColorStripSource]):
|
||||
"""Persistent storage for color strip sources."""
|
||||
|
||||
_json_key = "color_strip_sources"
|
||||
_entity_name = "Color strip source"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._sources: Dict[str, ColorStripSource] = {}
|
||||
self._load()
|
||||
super().__init__(file_path, ColorStripSource.from_dict)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
logger.info("Color strip store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
sources_data = data.get("color_strip_sources", {})
|
||||
loaded = 0
|
||||
for source_id, source_dict in sources_data.items():
|
||||
try:
|
||||
source = ColorStripSource.from_dict(source_dict)
|
||||
self._sources[source_id] = source
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load color strip source {source_id}: {e}", exc_info=True)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} color strip sources from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load color strip sources from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Color strip store initialized with {len(self._sources)} sources")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"color_strip_sources": {
|
||||
sid: source.to_dict()
|
||||
for sid, source in self._sources.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save color strip sources to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_sources(self) -> List[ColorStripSource]:
|
||||
return list(self._sources.values())
|
||||
# Backward-compatible aliases
|
||||
get_all_sources = BaseJsonStore.get_all
|
||||
delete_source = BaseJsonStore.delete
|
||||
|
||||
def get_source(self, source_id: str) -> ColorStripSource:
|
||||
"""Get a color strip source by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If source not found
|
||||
"""
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Color strip source not found: {source_id}")
|
||||
return self._sources[source_id]
|
||||
"""Get a color strip source by ID (alias for get())."""
|
||||
return self.get(source_id)
|
||||
|
||||
def create_source(
|
||||
self,
|
||||
@@ -129,6 +82,7 @@ class ColorStripStore:
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Create a new color strip source.
|
||||
|
||||
@@ -138,12 +92,12 @@ class ColorStripStore:
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Name is required")
|
||||
|
||||
for source in self._sources.values():
|
||||
for source in self._items.values():
|
||||
if source.name == name:
|
||||
raise ValueError(f"Color strip source with name '{name}' already exists")
|
||||
|
||||
source_id = f"css_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if source_type == "static":
|
||||
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255]
|
||||
@@ -325,7 +279,8 @@ class ColorStripStore:
|
||||
frame_interpolation=frame_interpolation,
|
||||
)
|
||||
|
||||
self._sources[source_id] = source
|
||||
source.tags = tags or []
|
||||
self._items[source_id] = source
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})")
|
||||
@@ -371,19 +326,20 @@ class ColorStripStore:
|
||||
app_filter_mode: Optional[str] = None,
|
||||
app_filter_list: Optional[list] = None,
|
||||
os_listener: Optional[bool] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ColorStripSource:
|
||||
"""Update an existing color strip source.
|
||||
|
||||
Raises:
|
||||
ValueError: If source not found
|
||||
"""
|
||||
if source_id not in self._sources:
|
||||
if source_id not in self._items:
|
||||
raise ValueError(f"Color strip source not found: {source_id}")
|
||||
|
||||
source = self._sources[source_id]
|
||||
source = self._items[source_id]
|
||||
|
||||
if name is not None:
|
||||
for other in self._sources.values():
|
||||
for other in self._items.values():
|
||||
if other.id != source_id and other.name == name:
|
||||
raise ValueError(f"Color strip source with name '{name}' already exists")
|
||||
source.name = name
|
||||
@@ -394,6 +350,9 @@ class ColorStripStore:
|
||||
if clock_id is not None:
|
||||
source.clock_id = clock_id if clock_id else None
|
||||
|
||||
if tags is not None:
|
||||
source.tags = tags
|
||||
|
||||
if isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
|
||||
if picture_source_id is not None and isinstance(source, PictureColorStripSource):
|
||||
source.picture_source_id = picture_source_id
|
||||
@@ -494,30 +453,16 @@ class ColorStripStore:
|
||||
if os_listener is not None:
|
||||
source.os_listener = bool(os_listener)
|
||||
|
||||
source.updated_at = datetime.utcnow()
|
||||
source.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated color strip source: {source_id}")
|
||||
return source
|
||||
|
||||
def delete_source(self, source_id: str) -> None:
|
||||
"""Delete a color strip source.
|
||||
|
||||
Raises:
|
||||
ValueError: If source not found
|
||||
"""
|
||||
if source_id not in self._sources:
|
||||
raise ValueError(f"Color strip source not found: {source_id}")
|
||||
|
||||
del self._sources[source_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted color strip source: {source_id}")
|
||||
|
||||
def get_composites_referencing(self, source_id: str) -> List[str]:
|
||||
"""Return names of composite sources that reference a given source as a layer."""
|
||||
names = []
|
||||
for source in self._sources.values():
|
||||
for source in self._items.values():
|
||||
if isinstance(source, CompositeColorStripSource):
|
||||
for layer in source.layers:
|
||||
if layer.get("source_id") == source_id:
|
||||
@@ -528,7 +473,7 @@ class ColorStripStore:
|
||||
def get_mapped_referencing(self, source_id: str) -> List[str]:
|
||||
"""Return names of mapped sources that reference a given source as a zone."""
|
||||
names = []
|
||||
for source in self._sources.values():
|
||||
for source in self._items.values():
|
||||
if isinstance(source, MappedColorStripSource):
|
||||
for zone in source.zones:
|
||||
if zone.get("source_id") == source_id:
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wled_controller.utils import get_logger
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -33,6 +33,7 @@ class Device:
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
tags: List[str] = None,
|
||||
created_at: Optional[datetime] = None,
|
||||
updated_at: Optional[datetime] = None,
|
||||
):
|
||||
@@ -48,8 +49,9 @@ class Device:
|
||||
self.send_latency_ms = send_latency_ms
|
||||
self.rgbw = rgbw
|
||||
self.zone_mode = zone_mode
|
||||
self.created_at = created_at or datetime.utcnow()
|
||||
self.updated_at = updated_at or datetime.utcnow()
|
||||
self.tags = tags or []
|
||||
self.created_at = created_at or datetime.now(timezone.utc)
|
||||
self.updated_at = updated_at or datetime.now(timezone.utc)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert device to dictionary."""
|
||||
@@ -75,6 +77,8 @@ class Device:
|
||||
d["rgbw"] = True
|
||||
if self.zone_mode != "combined":
|
||||
d["zone_mode"] = self.zone_mode
|
||||
if self.tags:
|
||||
d["tags"] = self.tags
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
@@ -93,8 +97,9 @@ class Device:
|
||||
send_latency_ms=data.get("send_latency_ms", 0),
|
||||
rgbw=data.get("rgbw", False),
|
||||
zone_mode=data.get("zone_mode", "combined"),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
tags=data.get("tags", []),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
|
||||
|
||||
@@ -158,11 +163,7 @@ class DeviceStore:
|
||||
}
|
||||
}
|
||||
|
||||
temp_file = self.storage_file.with_suffix(".tmp")
|
||||
with open(temp_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
temp_file.replace(self.storage_file)
|
||||
atomic_write_json(self.storage_file, data)
|
||||
|
||||
logger.debug(f"Saved {len(self._devices)} devices to storage")
|
||||
|
||||
@@ -181,6 +182,7 @@ class DeviceStore:
|
||||
send_latency_ms: int = 0,
|
||||
rgbw: bool = False,
|
||||
zone_mode: str = "combined",
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Device:
|
||||
"""Create a new device."""
|
||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||
@@ -200,6 +202,7 @@ class DeviceStore:
|
||||
send_latency_ms=send_latency_ms,
|
||||
rgbw=rgbw,
|
||||
zone_mode=zone_mode,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._devices[device_id] = device
|
||||
@@ -228,6 +231,7 @@ class DeviceStore:
|
||||
send_latency_ms: Optional[int] = None,
|
||||
rgbw: Optional[bool] = None,
|
||||
zone_mode: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> Device:
|
||||
"""Update device."""
|
||||
device = self._devices.get(device_id)
|
||||
@@ -252,8 +256,10 @@ class DeviceStore:
|
||||
device.rgbw = rgbw
|
||||
if zone_mode is not None:
|
||||
device.zone_mode = zone_mode
|
||||
if tags is not None:
|
||||
device.tags = tags
|
||||
|
||||
device.updated_at = datetime.utcnow()
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
self.save()
|
||||
|
||||
logger.info(f"Updated device {device_id}")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Key colors output target — extracts key colors from image rectangles."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.output_target import OutputTarget
|
||||
@@ -100,9 +100,10 @@ class KeyColorsOutputTarget(OutputTarget):
|
||||
|
||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
||||
settings=None, key_colors_settings=None, description=None,
|
||||
tags=None,
|
||||
**_kwargs) -> None:
|
||||
"""Apply mutable field updates for KC targets."""
|
||||
super().update_fields(name=name, description=description)
|
||||
super().update_fields(name=name, description=description, tags=tags)
|
||||
if picture_source_id is not None:
|
||||
self.picture_source_id = picture_source_id
|
||||
if key_colors_settings is not None:
|
||||
@@ -130,6 +131,7 @@ class KeyColorsOutputTarget(OutputTarget):
|
||||
picture_source_id=data.get("picture_source_id", ""),
|
||||
settings=settings,
|
||||
description=data.get("description"),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
tags=data.get("tags", []),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Output target base data model."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -15,6 +15,7 @@ class OutputTarget:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def register_with_manager(self, manager) -> None:
|
||||
"""Register this target with the processor manager. Subclasses override."""
|
||||
@@ -26,12 +27,15 @@ class OutputTarget:
|
||||
|
||||
def update_fields(self, *, name=None, device_id=None, picture_source_id=None,
|
||||
settings=None, key_colors_settings=None, description=None,
|
||||
tags: Optional[List[str]] = None,
|
||||
**_kwargs) -> None:
|
||||
"""Apply mutable field updates. Base handles common fields; subclasses handle type-specific ones."""
|
||||
if name is not None:
|
||||
self.name = name
|
||||
if description is not None:
|
||||
self.description = description
|
||||
if tags is not None:
|
||||
self.tags = tags
|
||||
|
||||
@property
|
||||
def has_picture_source(self) -> bool:
|
||||
@@ -45,6 +49,7 @@ class OutputTarget:
|
||||
"name": self.name,
|
||||
"target_type": self.target_type,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -2,93 +2,61 @@
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.output_target import OutputTarget
|
||||
from wled_controller.storage.wled_output_target import WledOutputTarget
|
||||
from wled_controller.storage.key_colors_output_target import (
|
||||
KeyColorsSettings,
|
||||
KeyColorsOutputTarget,
|
||||
)
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
||||
|
||||
|
||||
class OutputTargetStore:
|
||||
class OutputTargetStore(BaseJsonStore[OutputTarget]):
|
||||
"""Persistent storage for output targets."""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
"""Initialize output target store.
|
||||
_json_key = "output_targets"
|
||||
_entity_name = "Output target"
|
||||
|
||||
Args:
|
||||
file_path: Path to targets JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._targets: Dict[str, OutputTarget] = {}
|
||||
self._load()
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, OutputTarget.from_dict)
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load targets from file."""
|
||||
"""Override to support legacy 'picture_targets' JSON key."""
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
if not self.file_path.exists():
|
||||
logger.info(f"{self._entity_name} store file not found — starting empty")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Support both new "output_targets" and legacy "picture_targets" keys
|
||||
data = _json.load(f)
|
||||
targets_data = data.get("output_targets") or data.get("picture_targets", {})
|
||||
loaded = 0
|
||||
for target_id, target_dict in targets_data.items():
|
||||
try:
|
||||
target = OutputTarget.from_dict(target_dict)
|
||||
self._targets[target_id] = target
|
||||
self._items[target_id] = self._deserializer(target_dict)
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load output target {target_id}: {e}", exc_info=True)
|
||||
|
||||
logger.error(f"Failed to load {self._entity_name} {target_id}: {e}", exc_info=True)
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} output targets from storage")
|
||||
|
||||
logger.info(f"Loaded {loaded} {self._json_key} from storage")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load output targets from {self.file_path}: {e}")
|
||||
logger.error(f"Failed to load {self._json_key} from {self.file_path}: {e}")
|
||||
raise
|
||||
logger.info(f"{self._entity_name} store initialized with {len(self._items)} items")
|
||||
|
||||
logger.info(f"Output target store initialized with {len(self._targets)} targets")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all targets to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"output_targets": {
|
||||
target_id: target.to_dict()
|
||||
for target_id, target in self._targets.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save output targets to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_targets(self) -> List[OutputTarget]:
|
||||
"""Get all output targets."""
|
||||
return list(self._targets.values())
|
||||
|
||||
def get_target(self, target_id: str) -> OutputTarget:
|
||||
"""Get target by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If target not found
|
||||
"""
|
||||
if target_id not in self._targets:
|
||||
raise ValueError(f"Output target not found: {target_id}")
|
||||
return self._targets[target_id]
|
||||
# Backward-compatible aliases
|
||||
get_all_targets = BaseJsonStore.get_all
|
||||
get_target = BaseJsonStore.get
|
||||
delete_target = BaseJsonStore.delete
|
||||
|
||||
def create_target(
|
||||
self,
|
||||
@@ -106,6 +74,7 @@ class OutputTargetStore:
|
||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||
description: Optional[str] = None,
|
||||
picture_source_id: str = "",
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> OutputTarget:
|
||||
"""Create a new output target.
|
||||
|
||||
@@ -116,12 +85,12 @@ class OutputTargetStore:
|
||||
raise ValueError(f"Invalid target type: {target_type}")
|
||||
|
||||
# Check for duplicate name
|
||||
for target in self._targets.values():
|
||||
for target in self._items.values():
|
||||
if target.name == name:
|
||||
raise ValueError(f"Output target with name '{name}' already exists")
|
||||
|
||||
target_id = f"pt_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if target_type == "led":
|
||||
target: OutputTarget = WledOutputTarget(
|
||||
@@ -155,7 +124,8 @@ class OutputTargetStore:
|
||||
else:
|
||||
raise ValueError(f"Unknown target type: {target_type}")
|
||||
|
||||
self._targets[target_id] = target
|
||||
target.tags = tags or []
|
||||
self._items[target_id] = target
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created output target: {name} ({target_id}, type={target_type})")
|
||||
@@ -176,20 +146,21 @@ class OutputTargetStore:
|
||||
protocol: Optional[str] = None,
|
||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> OutputTarget:
|
||||
"""Update an output target.
|
||||
|
||||
Raises:
|
||||
ValueError: If target not found or validation fails
|
||||
"""
|
||||
if target_id not in self._targets:
|
||||
if target_id not in self._items:
|
||||
raise ValueError(f"Output target not found: {target_id}")
|
||||
|
||||
target = self._targets[target_id]
|
||||
target = self._items[target_id]
|
||||
|
||||
if name is not None:
|
||||
# Check for duplicate name (exclude self)
|
||||
for other in self._targets.values():
|
||||
for other in self._items.values():
|
||||
if other.id != target_id and other.name == name:
|
||||
raise ValueError(f"Output target with name '{name}' already exists")
|
||||
|
||||
@@ -206,50 +177,37 @@ class OutputTargetStore:
|
||||
protocol=protocol,
|
||||
key_colors_settings=key_colors_settings,
|
||||
description=description,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
target.updated_at = datetime.utcnow()
|
||||
target.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated output target: {target_id}")
|
||||
return target
|
||||
|
||||
def delete_target(self, target_id: str) -> None:
|
||||
"""Delete an output target.
|
||||
|
||||
Raises:
|
||||
ValueError: If target not found
|
||||
"""
|
||||
if target_id not in self._targets:
|
||||
raise ValueError(f"Output target not found: {target_id}")
|
||||
|
||||
del self._targets[target_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted output target: {target_id}")
|
||||
|
||||
def get_targets_for_device(self, device_id: str) -> List[OutputTarget]:
|
||||
"""Get all targets that reference a specific device."""
|
||||
return [
|
||||
t for t in self._targets.values()
|
||||
t for t in self._items.values()
|
||||
if isinstance(t, WledOutputTarget) and t.device_id == device_id
|
||||
]
|
||||
|
||||
def get_targets_referencing_source(self, source_id: str) -> List[str]:
|
||||
"""Return names of KC targets that reference a picture source."""
|
||||
return [
|
||||
target.name for target in self._targets.values()
|
||||
target.name for target in self._items.values()
|
||||
if isinstance(target, KeyColorsOutputTarget) and target.picture_source_id == source_id
|
||||
]
|
||||
|
||||
def get_targets_referencing_css(self, css_id: str) -> List[str]:
|
||||
"""Return names of LED targets that reference a color strip source."""
|
||||
return [
|
||||
target.name for target in self._targets.values()
|
||||
target.name for target in self._items.values()
|
||||
if isinstance(target, WledOutputTarget)
|
||||
and target.color_strip_source_id == css_id
|
||||
]
|
||||
|
||||
def count(self) -> int:
|
||||
"""Get number of targets."""
|
||||
return len(self._targets)
|
||||
return len(self._items)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Pattern template data model for key color rectangle layouts."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||
@@ -17,6 +17,7 @@ class PatternTemplate:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary."""
|
||||
@@ -27,6 +28,7 @@ class PatternTemplate:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -39,9 +41,10 @@ class PatternTemplate:
|
||||
rectangles=rectangles,
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.utcnow()),
|
||||
else data.get("created_at", datetime.now(timezone.utc)),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"])
|
||||
if isinstance(data.get("updated_at"), str)
|
||||
else data.get("updated_at", datetime.utcnow()),
|
||||
else data.get("updated_at", datetime.now(timezone.utc)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
"""Pattern template storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorRectangle
|
||||
from wled_controller.storage.pattern_template import PatternTemplate
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PatternTemplateStore:
|
||||
class PatternTemplateStore(BaseJsonStore[PatternTemplate]):
|
||||
"""Storage for pattern templates (rectangle layouts for key color extraction).
|
||||
|
||||
All templates are persisted to the JSON file.
|
||||
On startup, if no templates exist, a default one is auto-created.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
"""Initialize pattern template store.
|
||||
_json_key = "pattern_templates"
|
||||
_entity_name = "Pattern template"
|
||||
|
||||
Args:
|
||||
file_path: Path to templates JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._templates: Dict[str, PatternTemplate] = {}
|
||||
self._load()
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, PatternTemplate.from_dict)
|
||||
self._ensure_initial_template()
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseJsonStore.get_all
|
||||
get_template = BaseJsonStore.get
|
||||
delete_template = BaseJsonStore.delete
|
||||
|
||||
def _ensure_initial_template(self) -> None:
|
||||
"""Auto-create a default pattern template if none exist."""
|
||||
if self._templates:
|
||||
if self._items:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template_id = f"pat_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
template = PatternTemplate(
|
||||
@@ -50,95 +50,24 @@ class PatternTemplateStore:
|
||||
description="Default pattern template with full-frame rectangle",
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(f"Auto-created initial pattern template: {template.name} ({template_id})")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load templates from file."""
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
templates_data = data.get("pattern_templates", {})
|
||||
loaded = 0
|
||||
for template_id, template_dict in templates_data.items():
|
||||
try:
|
||||
template = PatternTemplate.from_dict(template_dict)
|
||||
self._templates[template_id] = template
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load pattern template {template_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} pattern templates from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pattern templates from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Pattern template store initialized with {len(self._templates)} templates")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all templates to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"pattern_templates": {
|
||||
template_id: template.to_dict()
|
||||
for template_id, template in self._templates.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save pattern templates to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_templates(self) -> List[PatternTemplate]:
|
||||
"""Get all pattern templates."""
|
||||
return list(self._templates.values())
|
||||
|
||||
def get_template(self, template_id: str) -> PatternTemplate:
|
||||
"""Get template by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Pattern template not found: {template_id}")
|
||||
return self._templates[template_id]
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
rectangles: Optional[List[KeyColorRectangle]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PatternTemplate:
|
||||
"""Create a new pattern template.
|
||||
|
||||
Args:
|
||||
name: Template name (must be unique)
|
||||
rectangles: List of named rectangles
|
||||
description: Optional description
|
||||
|
||||
Raises:
|
||||
ValueError: If template with same name exists
|
||||
"""
|
||||
for template in self._templates.values():
|
||||
if template.name == name:
|
||||
raise ValueError(f"Pattern template with name '{name}' already exists")
|
||||
self._check_name_unique(name)
|
||||
|
||||
if rectangles is None:
|
||||
rectangles = []
|
||||
|
||||
template_id = f"pat_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
template = PatternTemplate(
|
||||
id=template_id,
|
||||
@@ -147,9 +76,10 @@ class PatternTemplateStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created pattern template: {name} ({template_id})")
|
||||
@@ -161,48 +91,26 @@ class PatternTemplateStore:
|
||||
name: Optional[str] = None,
|
||||
rectangles: Optional[List[KeyColorRectangle]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PatternTemplate:
|
||||
"""Update an existing pattern template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Pattern template not found: {template_id}")
|
||||
|
||||
template = self._templates[template_id]
|
||||
template = self.get(template_id)
|
||||
|
||||
if name is not None:
|
||||
for tid, t in self._templates.items():
|
||||
if tid != template_id and t.name == name:
|
||||
raise ValueError(f"Pattern template with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if rectangles is not None:
|
||||
template.rectangles = rectangles
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated pattern template: {template_id}")
|
||||
return template
|
||||
|
||||
def delete_template(self, template_id: str) -> None:
|
||||
"""Delete a pattern template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Pattern template not found: {template_id}")
|
||||
|
||||
del self._templates[template_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted pattern template: {template_id}")
|
||||
|
||||
def get_targets_referencing(self, template_id: str, output_target_store) -> List[str]:
|
||||
"""Return names of KC targets that reference this template."""
|
||||
from wled_controller.storage.key_colors_output_target import KeyColorsOutputTarget
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Picture source data model with inheritance-based stream types."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -21,6 +21,7 @@ class PictureSource:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert stream to dictionary. Subclasses extend this."""
|
||||
@@ -31,6 +32,7 @@ class PictureSource:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
# Subclass fields default to None for backward compat
|
||||
"display_index": None,
|
||||
"capture_template_id": None,
|
||||
@@ -47,39 +49,40 @@ class PictureSource:
|
||||
sid: str = data["id"]
|
||||
name: str = data["name"]
|
||||
description: str | None = data.get("description")
|
||||
tags: list = data.get("tags", [])
|
||||
|
||||
raw_created = data.get("created_at")
|
||||
created_at: datetime = (
|
||||
datetime.fromisoformat(raw_created)
|
||||
if isinstance(raw_created, str)
|
||||
else raw_created if isinstance(raw_created, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
raw_updated = data.get("updated_at")
|
||||
updated_at: datetime = (
|
||||
datetime.fromisoformat(raw_updated)
|
||||
if isinstance(raw_updated, str)
|
||||
else raw_updated if isinstance(raw_updated, datetime)
|
||||
else datetime.utcnow()
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
if stream_type == "processed":
|
||||
return ProcessedPictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
source_stream_id=data.get("source_stream_id") or "",
|
||||
postprocessing_template_id=data.get("postprocessing_template_id") or "",
|
||||
)
|
||||
elif stream_type == "static_image":
|
||||
return StaticImagePictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
image_source=data.get("image_source") or "",
|
||||
)
|
||||
else:
|
||||
return ScreenCapturePictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
display_index=data.get("display_index") or 0,
|
||||
capture_template_id=data.get("capture_template_id") or "",
|
||||
target_fps=data.get("target_fps") or 30,
|
||||
|
||||
@@ -1,84 +1,43 @@
|
||||
"""Picture source storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.picture_source import (
|
||||
PictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
ProcessedPictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
StaticImagePictureSource,
|
||||
)
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PictureSourceStore:
|
||||
class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
"""Storage for picture sources.
|
||||
|
||||
Supports raw and processed stream types with cycle detection
|
||||
for processed streams that reference other streams.
|
||||
"""
|
||||
|
||||
_json_key = "picture_sources"
|
||||
_entity_name = "Picture source"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
"""Initialize picture source store.
|
||||
super().__init__(file_path, PictureSource.from_dict)
|
||||
|
||||
Args:
|
||||
file_path: Path to streams JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._streams: Dict[str, PictureSource] = {}
|
||||
self._load()
|
||||
# Backward-compatible aliases
|
||||
get_all_sources = BaseJsonStore.get_all
|
||||
get_source = BaseJsonStore.get
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load streams from file."""
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
# Legacy aliases (old code used "stream" naming)
|
||||
get_all_streams = BaseJsonStore.get_all
|
||||
get_stream = BaseJsonStore.get
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
streams_data = data.get("picture_sources", {})
|
||||
loaded = 0
|
||||
for stream_id, stream_dict in streams_data.items():
|
||||
try:
|
||||
stream = PictureSource.from_dict(stream_dict)
|
||||
self._streams[stream_id] = stream
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load picture source {stream_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} picture sources from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load picture sources from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Picture source store initialized with {len(self._streams)} streams")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all streams to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"picture_sources": {
|
||||
stream_id: stream.to_dict()
|
||||
for stream_id, stream in self._streams.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save picture sources to {self.file_path}: {e}")
|
||||
raise
|
||||
# ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _detect_cycle(self, source_stream_id: str, exclude_stream_id: Optional[str] = None) -> bool:
|
||||
"""Detect if following the source chain from source_stream_id would create a cycle.
|
||||
@@ -100,7 +59,7 @@ class PictureSourceStore:
|
||||
return True
|
||||
visited.add(current_id)
|
||||
|
||||
current_stream = self._streams.get(current_id)
|
||||
current_stream = self._items.get(current_id)
|
||||
if not current_stream:
|
||||
break
|
||||
if not isinstance(current_stream, ProcessedPictureSource):
|
||||
@@ -109,19 +68,7 @@ class PictureSourceStore:
|
||||
|
||||
return False
|
||||
|
||||
def get_all_streams(self) -> List[PictureSource]:
|
||||
"""Get all picture sources."""
|
||||
return list(self._streams.values())
|
||||
|
||||
def get_stream(self, stream_id: str) -> PictureSource:
|
||||
"""Get stream by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If stream not found
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
raise ValueError(f"Picture source not found: {stream_id}")
|
||||
return self._streams[stream_id]
|
||||
# ── CRUD ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_stream(
|
||||
self,
|
||||
@@ -134,6 +81,7 @@ class PictureSourceStore:
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PictureSource:
|
||||
"""Create a new picture source.
|
||||
|
||||
@@ -167,7 +115,7 @@ class PictureSourceStore:
|
||||
if not postprocessing_template_id:
|
||||
raise ValueError("Processed streams require postprocessing_template_id")
|
||||
# Validate source stream exists
|
||||
if source_stream_id not in self._streams:
|
||||
if source_stream_id not in self._items:
|
||||
raise ValueError(f"Source stream not found: {source_stream_id}")
|
||||
# Check for cycles
|
||||
if self._detect_cycle(source_stream_id):
|
||||
@@ -177,16 +125,15 @@ class PictureSourceStore:
|
||||
raise ValueError("Static image streams require image_source")
|
||||
|
||||
# Check for duplicate name
|
||||
for stream in self._streams.values():
|
||||
if stream.name == name:
|
||||
raise ValueError(f"Picture source with name '{name}' already exists")
|
||||
self._check_name_unique(name)
|
||||
|
||||
stream_id = f"ps_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
common = dict(
|
||||
id=stream_id, name=name, stream_type=stream_type,
|
||||
created_at=now, updated_at=now, description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
stream: PictureSource
|
||||
@@ -209,7 +156,7 @@ class PictureSourceStore:
|
||||
image_source=image_source, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
self._streams[stream_id] = stream
|
||||
self._items[stream_id] = stream
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created picture source: {name} ({stream_id}, type={stream_type})")
|
||||
@@ -226,28 +173,29 @@ class PictureSourceStore:
|
||||
postprocessing_template_id: Optional[str] = None,
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PictureSource:
|
||||
"""Update an existing picture source.
|
||||
|
||||
Raises:
|
||||
ValueError: If stream not found, validation fails, or cycle detected
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
raise ValueError(f"Picture source not found: {stream_id}")
|
||||
|
||||
stream = self._streams[stream_id]
|
||||
stream = self.get(stream_id)
|
||||
|
||||
# If changing source_stream_id on a processed stream, check for cycles
|
||||
if source_stream_id is not None and isinstance(stream, ProcessedPictureSource):
|
||||
if source_stream_id not in self._streams:
|
||||
if source_stream_id not in self._items:
|
||||
raise ValueError(f"Source stream not found: {source_stream_id}")
|
||||
if self._detect_cycle(source_stream_id, exclude_stream_id=stream_id):
|
||||
raise ValueError("Cycle detected in stream chain")
|
||||
|
||||
if name is not None:
|
||||
self._check_name_unique(name, exclude_id=stream_id)
|
||||
stream.name = name
|
||||
if description is not None:
|
||||
stream.description = description
|
||||
if tags is not None:
|
||||
stream.tags = tags
|
||||
|
||||
if isinstance(stream, ScreenCapturePictureSource):
|
||||
if display_index is not None:
|
||||
@@ -265,7 +213,7 @@ class PictureSourceStore:
|
||||
if image_source is not None:
|
||||
stream.image_source = image_source
|
||||
|
||||
stream.updated_at = datetime.utcnow()
|
||||
stream.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
self._save()
|
||||
|
||||
@@ -278,22 +226,29 @@ class PictureSourceStore:
|
||||
Raises:
|
||||
ValueError: If stream not found or is referenced by another stream
|
||||
"""
|
||||
if stream_id not in self._streams:
|
||||
if stream_id not in self._items:
|
||||
raise ValueError(f"Picture source not found: {stream_id}")
|
||||
|
||||
# Check if any other stream references this one as source
|
||||
for other_stream in self._streams.values():
|
||||
for other_stream in self._items.values():
|
||||
if isinstance(other_stream, ProcessedPictureSource) and other_stream.source_stream_id == stream_id:
|
||||
raise ValueError(
|
||||
f"Cannot delete stream '{self._streams[stream_id].name}': "
|
||||
f"Cannot delete stream '{self._items[stream_id].name}': "
|
||||
f"it is referenced by stream '{other_stream.name}'"
|
||||
)
|
||||
|
||||
del self._streams[stream_id]
|
||||
del self._items[stream_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted picture source: {stream_id}")
|
||||
|
||||
# Also expose as delete_source for consistency
|
||||
def delete_source(self, source_id: str) -> None:
|
||||
"""Alias for delete_stream with reference checking."""
|
||||
self.delete_stream(source_id)
|
||||
|
||||
# ── Query helpers ─────────────────────────────────────────────────
|
||||
|
||||
def get_targets_referencing(self, stream_id: str, target_store) -> List[str]:
|
||||
"""Return names of targets that reference this stream."""
|
||||
return target_store.get_targets_referencing_source(stream_id)
|
||||
@@ -324,7 +279,7 @@ class PictureSourceStore:
|
||||
raise ValueError(f"Cycle detected in stream chain at {current_id}")
|
||||
visited.add(current_id)
|
||||
|
||||
stream = self.get_stream(current_id)
|
||||
stream = self.get(current_id)
|
||||
|
||||
if not isinstance(stream, ProcessedPictureSource):
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Postprocessing template data model."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
@@ -17,6 +17,7 @@ class PostprocessingTemplate:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert template to dictionary."""
|
||||
@@ -27,6 +28,7 @@ class PostprocessingTemplate:
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -40,9 +42,10 @@ class PostprocessingTemplate:
|
||||
filters=filters,
|
||||
created_at=datetime.fromisoformat(data["created_at"])
|
||||
if isinstance(data.get("created_at"), str)
|
||||
else data.get("created_at", datetime.utcnow()),
|
||||
else data.get("created_at", datetime.now(timezone.utc)),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"])
|
||||
if isinstance(data.get("updated_at"), str)
|
||||
else data.get("updated_at", datetime.utcnow()),
|
||||
else data.get("updated_at", datetime.now(timezone.utc)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
)
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
"""Postprocessing template storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.core.filters.filter_instance import FilterInstance
|
||||
from wled_controller.core.filters.registry import FilterRegistry
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.picture_source import ProcessedPictureSource
|
||||
from wled_controller.storage.postprocessing_template import PostprocessingTemplate
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PostprocessingTemplateStore:
|
||||
class PostprocessingTemplateStore(BaseJsonStore[PostprocessingTemplate]):
|
||||
"""Storage for postprocessing templates.
|
||||
|
||||
All templates are persisted to the JSON file.
|
||||
On startup, if no templates exist, a default one is auto-created.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
"""Initialize postprocessing template store.
|
||||
_json_key = "postprocessing_templates"
|
||||
_entity_name = "Postprocessing template"
|
||||
_version = "2.0.0"
|
||||
|
||||
Args:
|
||||
file_path: Path to templates JSON file
|
||||
"""
|
||||
self.file_path = Path(file_path)
|
||||
self._templates: Dict[str, PostprocessingTemplate] = {}
|
||||
self._load()
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, PostprocessingTemplate.from_dict)
|
||||
self._ensure_initial_template()
|
||||
|
||||
# Backward-compatible aliases
|
||||
get_all_templates = BaseJsonStore.get_all
|
||||
get_template = BaseJsonStore.get
|
||||
delete_template = BaseJsonStore.delete
|
||||
|
||||
def _ensure_initial_template(self) -> None:
|
||||
"""Auto-create a default postprocessing template if none exist."""
|
||||
if self._templates:
|
||||
if self._items:
|
||||
return
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
template_id = f"pp_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
template = PostprocessingTemplate(
|
||||
@@ -54,89 +55,18 @@ class PostprocessingTemplateStore:
|
||||
description="Default postprocessing template",
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
logger.info(f"Auto-created initial postprocessing template: {template.name} ({template_id})")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load templates from file."""
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
templates_data = data.get("postprocessing_templates", {})
|
||||
loaded = 0
|
||||
for template_id, template_dict in templates_data.items():
|
||||
try:
|
||||
template = PostprocessingTemplate.from_dict(template_dict)
|
||||
self._templates[template_id] = template
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to load postprocessing template {template_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} postprocessing templates from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load postprocessing templates from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Postprocessing template store initialized with {len(self._templates)} templates")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save all templates to file."""
|
||||
try:
|
||||
data = {
|
||||
"version": "2.0.0",
|
||||
"postprocessing_templates": {
|
||||
template_id: template.to_dict()
|
||||
for template_id, template in self._templates.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save postprocessing templates to {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_all_templates(self) -> List[PostprocessingTemplate]:
|
||||
"""Get all postprocessing templates."""
|
||||
return list(self._templates.values())
|
||||
|
||||
def get_template(self, template_id: str) -> PostprocessingTemplate:
|
||||
"""Get template by ID.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||
return self._templates[template_id]
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
name: str,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PostprocessingTemplate:
|
||||
"""Create a new postprocessing template.
|
||||
|
||||
Args:
|
||||
name: Template name (must be unique)
|
||||
filters: Ordered list of filter instances
|
||||
description: Optional description
|
||||
|
||||
Raises:
|
||||
ValueError: If template with same name exists or invalid filter_id
|
||||
"""
|
||||
for template in self._templates.values():
|
||||
if template.name == name:
|
||||
raise ValueError(f"Postprocessing template with name '{name}' already exists")
|
||||
self._check_name_unique(name)
|
||||
|
||||
if filters is None:
|
||||
filters = []
|
||||
@@ -147,7 +77,7 @@ class PostprocessingTemplateStore:
|
||||
raise ValueError(f"Unknown filter type: '{fi.filter_id}'")
|
||||
|
||||
template_id = f"pp_{uuid.uuid4().hex[:8]}"
|
||||
now = datetime.utcnow()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
template = PostprocessingTemplate(
|
||||
id=template_id,
|
||||
@@ -156,9 +86,10 @@ class PostprocessingTemplateStore:
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
description=description,
|
||||
tags=tags or [],
|
||||
)
|
||||
|
||||
self._templates[template_id] = template
|
||||
self._items[template_id] = template
|
||||
self._save()
|
||||
|
||||
logger.info(f"Created postprocessing template: {name} ({template_id})")
|
||||
@@ -170,21 +101,12 @@ class PostprocessingTemplateStore:
|
||||
name: Optional[str] = None,
|
||||
filters: Optional[List[FilterInstance]] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> PostprocessingTemplate:
|
||||
"""Update an existing postprocessing template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found or invalid filter_id
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||
|
||||
template = self._templates[template_id]
|
||||
template = self.get(template_id)
|
||||
|
||||
if name is not None:
|
||||
for tid, t in self._templates.items():
|
||||
if tid != template_id and t.name == name:
|
||||
raise ValueError(f"Postprocessing template with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=template_id)
|
||||
template.name = name
|
||||
if filters is not None:
|
||||
# Validate filter IDs
|
||||
@@ -194,28 +116,15 @@ class PostprocessingTemplateStore:
|
||||
template.filters = filters
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if tags is not None:
|
||||
template.tags = tags
|
||||
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
template.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
|
||||
logger.info(f"Updated postprocessing template: {template_id}")
|
||||
return template
|
||||
|
||||
def delete_template(self, template_id: str) -> None:
|
||||
"""Delete a postprocessing template.
|
||||
|
||||
Raises:
|
||||
ValueError: If template not found or is referenced by a picture source
|
||||
"""
|
||||
if template_id not in self._templates:
|
||||
raise ValueError(f"Postprocessing template not found: {template_id}")
|
||||
|
||||
del self._templates[template_id]
|
||||
self._save()
|
||||
|
||||
logger.info(f"Deleted postprocessing template: {template_id}")
|
||||
|
||||
def resolve_filter_instances(self, filter_instances, _visited=None):
|
||||
"""Recursively resolve filter instances, expanding filter_template references.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Scene preset data models — snapshot of target state."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
|
||||
@@ -42,16 +42,18 @@ class ScenePreset:
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
targets: List[TargetSnapshot] = field(default_factory=list)
|
||||
order: int = 0
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"targets": [t.to_dict() for t in self.targets],
|
||||
"order": self.order,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
@@ -64,8 +66,9 @@ class ScenePreset:
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
description=data.get("description", ""),
|
||||
tags=data.get("tags", []),
|
||||
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
|
||||
order=data.get("order", 0),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
||||
)
|
||||
|
||||
@@ -1,79 +1,40 @@
|
||||
"""Scene preset storage using JSON files."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore
|
||||
from wled_controller.storage.scene_preset import ScenePreset, TargetSnapshot
|
||||
from wled_controller.utils import atomic_write_json, get_logger
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScenePresetStore:
|
||||
class ScenePresetStore(BaseJsonStore[ScenePreset]):
|
||||
"""Persistent storage for scene presets."""
|
||||
|
||||
_json_key = "scene_presets"
|
||||
_entity_name = "Scene preset"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = Path(file_path)
|
||||
self._presets: Dict[str, ScenePreset] = {}
|
||||
self._load()
|
||||
super().__init__(file_path, ScenePreset.from_dict)
|
||||
|
||||
def _load(self) -> None:
|
||||
if not self.file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
presets_data = data.get("scene_presets", {})
|
||||
loaded = 0
|
||||
for preset_id, preset_dict in presets_data.items():
|
||||
try:
|
||||
preset = ScenePreset.from_dict(preset_dict)
|
||||
self._presets[preset_id] = preset
|
||||
loaded += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load scene preset {preset_id}: {e}", exc_info=True)
|
||||
|
||||
if loaded > 0:
|
||||
logger.info(f"Loaded {loaded} scene presets from storage")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load scene presets from {self.file_path}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Scene preset store initialized with {len(self._presets)} presets")
|
||||
|
||||
def _save(self) -> None:
|
||||
try:
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"scene_presets": {
|
||||
pid: p.to_dict() for pid, p in self._presets.items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(self.file_path, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save scene presets to {self.file_path}: {e}")
|
||||
raise
|
||||
# Backward-compatible aliases
|
||||
get_preset = BaseJsonStore.get
|
||||
delete_preset = BaseJsonStore.delete
|
||||
|
||||
def get_all_presets(self) -> List[ScenePreset]:
|
||||
return sorted(self._presets.values(), key=lambda p: p.order)
|
||||
"""Get all presets sorted by order field."""
|
||||
return sorted(self._items.values(), key=lambda p: p.order)
|
||||
|
||||
def get_preset(self, preset_id: str) -> ScenePreset:
|
||||
if preset_id not in self._presets:
|
||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||
return self._presets[preset_id]
|
||||
# Override get_all to also sort by order for consistency
|
||||
def get_all(self) -> List[ScenePreset]:
|
||||
return self.get_all_presets()
|
||||
|
||||
def create_preset(self, preset: ScenePreset) -> ScenePreset:
|
||||
for p in self._presets.values():
|
||||
if p.name == preset.name:
|
||||
raise ValueError(f"Scene preset with name '{preset.name}' already exists")
|
||||
self._check_name_unique(preset.name)
|
||||
|
||||
self._presets[preset.id] = preset
|
||||
self._items[preset.id] = preset
|
||||
self._save()
|
||||
logger.info(f"Created scene preset: {preset.name} ({preset.id})")
|
||||
return preset
|
||||
@@ -85,16 +46,12 @@ class ScenePresetStore:
|
||||
description: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
targets: Optional[List[TargetSnapshot]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> ScenePreset:
|
||||
if preset_id not in self._presets:
|
||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||
|
||||
preset = self._presets[preset_id]
|
||||
preset = self.get(preset_id)
|
||||
|
||||
if name is not None:
|
||||
for pid, p in self._presets.items():
|
||||
if pid != preset_id and p.name == name:
|
||||
raise ValueError(f"Scene preset with name '{name}' already exists")
|
||||
self._check_name_unique(name, exclude_id=preset_id)
|
||||
preset.name = name
|
||||
if description is not None:
|
||||
preset.description = description
|
||||
@@ -102,31 +59,20 @@ class ScenePresetStore:
|
||||
preset.order = order
|
||||
if targets is not None:
|
||||
preset.targets = targets
|
||||
if tags is not None:
|
||||
preset.tags = tags
|
||||
|
||||
preset.updated_at = datetime.utcnow()
|
||||
preset.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
logger.info(f"Updated scene preset: {preset_id}")
|
||||
return preset
|
||||
|
||||
def recapture_preset(self, preset_id: str, preset: ScenePreset) -> ScenePreset:
|
||||
"""Replace snapshot data of an existing preset (recapture current state)."""
|
||||
if preset_id not in self._presets:
|
||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||
existing = self.get(preset_id)
|
||||
|
||||
existing = self._presets[preset_id]
|
||||
existing.targets = preset.targets
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
self._save()
|
||||
logger.info(f"Recaptured scene preset: {preset_id}")
|
||||
return existing
|
||||
|
||||
def delete_preset(self, preset_id: str) -> None:
|
||||
if preset_id not in self._presets:
|
||||
raise ValueError(f"Scene preset not found: {preset_id}")
|
||||
|
||||
del self._presets[preset_id]
|
||||
self._save()
|
||||
logger.info(f"Deleted scene preset: {preset_id}")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._presets)
|
||||
|
||||
@@ -5,9 +5,9 @@ color strip sources. Multiple CSS sources referencing the same clock
|
||||
animate in sync and share speed / pause / resume / reset controls.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -20,6 +20,7 @@ class SyncClock:
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -27,6 +28,7 @@ class SyncClock:
|
||||
"name": self.name,
|
||||
"speed": self.speed,
|
||||
"description": self.description,
|
||||
"tags": self.tags,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
@@ -38,6 +40,7 @@ class SyncClock:
|
||||
name=data["name"],
|
||||
speed=float(data.get("speed", 1.0)),
|
||||
description=data.get("description"),
|
||||
tags=data.get("tags", []),
|
||||
created_at=datetime.fromisoformat(data["created_at"]),
|
||||
updated_at=datetime.fromisoformat(data["updated_at"]),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user