From 294d704eb01afff86126200c192ee94fe45cd08b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 15 Mar 2026 02:16:59 +0300 Subject: [PATCH] Add CSPT entity, processed CSS source type, reverse filter, and UI improvements - Add Color Strip Processing Template (CSPT) entity: reusable filter chains for 1D LED strip postprocessing (backend, storage, API, frontend CRUD) - Add "processed" color strip source type that wraps another CSS source and applies a CSPT filter chain (dataclass, stream, schema, modal, cards) - Add Reverse filter for strip LED order reversal - Add CSPT and processed CSS nodes/edges to visual graph editor - Add CSPT test preview WS endpoint with input source selection - Add device settings CSPT template selector (add + edit modals with hints) - Use icon grids for palette quantization preset selector in filter lists - Use EntitySelect for template references and test modal source selectors - Fix filters.css_filter_template.desc missing localization - Fix icon grid cell height inequality (grid-auto-rows: 1fr) - Rename "Processed" subtab to "Processing Templates" - Localize all new strings (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) --- TODO.md | 33 ++ server/src/wled_controller/api/__init__.py | 2 + server/src/wled_controller/api/auth.py | 13 + .../src/wled_controller/api/dependencies.py | 185 +++--- .../api/routes/_test_helpers.py | 13 +- .../src/wled_controller/api/routes/audio.py | 10 +- .../api/routes/audio_sources.py | 14 +- .../api/routes/audio_templates.py | 15 +- .../api/routes/color_strip_processing.py | 265 +++++++++ .../api/routes/color_strip_sources.py | 10 +- .../src/wled_controller/api/routes/devices.py | 77 ++- .../api/routes/output_targets.py | 46 +- .../api/routes/picture_sources.py | 116 ++-- .../src/wled_controller/api/routes/system.py | 58 +- .../wled_controller/api/routes/templates.py | 46 ++ .../api/routes/value_sources.py | 14 +- .../api/schemas/color_strip_processing.py | 45 ++ .../api/schemas/color_strip_sources.py | 24 +- .../wled_controller/api/schemas/devices.py | 3 + server/src/wled_controller/config.py | 1 + .../wled_controller/core/filters/__init__.py | 2 + .../wled_controller/core/filters/auto_crop.py | 1 + .../src/wled_controller/core/filters/base.py | 35 ++ .../core/filters/css_filter_template.py | 46 ++ .../core/filters/downscaler.py | 1 + .../core/filters/filter_template.py | 1 + .../src/wled_controller/core/filters/flip.py | 1 + .../core/filters/frame_interpolation.py | 19 +- .../core/filters/palette_quantization.py | 2 +- .../wled_controller/core/filters/reverse.py | 30 + .../core/processing/audio_stream.py | 9 +- .../core/processing/color_strip_stream.py | 228 ++------ .../processing/color_strip_stream_manager.py | 9 +- .../core/processing/composite_stream.py | 37 +- .../core/processing/effect_stream.py | 35 +- .../core/processing/live_stream_manager.py | 7 +- .../core/processing/mapped_stream.py | 12 +- .../core/processing/metrics_history.py | 7 +- .../processing/os_notification_listener.py | 5 + .../core/processing/processed_stream.py | 159 +++++ .../core/processing/processor_manager.py | 23 +- .../core/processing/target_processor.py | 2 + .../core/processing/wled_target_processor.py | 45 ++ server/src/wled_controller/main.py | 12 +- .../wled_controller/static/css/components.css | 1 + server/src/wled_controller/static/js/app.js | 18 +- .../static/js/core/graph-layout.js | 26 + .../static/js/core/graph-nodes.js | 2 + .../wled_controller/static/js/core/icons.js | 2 + .../wled_controller/static/js/core/state.js | 23 + .../static/js/features/color-strips.js | 219 +++++-- .../static/js/features/device-discovery.js | 34 +- .../static/js/features/devices.js | 39 +- .../static/js/features/graph-editor.js | 7 +- .../static/js/features/streams.js | 520 ++++++++++++++--- .../wled_controller/static/locales/en.json | 32 + .../wled_controller/static/locales/ru.json | 32 + .../wled_controller/static/locales/zh.json | 32 + .../src/wled_controller/storage/base_store.py | 14 +- .../color_strip_processing_template.py | 53 ++ .../color_strip_processing_template_store.py | 170 ++++++ .../storage/color_strip_source.py | 547 +++++++++++++++--- .../storage/color_strip_store.py | 457 ++------------- .../wled_controller/storage/device_store.py | 218 ++----- .../src/wled_controller/templates/index.html | 1 + .../templates/modals/add-device.html | 10 + .../templates/modals/cspt-modal.html | 40 ++ .../templates/modals/css-editor.html | 77 +-- .../templates/modals/device-settings.html | 11 + .../templates/modals/test-css-source.html | 6 + server/src/wled_controller/utils/gpu.py | 24 + server/tests/test_device_store.py | 75 +-- 72 files changed, 2992 insertions(+), 1416 deletions(-) create mode 100644 server/src/wled_controller/api/routes/color_strip_processing.py create mode 100644 server/src/wled_controller/api/schemas/color_strip_processing.py create mode 100644 server/src/wled_controller/core/filters/css_filter_template.py create mode 100644 server/src/wled_controller/core/filters/reverse.py create mode 100644 server/src/wled_controller/core/processing/processed_stream.py create mode 100644 server/src/wled_controller/storage/color_strip_processing_template.py create mode 100644 server/src/wled_controller/storage/color_strip_processing_template_store.py create mode 100644 server/src/wled_controller/templates/modals/cspt-modal.html create mode 100644 server/src/wled_controller/utils/gpu.py diff --git a/TODO.md b/TODO.md index 5e502f9..bf7132a 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,39 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort - [ ] `P2` **Distinguish "crashed" vs "stopped" in dashboard** — `metrics.last_error` is already populated - [ ] `P3` **CSS source import/export** — share individual sources without full config backup +## Backend Review Fixes (2026-03-14) + +### Performance +- [x] **P1** PIL blocking in async handlers → `asyncio.to_thread` +- [x] **P2** `subprocess.run` blocking event loop → `asyncio.create_subprocess_exec` +- [x] **P3** Audio enum blocking async → `asyncio.to_thread` +- [x] **P4** Display enum blocking async → `asyncio.to_thread` +- [x] **P5** `colorsys` scalar loop in hot path → vectorize numpy +- [x] **P6** `MappedStream` per-frame allocation → double-buffer +- [x] **P7** Audio/effect per-frame temp allocs → pre-allocate +- [x] **P8** Blocking `httpx.get` in stream init → documented (callers use to_thread) +- [x] **P9** No-cache middleware runs on all requests → scope to static +- [x] **P10** Sync file I/O in async handlers (stores) → documented as accepted risk (< 5ms) +- [x] **P11** `frame_time` float division every loop iter → cache field +- [x] **P12** `_check_name_unique` O(N) + no lock → add threading.Lock +- [x] **P13** Imports inside 1-Hz metrics loop → move to module level + +### Code Quality + +- [x] **Q1** `DeviceStore` not using `BaseJsonStore` +- [x] **Q2** `ColorStripStore` 275-line god methods → factory dispatch +- [x] **Q3** Layer violation: core imports from routes → extract to utility +- [x] **Q4** 20+ field-by-field update in Device/routes → dataclass + generic update +- [x] **Q5** WebSocket auth copy-pasted 9x → extract helper +- [x] **Q6** `set_device_brightness` bypasses store → use update_device +- [x] **Q7** DI via 16+ module globals → registry pattern +- [x] **Q8** `_css_to_response` 30+ getattr → polymorphic to_response +- [x] **Q9** Private attribute access across modules → expose as properties +- [x] **Q10** `ColorStripSource.to_dict()` emits ~25 nulls → per-subclass override +- [x] **Q11** `DeviceStore.get_device` returns None vs raises → raise ValueError +- [x] **Q12** `list_all_tags` fragile method-name probing → use get_all() +- [x] **Q13** Route create/update pass 30 individual fields → **kwargs + ## UX - [ ] `P1` **Collapse dashboard running target stats** — Show only FPS chart by default; uptime, errors, and pipeline timings in an expandable section collapsed by default diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py index 60cdd38..82f30c3 100644 --- a/server/src/wled_controller/api/__init__.py +++ b/server/src/wled_controller/api/__init__.py @@ -18,6 +18,7 @@ from .routes.automations import router as automations_router from .routes.scene_presets import router as scene_presets_router from .routes.webhooks import router as webhooks_router from .routes.sync_clocks import router as sync_clocks_router +from .routes.color_strip_processing import router as cspt_router router = APIRouter() router.include_router(system_router) @@ -36,5 +37,6 @@ router.include_router(automations_router) router.include_router(scene_presets_router) router.include_router(webhooks_router) router.include_router(sync_clocks_router) +router.include_router(cspt_router) __all__ = ["router"] diff --git a/server/src/wled_controller/api/auth.py b/server/src/wled_controller/api/auth.py index 9bcade2..c3a9e75 100644 --- a/server/src/wled_controller/api/auth.py +++ b/server/src/wled_controller/api/auth.py @@ -75,3 +75,16 @@ def verify_api_key( # Dependency for protected routes # Returns the label/identifier of the authenticated client AuthRequired = Annotated[str, Depends(verify_api_key)] + + +def verify_ws_token(token: str) -> bool: + """Check a WebSocket query-param token against configured API keys. + + Use this for WebSocket endpoints where FastAPI's Depends() isn't available. + """ + config = get_config() + if token and config.auth.api_keys: + for _label, api_key in config.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + return True + return False diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py index f3ee254..0416850 100644 --- a/server/src/wled_controller/api/dependencies.py +++ b/server/src/wled_controller/api/dependencies.py @@ -1,4 +1,10 @@ -"""Dependency injection for API routes.""" +"""Dependency injection for API routes. + +Uses a registry dict instead of individual module-level globals. +All getter function signatures remain unchanged for FastAPI Depends() compatibility. +""" + +from typing import Any, Dict, Type, TypeVar from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.storage import DeviceStore @@ -14,147 +20,101 @@ from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.sync_clock_store import SyncClockStore +from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.backup.auto_backup import AutoBackupEngine from wled_controller.core.processing.sync_clock_manager import SyncClockManager -# Global instances (initialized in main.py) -_auto_backup_engine: AutoBackupEngine | None = None -_device_store: DeviceStore | None = None -_template_store: TemplateStore | None = None -_pp_template_store: PostprocessingTemplateStore | None = None -_pattern_template_store: PatternTemplateStore | None = None -_picture_source_store: PictureSourceStore | None = None -_output_target_store: OutputTargetStore | None = None -_color_strip_store: ColorStripStore | None = None -_audio_source_store: AudioSourceStore | None = None -_audio_template_store: AudioTemplateStore | None = None -_value_source_store: ValueSourceStore | None = None -_processor_manager: ProcessorManager | None = None -_automation_store: AutomationStore | None = None -_scene_preset_store: ScenePresetStore | None = None -_automation_engine: AutomationEngine | None = None -_sync_clock_store: SyncClockStore | None = None -_sync_clock_manager: SyncClockManager | None = None +T = TypeVar("T") + +# Central dependency registry — keyed by type or string label +_deps: Dict[str, Any] = {} + + +def _get(key: str, label: str) -> Any: + """Get a dependency by key, raising RuntimeError if not initialized.""" + dep = _deps.get(key) + if dep is None: + raise RuntimeError(f"{label} not initialized") + return dep + + +# ── Typed getters (unchanged signatures for FastAPI Depends()) ────────── def get_device_store() -> DeviceStore: - """Get device store dependency.""" - if _device_store is None: - raise RuntimeError("Device store not initialized") - return _device_store + return _get("device_store", "Device store") def get_template_store() -> TemplateStore: - """Get template store dependency.""" - if _template_store is None: - raise RuntimeError("Template store not initialized") - return _template_store + return _get("template_store", "Template store") def get_pp_template_store() -> PostprocessingTemplateStore: - """Get postprocessing template store dependency.""" - if _pp_template_store is None: - raise RuntimeError("Postprocessing template store not initialized") - return _pp_template_store + return _get("pp_template_store", "Postprocessing template store") def get_pattern_template_store() -> PatternTemplateStore: - """Get pattern template store dependency.""" - if _pattern_template_store is None: - raise RuntimeError("Pattern template store not initialized") - return _pattern_template_store + return _get("pattern_template_store", "Pattern template store") def get_picture_source_store() -> PictureSourceStore: - """Get picture source store dependency.""" - if _picture_source_store is None: - raise RuntimeError("Picture source store not initialized") - return _picture_source_store + return _get("picture_source_store", "Picture source store") def get_output_target_store() -> OutputTargetStore: - """Get output target store dependency.""" - if _output_target_store is None: - raise RuntimeError("Picture target store not initialized") - return _output_target_store + return _get("output_target_store", "Output target store") def get_color_strip_store() -> ColorStripStore: - """Get color strip store dependency.""" - if _color_strip_store is None: - raise RuntimeError("Color strip store not initialized") - return _color_strip_store + return _get("color_strip_store", "Color strip store") def get_audio_source_store() -> AudioSourceStore: - """Get audio source store dependency.""" - if _audio_source_store is None: - raise RuntimeError("Audio source store not initialized") - return _audio_source_store + return _get("audio_source_store", "Audio source store") def get_audio_template_store() -> AudioTemplateStore: - """Get audio template store dependency.""" - if _audio_template_store is None: - raise RuntimeError("Audio template store not initialized") - return _audio_template_store + return _get("audio_template_store", "Audio template store") def get_value_source_store() -> ValueSourceStore: - """Get value source store dependency.""" - if _value_source_store is None: - raise RuntimeError("Value source store not initialized") - return _value_source_store + return _get("value_source_store", "Value source store") def get_processor_manager() -> ProcessorManager: - """Get processor manager dependency.""" - if _processor_manager is None: - raise RuntimeError("Processor manager not initialized") - return _processor_manager + return _get("processor_manager", "Processor manager") def get_automation_store() -> AutomationStore: - """Get automation store dependency.""" - if _automation_store is None: - raise RuntimeError("Automation store not initialized") - return _automation_store + return _get("automation_store", "Automation store") def get_scene_preset_store() -> ScenePresetStore: - """Get scene preset store dependency.""" - if _scene_preset_store is None: - raise RuntimeError("Scene preset store not initialized") - return _scene_preset_store + return _get("scene_preset_store", "Scene preset store") def get_automation_engine() -> AutomationEngine: - """Get automation engine dependency.""" - if _automation_engine is None: - raise RuntimeError("Automation engine not initialized") - return _automation_engine + return _get("automation_engine", "Automation engine") def get_auto_backup_engine() -> AutoBackupEngine: - """Get auto-backup engine dependency.""" - if _auto_backup_engine is None: - raise RuntimeError("Auto-backup engine not initialized") - return _auto_backup_engine + return _get("auto_backup_engine", "Auto-backup engine") def get_sync_clock_store() -> SyncClockStore: - """Get sync clock store dependency.""" - if _sync_clock_store is None: - raise RuntimeError("Sync clock store not initialized") - return _sync_clock_store + return _get("sync_clock_store", "Sync clock store") def get_sync_clock_manager() -> SyncClockManager: - """Get sync clock manager dependency.""" - if _sync_clock_manager is None: - raise RuntimeError("Sync clock manager not initialized") - return _sync_clock_manager + return _get("sync_clock_manager", "Sync clock manager") + + +def get_cspt_store() -> ColorStripProcessingTemplateStore: + return _get("cspt_store", "Color strip processing template store") + + +# ── Event helper ──────────────────────────────────────────────────────── def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: @@ -165,8 +125,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: action: "created", "updated", or "deleted" entity_id: The entity's unique ID """ - if _processor_manager is not None: - _processor_manager.fire_event({ + pm = _deps.get("processor_manager") + if pm is not None: + pm.fire_event({ "type": "entity_changed", "entity_type": entity_type, "action": action, @@ -174,6 +135,9 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: }) +# ── Initialization ────────────────────────────────────────────────────── + + def init_dependencies( device_store: DeviceStore, template_store: TemplateStore, @@ -192,27 +156,26 @@ def init_dependencies( auto_backup_engine: AutoBackupEngine | None = None, sync_clock_store: SyncClockStore | None = None, sync_clock_manager: SyncClockManager | None = None, + cspt_store: ColorStripProcessingTemplateStore | None = None, ): """Initialize global dependencies.""" - global _device_store, _template_store, _processor_manager - global _pp_template_store, _pattern_template_store, _picture_source_store, _output_target_store - global _color_strip_store, _audio_source_store, _audio_template_store - global _value_source_store, _automation_store, _scene_preset_store, _automation_engine, _auto_backup_engine - global _sync_clock_store, _sync_clock_manager - _device_store = device_store - _template_store = template_store - _processor_manager = processor_manager - _pp_template_store = pp_template_store - _pattern_template_store = pattern_template_store - _picture_source_store = picture_source_store - _output_target_store = output_target_store - _color_strip_store = color_strip_store - _audio_source_store = audio_source_store - _audio_template_store = audio_template_store - _value_source_store = value_source_store - _automation_store = automation_store - _scene_preset_store = scene_preset_store - _automation_engine = automation_engine - _auto_backup_engine = auto_backup_engine - _sync_clock_store = sync_clock_store - _sync_clock_manager = sync_clock_manager + _deps.update({ + "device_store": device_store, + "template_store": template_store, + "processor_manager": processor_manager, + "pp_template_store": pp_template_store, + "pattern_template_store": pattern_template_store, + "picture_source_store": picture_source_store, + "output_target_store": output_target_store, + "color_strip_store": color_strip_store, + "audio_source_store": audio_source_store, + "audio_template_store": audio_template_store, + "value_source_store": value_source_store, + "automation_store": automation_store, + "scene_preset_store": scene_preset_store, + "automation_engine": automation_engine, + "auto_backup_engine": auto_backup_engine, + "sync_clock_store": sync_clock_store, + "sync_clock_manager": sync_clock_manager, + "cspt_store": cspt_store, + }) diff --git a/server/src/wled_controller/api/routes/_test_helpers.py b/server/src/wled_controller/api/routes/_test_helpers.py index 6a61879..0af52e1 100644 --- a/server/src/wled_controller/api/routes/_test_helpers.py +++ b/server/src/wled_controller/api/routes/_test_helpers.py @@ -26,13 +26,12 @@ PREVIEW_JPEG_QUALITY = 70 def authenticate_ws_token(token: str) -> bool: - """Check a WebSocket query-param token against configured API keys.""" - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - return True - return False + """Check a WebSocket query-param token against configured API keys. + + Delegates to the canonical implementation in auth module. + """ + from wled_controller.api.auth import verify_ws_token + return verify_ws_token(token) def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str: diff --git a/server/src/wled_controller/api/routes/audio.py b/server/src/wled_controller/api/routes/audio.py index 5c66a6a..3971302 100644 --- a/server/src/wled_controller/api/routes/audio.py +++ b/server/src/wled_controller/api/routes/audio.py @@ -1,5 +1,7 @@ """Audio device routes: enumerate available audio devices.""" +import asyncio + from fastapi import APIRouter from wled_controller.api.auth import AuthRequired @@ -17,8 +19,12 @@ async def list_audio_devices(_auth: AuthRequired): filter by the selected audio template's engine type. """ try: - devices = AudioCaptureManager.enumerate_devices() - by_engine = AudioCaptureManager.enumerate_devices_by_engine() + devices, by_engine = await asyncio.to_thread( + lambda: ( + AudioCaptureManager.enumerate_devices(), + AudioCaptureManager.enumerate_devices_by_engine(), + ) + ) return { "devices": devices, "count": len(devices), diff --git a/server/src/wled_controller/api/routes/audio_sources.py b/server/src/wled_controller/api/routes/audio_sources.py index 7c1b197..6f20a54 100644 --- a/server/src/wled_controller/api/routes/audio_sources.py +++ b/server/src/wled_controller/api/routes/audio_sources.py @@ -1,7 +1,6 @@ """Audio source routes: CRUD for audio sources + real-time test WebSocket.""" import asyncio -import secrets from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -21,7 +20,6 @@ from wled_controller.api.schemas.audio_sources import ( AudioSourceResponse, AudioSourceUpdate, ) -from wled_controller.config import get_config from wled_controller.storage.audio_source import AudioSource from wled_controller.storage.audio_source_store import AudioSourceStore from wled_controller.storage.color_strip_store import ColorStripStore @@ -169,16 +167,8 @@ async def test_audio_source_ws( (ref-counted — shares with running targets), and streams AudioAnalysis snapshots as JSON at ~20 Hz. """ - # Authenticate - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return diff --git a/server/src/wled_controller/api/routes/audio_templates.py b/server/src/wled_controller/api/routes/audio_templates.py index 157e68a..98f72f0 100644 --- a/server/src/wled_controller/api/routes/audio_templates.py +++ b/server/src/wled_controller/api/routes/audio_templates.py @@ -2,8 +2,6 @@ import asyncio import json -import secrets - from fastapi import APIRouter, HTTPException, Depends, Query from starlette.websockets import WebSocket, WebSocketDisconnect @@ -17,7 +15,6 @@ from wled_controller.api.schemas.audio_templates import ( AudioTemplateResponse, AudioTemplateUpdate, ) -from wled_controller.config import get_config from wled_controller.core.audio.factory import AudioEngineRegistry from wled_controller.storage.audio_template_store import AudioTemplateStore from wled_controller.storage.audio_source_store import AudioSourceStore @@ -189,16 +186,8 @@ async def test_audio_template_ws( Auth via ?token=. Device specified via ?device_index=N&is_loopback=0|1. Streams AudioAnalysis snapshots as JSON at ~20 Hz. """ - # Authenticate - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return diff --git a/server/src/wled_controller/api/routes/color_strip_processing.py b/server/src/wled_controller/api/routes/color_strip_processing.py new file mode 100644 index 0000000..9165d64 --- /dev/null +++ b/server/src/wled_controller/api/routes/color_strip_processing.py @@ -0,0 +1,265 @@ +"""Color strip processing template routes.""" + +import asyncio +import json as _json +import time as _time +import uuid as _uuid + +import numpy as np +from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect + +from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import ( + fire_entity_event, + get_color_strip_store, + get_cspt_store, + get_device_store, + get_processor_manager, +) +from wled_controller.api.schemas.filters import FilterInstanceSchema +from wled_controller.api.schemas.color_strip_processing import ( + ColorStripProcessingTemplateCreate, + ColorStripProcessingTemplateListResponse, + ColorStripProcessingTemplateResponse, + ColorStripProcessingTemplateUpdate, +) +from wled_controller.core.filters import FilterInstance +from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore +from wled_controller.storage.color_strip_store import ColorStripStore +from wled_controller.storage import DeviceStore +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +router = APIRouter() + + +def _cspt_to_response(t) -> ColorStripProcessingTemplateResponse: + """Convert a ColorStripProcessingTemplate to its API response.""" + return ColorStripProcessingTemplateResponse( + id=t.id, + name=t.name, + filters=[FilterInstanceSchema(filter_id=f.filter_id, options=f.options) for f in t.filters], + created_at=t.created_at, + updated_at=t.updated_at, + description=t.description, + tags=getattr(t, 'tags', []), + ) + + +@router.get("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateListResponse, tags=["Color Strip Processing"]) +async def list_cspt( + _auth: AuthRequired, + store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), +): + """List all color strip processing templates.""" + try: + templates = store.get_all_templates() + responses = [_cspt_to_response(t) for t in templates] + return ColorStripProcessingTemplateListResponse(templates=responses, count=len(responses)) + except Exception as e: + logger.error(f"Failed to list color strip processing templates: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/api/v1/color-strip-processing-templates", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"], status_code=201) +async def create_cspt( + data: ColorStripProcessingTemplateCreate, + _auth: AuthRequired, + store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), +): + """Create a new color strip processing template.""" + try: + filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] + template = store.create_template( + name=data.name, + filters=filters, + description=data.description, + tags=data.tags, + ) + fire_entity_event("cspt", "created", template.id) + return _cspt_to_response(template) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to create color strip processing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"]) +async def get_cspt( + template_id: str, + _auth: AuthRequired, + store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), +): + """Get color strip processing template by ID.""" + try: + template = store.get_template(template_id) + return _cspt_to_response(template) + except ValueError: + raise HTTPException(status_code=404, detail=f"Color strip processing template {template_id} not found") + + +@router.put("/api/v1/color-strip-processing-templates/{template_id}", response_model=ColorStripProcessingTemplateResponse, tags=["Color Strip Processing"]) +async def update_cspt( + template_id: str, + data: ColorStripProcessingTemplateUpdate, + _auth: AuthRequired, + store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), +): + """Update a color strip processing template.""" + try: + filters = [FilterInstance(f.filter_id, f.options) for f in data.filters] if data.filters is not None else None + template = store.update_template( + template_id=template_id, + name=data.name, + filters=filters, + description=data.description, + tags=data.tags, + ) + fire_entity_event("cspt", "updated", template_id) + return _cspt_to_response(template) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to update color strip processing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/api/v1/color-strip-processing-templates/{template_id}", status_code=204, tags=["Color Strip Processing"]) +async def delete_cspt( + template_id: str, + _auth: AuthRequired, + store: ColorStripProcessingTemplateStore = Depends(get_cspt_store), + device_store: DeviceStore = Depends(get_device_store), + css_store: ColorStripStore = Depends(get_color_strip_store), +): + """Delete a color strip processing template.""" + try: + refs = store.get_references(template_id, device_store=device_store, css_store=css_store) + if refs: + names = ", ".join(refs) + raise HTTPException( + status_code=409, + detail=f"Cannot delete: template is referenced by: {names}. " + "Please reassign before deleting.", + ) + store.delete_template(template_id) + fire_entity_event("cspt", "deleted", template_id) + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Failed to delete color strip processing template: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ── Test / Preview WebSocket ────────────────────────────────────────── + +@router.websocket("/api/v1/color-strip-processing-templates/{template_id}/test/ws") +async def test_cspt_ws( + websocket: WebSocket, + template_id: str, + token: str = Query(""), + input_source_id: str = Query(""), + led_count: int = Query(100), + fps: int = Query(20), +): + """WebSocket for real-time CSPT preview. + + Takes an input CSS source, applies the CSPT filter chain, and streams + the processed RGB frames. Auth via ``?token=``. + """ + from wled_controller.api.auth import verify_ws_token + from wled_controller.core.filters import FilterRegistry + from wled_controller.core.processing.processor_manager import ProcessorManager + + if not verify_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + # Validate template exists + cspt_store = get_cspt_store() + try: + template = cspt_store.get_template(template_id) + except (ValueError, RuntimeError) as e: + await websocket.close(code=4004, reason=str(e)) + return + + if not input_source_id: + await websocket.close(code=4003, reason="input_source_id is required") + return + + # Validate input source exists + css_store = get_color_strip_store() + try: + input_source = css_store.get_source(input_source_id) + except (ValueError, RuntimeError) as e: + await websocket.close(code=4004, reason=str(e)) + return + + # Resolve filter chain + try: + resolved = cspt_store.resolve_filter_instances(template.filters) + filters = [FilterRegistry.create_instance(fi.filter_id, fi.options) for fi in resolved] + except Exception as e: + logger.error(f"CSPT test: failed to resolve filters for {template_id}: {e}") + await websocket.close(code=4003, reason=str(e)) + return + + # Acquire input stream + manager: ProcessorManager = get_processor_manager() + csm = manager.color_strip_stream_manager + consumer_id = f"__cspt_test_{_uuid.uuid4().hex[:8]}__" + try: + stream = csm.acquire(input_source_id, consumer_id) + except Exception as e: + logger.error(f"CSPT test: failed to acquire input stream for {input_source_id}: {e}") + await websocket.close(code=4003, reason=str(e)) + return + + # Configure LED count for auto-sizing streams + if hasattr(stream, "configure"): + stream.configure(max(1, led_count)) + + fps = max(1, min(60, fps)) + frame_interval = 1.0 / fps + + await websocket.accept() + logger.info(f"CSPT test WS connected: template={template_id}, input={input_source_id}") + + try: + # Send metadata + meta = { + "type": "meta", + "source_type": input_source.source_type, + "source_name": input_source.name, + "template_name": template.name, + "led_count": stream.led_count, + "filter_count": len(filters), + } + await websocket.send_text(_json.dumps(meta)) + + # Stream processed frames + while True: + colors = stream.get_latest_colors() + if colors is not None: + # Apply CSPT filters + for flt in filters: + try: + result = flt.process_strip(colors) + if result is not None: + colors = result + except Exception: + pass + await websocket.send_bytes(colors.tobytes()) + await asyncio.sleep(frame_interval) + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"CSPT test WS error: {e}") + finally: + csm.release(input_source_id, consumer_id) + logger.info(f"CSPT test WS disconnected: template={template_id}") diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index bc0917e..4ca9ee6 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -36,7 +36,7 @@ from wled_controller.core.capture.calibration import ( ) from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.processing.processor_manager import ProcessorManager -from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource +from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource, ProcessedColorStripSource from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource from wled_controller.storage.picture_source_store import PictureSourceStore @@ -238,6 +238,14 @@ async def delete_color_strip_source( detail=f"Color strip source is used as a zone in mapped source(s): {names}. " "Remove it from the mapped source(s) first.", ) + processed_names = store.get_processed_referencing(source_id) + if processed_names: + names = ", ".join(processed_names) + raise HTTPException( + status_code=409, + detail=f"Color strip source is used as input in processed source(s): {names}. " + "Delete or reassign the processed source(s) first.", + ) store.delete_source(source_id) fire_entity_event("color_strip_source", "deleted", source_id) except HTTPException: diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index d03aa87..9d8f43e 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -1,7 +1,5 @@ """Device routes: CRUD, health state, brightness, power, calibration, WS stream.""" -import secrets - import httpx from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect @@ -157,8 +155,7 @@ async def create_device( # WS devices: auto-set URL to ws://{device_id} if device_type == "ws": - store.update_device(device_id=device.id, url=f"ws://{device.id}") - device = store.get_device(device.id) + device = store.update_device(device.id, url=f"ws://{device.id}") # Register in processor manager for health monitoring manager.add_device( @@ -309,9 +306,10 @@ async def get_device( store: DeviceStore = Depends(get_device_store), ): """Get device details by ID.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) return _device_to_response(device) @@ -430,9 +428,10 @@ async def get_device_state( manager: ProcessorManager = Depends(get_processor_manager), ): """Get device health/connection state.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) try: state = manager.get_device_health_dict(device_id) @@ -450,9 +449,10 @@ async def ping_device( manager: ProcessorManager = Depends(get_processor_manager), ): """Force an immediate health check on a device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) try: state = await manager.force_device_health_check(device_id) @@ -477,9 +477,10 @@ async def get_device_brightness( frontend request — hitting the ESP32 over WiFi in the async event loop causes ~150 ms jitter in the processing loop. """ - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) if "brightness_control" not in get_device_capabilities(device.device_type): raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") @@ -512,9 +513,10 @@ async def set_device_brightness( manager: ProcessorManager = Depends(get_processor_manager), ): """Set brightness on the device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) if "brightness_control" not in get_device_capabilities(device.device_type): raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") @@ -528,10 +530,7 @@ async def set_device_brightness( await provider.set_brightness(device.url, bri) except NotImplementedError: # Provider has no hardware brightness; use software brightness - device.software_brightness = bri - from datetime import datetime, timezone - device.updated_at = datetime.now(timezone.utc) - store.save() + store.update_device(device_id=device_id, software_brightness=bri) ds = manager.find_device_state(device_id) if ds: ds.software_brightness = bri @@ -557,9 +556,10 @@ async def get_device_power( manager: ProcessorManager = Depends(get_processor_manager), ): """Get current power state from the device.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) if "power_control" not in get_device_capabilities(device.device_type): raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices") @@ -586,9 +586,10 @@ async def set_device_power( manager: ProcessorManager = Depends(get_processor_manager), ): """Turn device on or off.""" - device = store.get_device(device_id) - if not device: - raise HTTPException(status_code=404, detail=f"Device {device_id} not found") + try: + device = store.get_device(device_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) if "power_control" not in get_device_capabilities(device.device_type): raise HTTPException(status_code=400, detail=f"Power control is not supported for {device.device_type} devices") @@ -628,23 +629,15 @@ async def device_ws_stream( Wire format: [brightness_byte][R G B R G B ...] Auth via ?token=. """ - from wled_controller.config import get_config - - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return store = get_device_store() - device = store.get_device(device_id) - if not device: + try: + device = store.get_device(device_id) + except ValueError: await websocket.close(code=4004, reason="Device not found") return if device.device_type != "ws": diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index b57d99f..dab3c36 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -3,7 +3,6 @@ import asyncio import base64 import io -import secrets import time import numpy as np @@ -35,7 +34,6 @@ from wled_controller.api.schemas.output_targets import ( TargetMetricsResponse, TargetProcessingState, ) -from wled_controller.config import get_config from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.processing.processor_manager import ProcessorManager @@ -151,8 +149,9 @@ async def create_target( try: # Validate device exists if provided if data.device_id: - device = device_store.get_device(data.device_id) - if not device: + try: + device_store.get_device(data.device_id) + except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None @@ -250,8 +249,9 @@ async def update_target( try: # Validate device exists if changing if data.device_id is not None and data.device_id: - device = device_store.get_device(data.device_id) - if not device: + try: + device_store.get_device(data.device_id) + except ValueError: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") # Build KC settings with partial-update support: only apply fields that were @@ -697,16 +697,8 @@ async def target_colors_ws( token: str = Query(""), ): """WebSocket for real-time key color updates. Auth via ?token=.""" - # Authenticate - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return @@ -737,15 +729,8 @@ async def led_preview_ws( token: str = Query(""), ): """WebSocket for real-time LED strip preview. Sends binary RGB frames. Auth via ?token=.""" - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return @@ -777,15 +762,8 @@ async def events_ws( token: str = Query(""), ): """WebSocket for real-time state change events. Auth via ?token=.""" - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index dbe5946..bb64007 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -1,5 +1,6 @@ """Picture source routes.""" +import asyncio import base64 import io import time @@ -97,23 +98,26 @@ async def validate_image( async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: response = await client.get(source) response.raise_for_status() - pil_image = Image.open(io.BytesIO(response.content)) + img_bytes = response.content else: path = Path(source) if not path.exists(): return ImageValidateResponse(valid=False, error=f"File not found: {source}") - pil_image = Image.open(path) + img_bytes = path - pil_image = pil_image.convert("RGB") - width, height = pil_image.size + def _process_image(src): + pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src) + pil_image = pil_image.convert("RGB") + width, height = pil_image.size + thumb = pil_image.copy() + thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) + buf = io.BytesIO() + thumb.save(buf, format="JPEG", quality=80) + buf.seek(0) + preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" + return width, height, preview - # Create thumbnail preview (max 320px wide) - thumb = pil_image.copy() - thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) - buf = io.BytesIO() - thumb.save(buf, format="JPEG", quality=80) - buf.seek(0) - preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" + width, height, preview = await asyncio.to_thread(_process_image, img_bytes) return ImageValidateResponse( valid=True, width=width, height=height, preview=preview @@ -140,18 +144,22 @@ async def get_full_image( async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: response = await client.get(source) response.raise_for_status() - pil_image = Image.open(io.BytesIO(response.content)) + img_bytes = response.content else: path = Path(source) if not path.exists(): raise HTTPException(status_code=404, detail="File not found") - pil_image = Image.open(path) + img_bytes = path - pil_image = pil_image.convert("RGB") - buf = io.BytesIO() - pil_image.save(buf, format="JPEG", quality=90) - buf.seek(0) - return Response(content=buf.getvalue(), media_type="image/jpeg") + def _encode_full(src): + pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src) + pil_image = pil_image.convert("RGB") + buf = io.BytesIO() + pil_image.save(buf, format="JPEG", quality=90) + return buf.getvalue() + + jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes) + return Response(content=jpeg_bytes, media_type="image/jpeg") except HTTPException: raise @@ -326,7 +334,7 @@ async def test_picture_source( path = Path(source) if not path.exists(): raise HTTPException(status_code=400, detail=f"Image file not found: {source}") - pil_image = Image.open(path).convert("RGB") + pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB")) actual_duration = time.perf_counter() - start_time frame_count = 1 @@ -393,48 +401,50 @@ async def test_picture_source( else: raise ValueError("Unexpected image format from engine") - # Create thumbnail - thumbnail_width = 640 - aspect_ratio = pil_image.height / pil_image.width - thumbnail_height = int(thumbnail_width * aspect_ratio) - thumbnail = pil_image.copy() - thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) - - # Apply postprocessing filters if this is a processed stream + # Create thumbnail + encode (CPU-bound — run in thread) pp_template_ids = chain["postprocessing_template_ids"] + flat_filters = None if pp_template_ids: try: pp_template = pp_store.get_template(pp_template_ids[0]) - flat_filters = pp_store.resolve_filter_instances(pp_template.filters) - if flat_filters: - pool = ImagePool() - - def apply_filters(img): - arr = np.array(img) - for fi in flat_filters: - f = FilterRegistry.create_instance(fi.filter_id, fi.options) - result = f.process_image(arr, pool) - if result is not None: - arr = result - return Image.fromarray(arr) - - thumbnail = apply_filters(thumbnail) - pil_image = apply_filters(pil_image) + flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None except ValueError: logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") - # Encode thumbnail - img_buffer = io.BytesIO() - thumbnail.save(img_buffer, format='JPEG', quality=85) - img_buffer.seek(0) - thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + def _create_thumbnails_and_encode(pil_img, filters): + thumbnail_w = 640 + aspect_ratio = pil_img.height / pil_img.width + thumbnail_h = int(thumbnail_w * aspect_ratio) + thumb = pil_img.copy() + thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS) - # Encode full-resolution image - full_buffer = io.BytesIO() - pil_image.save(full_buffer, format='JPEG', quality=90) - full_buffer.seek(0) - full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + if filters: + pool = ImagePool() + def apply_filters(img): + arr = np.array(img) + for fi in filters: + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(arr, pool) + if result is not None: + arr = result + return Image.fromarray(arr) + thumb = apply_filters(thumb) + pil_img = apply_filters(pil_img) + + img_buffer = io.BytesIO() + thumb.save(img_buffer, format='JPEG', quality=85) + thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + + full_buffer = io.BytesIO() + pil_img.save(full_buffer, format='JPEG', quality=90) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + + return thumbnail_w, thumbnail_h, thumb_b64, full_b64 + + thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread( + _create_thumbnails_and_encode, pil_image, flat_filters + ) + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" full_data_uri = f"data:image/jpeg;base64,{full_b64}" actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index c0afca7..a1963b6 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -59,19 +59,8 @@ logger = get_logger(__name__) # Prime psutil CPU counter (first call always returns 0.0) psutil.cpu_percent(interval=None) -# Try to initialize NVIDIA GPU monitoring -_nvml_available = False -try: - import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated) - - _pynvml_mod.nvmlInit() - _nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0) - _nvml_available = True - _nvml = _pynvml_mod - logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}") -except Exception: - _nvml = None - logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)") +# GPU monitoring (initialized once in utils.gpu, shared with metrics_history) +from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle def _get_cpu_name() -> str | None: @@ -156,17 +145,9 @@ async def list_all_tags(_: AuthRequired): 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 + # BaseJsonStore subclasses provide get_all(); DeviceStore provides get_all_devices() + fn = getattr(store, "get_all", None) or getattr(store, "get_all_devices", None) + items = fn() if fn else None if items: for item in items: all_tags.update(getattr(item, 'tags', [])) @@ -191,9 +172,9 @@ async def get_displays( from wled_controller.core.capture_engines import EngineRegistry engine_cls = EngineRegistry.get_engine(engine_type) - display_dataclasses = engine_cls.get_available_displays() + display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays) else: - display_dataclasses = get_available_displays() + display_dataclasses = await asyncio.to_thread(get_available_displays) # Convert dataclass DisplayInfo to Pydantic DisplayInfo displays = [ @@ -321,6 +302,7 @@ STORE_MAP = { "audio_templates": "audio_templates_file", "value_sources": "value_sources_file", "sync_clocks": "sync_clocks_file", + "color_strip_processing_templates": "color_strip_processing_templates_file", "automations": "automations_file", "scene_presets": "scene_presets_file", } @@ -579,11 +561,13 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest): adb = _get_adb_path() logger.info(f"Connecting ADB device: {address}") try: - result = subprocess.run( - [adb, "connect", address], - capture_output=True, text=True, timeout=10, + proc = await asyncio.create_subprocess_exec( + adb, "connect", address, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - output = (result.stdout + result.stderr).strip() + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + output = (stdout.decode() + stderr.decode()).strip() if "connected" in output.lower(): return {"status": "connected", "address": address, "message": output} raise HTTPException(status_code=400, detail=output or "Connection failed") @@ -592,7 +576,7 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest): status_code=500, detail="adb not found on PATH. Install Android SDK Platform-Tools.", ) - except subprocess.TimeoutExpired: + except asyncio.TimeoutError: raise HTTPException(status_code=504, detail="ADB connect timed out") @@ -606,12 +590,14 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest): adb = _get_adb_path() logger.info(f"Disconnecting ADB device: {address}") try: - result = subprocess.run( - [adb, "disconnect", address], - capture_output=True, text=True, timeout=10, + proc = await asyncio.create_subprocess_exec( + adb, "disconnect", address, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) - return {"status": "disconnected", "message": result.stdout.strip()} + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + return {"status": "disconnected", "message": stdout.decode().strip()} except FileNotFoundError: raise HTTPException(status_code=500, detail="adb not found on PATH") - except subprocess.TimeoutExpired: + except asyncio.TimeoutError: raise HTTPException(status_code=504, detail="ADB disconnect timed out") diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index e308fda..a417b8b 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -11,6 +11,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSock from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( fire_entity_event, + get_cspt_store, get_picture_source_store, get_pp_template_store, get_template_store, @@ -479,3 +480,48 @@ async def list_filter_types( options_schema=opt_schemas, )) return FilterTypeListResponse(filters=responses, count=len(responses)) + + +@router.get("/api/v1/strip-filters", response_model=FilterTypeListResponse, tags=["Filters"]) +async def list_strip_filter_types( + _auth: AuthRequired, + cspt_store=Depends(get_cspt_store), +): + """List filter types that support 1D LED strip processing.""" + all_filters = FilterRegistry.get_all() + + # Pre-build template choices for the css_filter_template filter + cspt_choices = None + if cspt_store: + try: + templates = cspt_store.get_all_templates() + cspt_choices = [{"value": t.id, "label": t.name} for t in templates] + except Exception: + cspt_choices = [] + + responses = [] + for filter_id, filter_cls in all_filters.items(): + if not getattr(filter_cls, "supports_strip", True): + continue + schema = filter_cls.get_options_schema() + opt_schemas = [] + for opt in schema: + choices = opt.choices + if filter_id == "css_filter_template" and opt.key == "template_id" and cspt_choices is not None: + choices = cspt_choices + opt_schemas.append(FilterOptionDefSchema( + key=opt.key, + label=opt.label, + type=opt.option_type, + default=opt.default, + min_value=opt.min_value, + max_value=opt.max_value, + step=opt.step, + choices=choices, + )) + responses.append(FilterTypeResponse( + filter_id=filter_cls.filter_id, + filter_name=filter_cls.filter_name, + options_schema=opt_schemas, + )) + return FilterTypeListResponse(filters=responses, count=len(responses)) diff --git a/server/src/wled_controller/api/routes/value_sources.py b/server/src/wled_controller/api/routes/value_sources.py index baf35bc..758574d 100644 --- a/server/src/wled_controller/api/routes/value_sources.py +++ b/server/src/wled_controller/api/routes/value_sources.py @@ -1,7 +1,6 @@ """Value source routes: CRUD for value sources.""" import asyncio -import secrets from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect @@ -13,7 +12,6 @@ from wled_controller.api.dependencies import ( get_processor_manager, get_value_source_store, ) -from wled_controller.config import get_config from wled_controller.api.schemas.value_sources import ( ValueSourceCreate, ValueSourceListResponse, @@ -202,16 +200,8 @@ async def test_value_source_ws( Acquires a ValueStream for the given source, polls get_value() at ~20 Hz, and streams {value: float} JSON to the client. """ - # Authenticate - authenticated = False - cfg = get_config() - if token and cfg.auth.api_keys: - for _label, api_key in cfg.auth.api_keys.items(): - if secrets.compare_digest(token, api_key): - authenticated = True - break - - if not authenticated: + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): await websocket.close(code=4001, reason="Unauthorized") return diff --git a/server/src/wled_controller/api/schemas/color_strip_processing.py b/server/src/wled_controller/api/schemas/color_strip_processing.py new file mode 100644 index 0000000..71571b0 --- /dev/null +++ b/server/src/wled_controller/api/schemas/color_strip_processing.py @@ -0,0 +1,45 @@ +"""Color strip processing template schemas.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from .filters import FilterInstanceSchema + + +class ColorStripProcessingTemplateCreate(BaseModel): + """Request to create a color strip processing template.""" + + 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 ColorStripProcessingTemplateUpdate(BaseModel): + """Request to update a color strip processing template.""" + + 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 ColorStripProcessingTemplateResponse(BaseModel): + """Color strip processing template information response.""" + + 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") + + +class ColorStripProcessingTemplateListResponse(BaseModel): + """List of color strip processing templates response.""" + + templates: List[ColorStripProcessingTemplateResponse] = Field(description="List of templates") + count: int = Field(description="Number of templates") diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 512f9c1..73e9ce6 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -35,6 +35,7 @@ class CompositeLayer(BaseModel): opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") enabled: bool = Field(default=True, description="Whether this layer is active") brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness") + processing_template_id: Optional[str] = Field(None, description="Optional color strip processing template ID") class MappedZone(BaseModel): @@ -50,12 +51,9 @@ class ColorStripSourceCreate(BaseModel): """Request to create a color strip source.""" name: str = Field(description="Source name", min_length=1, max_length=100) - source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight"] = Field(default="picture", description="Source type") + source_type: Literal["picture", "picture_advanced", "static", "gradient", "color_cycle", "effect", "composite", "mapped", "audio", "api_input", "notification", "daylight", "candlelight", "processed"] = Field(default="picture", description="Source type") # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") - brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) - saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0) - gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") @@ -83,7 +81,6 @@ class ColorStripSourceCreate(BaseModel): # shared led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) - frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output") animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] when no data received (api_input type)") @@ -102,6 +99,9 @@ class ColorStripSourceCreate(BaseModel): latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0) # candlelight-type fields num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) + # processed-type fields + input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") + processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)") # 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") @@ -113,9 +113,6 @@ class ColorStripSourceUpdate(BaseModel): name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") - brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) - saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0) - gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") calibration: Optional[Calibration] = Field(None, description="LED calibration") @@ -143,7 +140,6 @@ class ColorStripSourceUpdate(BaseModel): # shared led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) - frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") @@ -162,6 +158,9 @@ class ColorStripSourceUpdate(BaseModel): latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0) # candlelight-type fields num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) + # processed-type fields + input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") + processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)") # sync clock clock_id: Optional[str] = Field(None, description="Optional sync clock ID for synchronized animation") tags: Optional[List[str]] = None @@ -175,9 +174,6 @@ class ColorStripSourceResponse(BaseModel): source_type: str = Field(description="Source type") # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") - brightness: Optional[float] = Field(None, description="Brightness multiplier") - saturation: Optional[float] = Field(None, description="Saturation") - gamma: Optional[float] = Field(None, description="Gamma correction") smoothing: Optional[float] = Field(None, description="Temporal smoothing") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") calibration: Optional[Calibration] = Field(None, description="LED calibration") @@ -205,7 +201,6 @@ class ColorStripSourceResponse(BaseModel): # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") - frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") # api_input-type fields fallback_color: Optional[List[int]] = Field(None, description="Fallback RGB color [R,G,B] (api_input type)") @@ -224,6 +219,9 @@ class ColorStripSourceResponse(BaseModel): latitude: Optional[float] = Field(None, description="Latitude for daylight timing") # candlelight-type fields num_candles: Optional[int] = Field(None, description="Number of independent candle sources") + # processed-type fields + input_source_id: Optional[str] = Field(None, description="Input color strip source ID") + processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID") # 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") diff --git a/server/src/wled_controller/api/schemas/devices.py b/server/src/wled_controller/api/schemas/devices.py index 370748a..ffd39c9 100644 --- a/server/src/wled_controller/api/schemas/devices.py +++ b/server/src/wled_controller/api/schemas/devices.py @@ -37,6 +37,7 @@ class DeviceCreate(BaseModel): chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type: keyboard, mouse, mousepad, headset, chromalink, keypad") # SteelSeries GameSense fields gamesense_device_type: Optional[str] = Field(None, description="GameSense device type: keyboard, mouse, headset, mousepad, indicator") + default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID") class DeviceUpdate(BaseModel): @@ -64,6 +65,7 @@ class DeviceUpdate(BaseModel): spi_led_type: Optional[str] = Field(None, description="LED chipset type") chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type") gamesense_device_type: Optional[str] = Field(None, description="GameSense device type") + default_css_processing_template_id: Optional[str] = Field(None, description="Default color strip processing template ID") class CalibrationLineSchema(BaseModel): @@ -170,6 +172,7 @@ class DeviceResponse(BaseModel): spi_led_type: str = Field(default="WS2812B", description="LED chipset type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") gamesense_device_type: str = Field(default="keyboard", description="GameSense device type") + default_css_processing_template_id: str = Field(default="", description="Default color strip processing template ID") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py index 38eed8e..155cbe1 100644 --- a/server/src/wled_controller/config.py +++ b/server/src/wled_controller/config.py @@ -39,6 +39,7 @@ class StorageConfig(BaseSettings): value_sources_file: str = "data/value_sources.json" automations_file: str = "data/automations.json" scene_presets_file: str = "data/scene_presets.json" + color_strip_processing_templates_file: str = "data/color_strip_processing_templates.json" sync_clocks_file: str = "data/sync_clocks.json" diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py index 93eeec7..d911cdc 100644 --- a/server/src/wled_controller/core/filters/__init__.py +++ b/server/src/wled_controller/core/filters/__init__.py @@ -20,8 +20,10 @@ import wled_controller.core.filters.flip # noqa: F401 import wled_controller.core.filters.color_correction # noqa: F401 import wled_controller.core.filters.frame_interpolation # noqa: F401 import wled_controller.core.filters.filter_template # noqa: F401 +import wled_controller.core.filters.css_filter_template # noqa: F401 import wled_controller.core.filters.noise_gate # noqa: F401 import wled_controller.core.filters.palette_quantization # noqa: F401 +import wled_controller.core.filters.reverse # noqa: F401 __all__ = [ "FilterOptionDef", diff --git a/server/src/wled_controller/core/filters/auto_crop.py b/server/src/wled_controller/core/filters/auto_crop.py index cdca02c..ab037b2 100644 --- a/server/src/wled_controller/core/filters/auto_crop.py +++ b/server/src/wled_controller/core/filters/auto_crop.py @@ -18,6 +18,7 @@ class AutoCropFilter(PostprocessingFilter): filter_id = "auto_crop" filter_name = "Auto Crop" + supports_strip = False @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: diff --git a/server/src/wled_controller/core/filters/base.py b/server/src/wled_controller/core/filters/base.py index 9c95220..dc0de18 100644 --- a/server/src/wled_controller/core/filters/base.py +++ b/server/src/wled_controller/core/filters/base.py @@ -44,11 +44,21 @@ class PostprocessingFilter(ABC): Each filter operates on a full image (np.ndarray H×W×3 uint8). Filters that preserve dimensions modify in-place and return None. Filters that change dimensions return a new array from the image pool. + + Filters that also support 1D LED strip arrays (N×3 uint8) should + leave ``supports_strip = True`` (the default). The base class + provides a generic ``process_strip`` that reshapes (N,3) → (1,N,3), + delegates to ``process_image``, and reshapes back. Subclasses may + override for a more efficient implementation. + + Filters that are purely spatial (auto-crop, downscaler, flip) should + set ``supports_strip = False``. """ filter_id: str = "" filter_name: str = "" supports_idle_frames: bool = False + supports_strip: bool = True def __init__(self, options: Dict[str, Any]): """Initialize filter with validated options.""" @@ -74,6 +84,31 @@ class PostprocessingFilter(ABC): """ ... + def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + """Process a 1D LED strip array (N, 3) uint8. + + Default implementation reshapes to (1, N, 3), calls process_image + with a no-op pool, and reshapes back. Override for filters that + need strip-specific behaviour or use ImagePool. + + Returns: + None if modified in-place. + New np.ndarray if a new array was created. + """ + from wled_controller.core.filters.image_pool import ImagePool + + img = strip[np.newaxis, :, :] # (1, N, 3) + pool = ImagePool(max_size=2) + result = self.process_image(img, pool) + if result is not None: + out = result[0] # (N, 3) + pool.release_all() + return out + # Modified in-place — extract back + np.copyto(strip, img[0]) + pool.release_all() + return None + @classmethod def validate_options(cls, options: dict) -> dict: """Validate and clamp options against the schema. Returns cleaned dict.""" diff --git a/server/src/wled_controller/core/filters/css_filter_template.py b/server/src/wled_controller/core/filters/css_filter_template.py new file mode 100644 index 0000000..66619d5 --- /dev/null +++ b/server/src/wled_controller/core/filters/css_filter_template.py @@ -0,0 +1,46 @@ +"""CSS Filter Template meta-filter — references a color strip processing template. + +This filter exists in the registry for UI discovery only. It is never +instantiated at runtime: the store expands it into the referenced +template's filters when building the processing pipeline. +""" + +from typing import List, Optional + +import numpy as np + +from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter +from wled_controller.core.filters.image_pool import ImagePool +from wled_controller.core.filters.registry import FilterRegistry + + +@FilterRegistry.register +class CSSFilterTemplateFilter(PostprocessingFilter): + """Include another color strip processing template's chain at this position.""" + + filter_id = "css_filter_template" + filter_name = "Strip Filter Template" + supports_strip = True + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="template_id", + label="Template", + option_type="select", + default="", + min_value=None, + max_value=None, + step=None, + choices=[], # populated dynamically by GET /api/v1/strip-filters + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + # Never called — expanded at pipeline build time. + return None + + def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + # Never called — expanded at pipeline build time. + return None diff --git a/server/src/wled_controller/core/filters/downscaler.py b/server/src/wled_controller/core/filters/downscaler.py index b82cded..081572e 100644 --- a/server/src/wled_controller/core/filters/downscaler.py +++ b/server/src/wled_controller/core/filters/downscaler.py @@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter): filter_id = "downscaler" filter_name = "Downscaler" + supports_strip = False @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: diff --git a/server/src/wled_controller/core/filters/filter_template.py b/server/src/wled_controller/core/filters/filter_template.py index 4ae0e73..a253d95 100644 --- a/server/src/wled_controller/core/filters/filter_template.py +++ b/server/src/wled_controller/core/filters/filter_template.py @@ -20,6 +20,7 @@ class FilterTemplateFilter(PostprocessingFilter): filter_id = "filter_template" filter_name = "Filter Template" + supports_strip = False @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: diff --git a/server/src/wled_controller/core/filters/flip.py b/server/src/wled_controller/core/filters/flip.py index ba6d031..bad8362 100644 --- a/server/src/wled_controller/core/filters/flip.py +++ b/server/src/wled_controller/core/filters/flip.py @@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter): filter_id = "flip" filter_name = "Flip" + supports_strip = False @classmethod def get_options_schema(cls) -> List[FilterOptionDef]: diff --git a/server/src/wled_controller/core/filters/frame_interpolation.py b/server/src/wled_controller/core/filters/frame_interpolation.py index 6b4b8c4..bfb5c4c 100644 --- a/server/src/wled_controller/core/filters/frame_interpolation.py +++ b/server/src/wled_controller/core/filters/frame_interpolation.py @@ -59,15 +59,23 @@ class FrameInterpolationFilter(PostprocessingFilter): None — image passes through unchanged (no blend needed). ndarray — blended output acquired from image_pool. """ + return self._blend(image, lambda shape: image_pool.acquire(*shape)) + + def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + """Frame interpolation for 1D LED strips — allocates directly.""" + return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8)) + + def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]: + """Shared blend logic for both images and strips.""" now = time.perf_counter() # Detect new vs idle frame via cheap 64-byte signature - sig = bytes(image.ravel()[:64]) + sig = bytes(data.ravel()[:64]) if sig != self._sig_b: # New source frame — shift A ← B, B ← current self._frame_a = self._frame_b self._time_a = self._time_b - self._frame_b = image.copy() + self._frame_b = data.copy() self._time_b = now self._sig_b = sig @@ -83,8 +91,7 @@ class FrameInterpolationFilter(PostprocessingFilter): # Blend: output = (1 - alpha)*A + alpha*B (integer fast path) alpha_i = int(alpha * 256) - h, w, c = image.shape - shape = (h, w, c) + shape = data.shape # Resize scratch buffers on shape change if self._blend_shape != shape: @@ -92,9 +99,9 @@ class FrameInterpolationFilter(PostprocessingFilter): self._u16_b = np.empty(shape, dtype=np.uint16) self._blend_shape = shape - out = image_pool.acquire(h, w, c) + out = alloc_fn(shape) np.copyto(self._u16_a, self._frame_a, casting='unsafe') - np.copyto(self._u16_b, image, casting='unsafe') + np.copyto(self._u16_b, data, casting='unsafe') self._u16_a *= (256 - alpha_i) self._u16_b *= alpha_i self._u16_a += self._u16_b diff --git a/server/src/wled_controller/core/filters/palette_quantization.py b/server/src/wled_controller/core/filters/palette_quantization.py index 7833947..126ba18 100644 --- a/server/src/wled_controller/core/filters/palette_quantization.py +++ b/server/src/wled_controller/core/filters/palette_quantization.py @@ -83,7 +83,7 @@ class PaletteQuantizationFilter(PostprocessingFilter): min_value=None, max_value=None, step=None, - choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS], + choices=[{"value": k, "label": k.capitalize(), "colors": v} for k, v in _PRESETS.items()], ), FilterOptionDef( key="colors", diff --git a/server/src/wled_controller/core/filters/reverse.py b/server/src/wled_controller/core/filters/reverse.py new file mode 100644 index 0000000..5956993 --- /dev/null +++ b/server/src/wled_controller/core/filters/reverse.py @@ -0,0 +1,30 @@ +"""Reverse filter — reverses the LED order in a 1D strip.""" + +from typing import List, Optional + +import numpy as np + +from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter +from wled_controller.core.filters.image_pool import ImagePool +from wled_controller.core.filters.registry import FilterRegistry + + +@FilterRegistry.register +class ReverseFilter(PostprocessingFilter): + """Reverses the order of LEDs in a color strip.""" + + filter_id = "reverse" + filter_name = "Reverse" + supports_strip = True + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + """Reverse image horizontally (for 2D fallback).""" + return image[:, ::-1].copy() + + def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]: + """Reverse the LED array order.""" + return strip[::-1].copy() diff --git a/server/src/wled_controller/core/processing/audio_stream.py b/server/src/wled_controller/core/processing/audio_stream.py index d2151cc..702b404 100644 --- a/server/src/wled_controller/core/processing/audio_stream.py +++ b/server/src/wled_controller/core/processing/audio_stream.py @@ -251,6 +251,7 @@ class AudioColorStripStream(ColorStripStream): _full_amp = np.empty(n, dtype=np.float32) _vu_gradient = np.linspace(0, 1, n, dtype=np.float32) _indices_buf = np.empty(n, dtype=np.int32) + _f32_rgb = np.empty((n, 3), dtype=np.float32) self._prev_spectrum = None # reset smoothing on resize # Make pre-computed arrays available to render methods @@ -260,6 +261,7 @@ class AudioColorStripStream(ColorStripStream): self._full_amp = _full_amp self._vu_gradient = _vu_gradient self._indices_buf = _indices_buf + self._f32_rgb = _f32_rgb buf = _buf_a if _use_a else _buf_b _use_a = not _use_a @@ -352,8 +354,11 @@ class AudioColorStripStream(ColorStripStream): # Scale brightness by amplitude — restore full_amp to [0, 1] full_amp *= (1.0 / 255.0) - for ch in range(3): - buf[:, ch] = (colors[:, ch].astype(np.float32) * full_amp).astype(np.uint8) + f32_rgb = self._f32_rgb + np.copyto(f32_rgb, colors, casting='unsafe') + f32_rgb *= full_amp[:, np.newaxis] + np.clip(f32_rgb, 0, 255, out=f32_rgb) + np.copyto(buf, f32_rgb, casting='unsafe') # ── VU Meter ─────────────────────────────────────────────────── diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 28a61d1..93bfd7b 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -6,7 +6,7 @@ by processing frames from a LiveStream. Multiple WledTargetProcessors may read from the same ColorStripStream instance (shared via ColorStripStreamManager reference counting), meaning the CPU-bound -processing — border extraction, pixel mapping, color correction — runs only once +processing — border extraction, pixel mapping, smoothing — runs only once even when multiple devices share the same source configuration. """ @@ -28,54 +28,6 @@ from wled_controller.utils.timer import high_resolution_timer logger = get_logger(__name__) -def _apply_saturation(colors: np.ndarray, saturation: float, - _i32: np.ndarray = None, _i32_gray: np.ndarray = None, - _out: np.ndarray = None) -> np.ndarray: - """Adjust saturation via luminance mixing (Rec.601 weights, integer math). - - saturation=1.0: no change - saturation=0.0: grayscale - saturation=2.0: double saturation (clipped to 0-255) - - Optional pre-allocated scratch buffers (_i32, _i32_gray, _out) avoid - per-frame allocations when called from a hot loop. - """ - n = len(colors) - if _i32 is None: - _i32 = np.empty((n, 3), dtype=np.int32) - if _i32_gray is None: - _i32_gray = np.empty((n, 1), dtype=np.int32) - if _out is None: - _out = np.empty((n, 3), dtype=np.uint8) - - sat_int = int(saturation * 256) - np.copyto(_i32, colors, casting='unsafe') - _i32_gray[:, 0] = (_i32[:, 0] * 299 + _i32[:, 1] * 587 + _i32[:, 2] * 114) // 1000 - _i32 *= sat_int - _i32_gray *= (256 - sat_int) - _i32 += _i32_gray - _i32 >>= 8 - np.clip(_i32, 0, 255, out=_i32) - np.copyto(_out, _i32, casting='unsafe') - return _out - - -def _build_gamma_lut(gamma: float) -> np.ndarray: - """Build a 256-entry uint8 LUT for gamma correction. - - gamma=1.0: identity (no correction) - gamma<1.0: brighter midtones (gamma < 1 lifts shadows) - gamma>1.0: darker midtones (standard LED gamma, e.g. 2.2–2.8) - """ - if gamma == 1.0: - return np.arange(256, dtype=np.uint8) - lut = np.array( - [min(255, int(((i / 255.0) ** gamma) * 255 + 0.5)) for i in range(256)], - dtype=np.uint8, - ) - return lut - - class ColorStripStream(ABC): """Abstract base: a runtime source of LED color arrays. @@ -142,10 +94,7 @@ class PictureColorStripStream(ColorStripStream): 2. Extracts border pixels using the calibration's border_width 3. Maps border pixels to LED colors via PixelMapper 4. Applies temporal smoothing - 5. Applies saturation correction - 6. Applies gamma correction (LUT-based, O(1) per pixel) - 7. Applies brightness scaling - 8. Caches the result for lock-free consumer reads + 5. Caches the result for lock-free consumer reads Processing parameters can be hot-updated via update_source() without restarting the thread (except when the underlying LiveStream changes). @@ -167,11 +116,10 @@ class PictureColorStripStream(ColorStripStream): else: self._live_streams = {} self._live_stream = live_stream + self._fps: int = 30 # internal capture rate (send FPS is on the target) + self._frame_time: float = 1.0 / 30 self._smoothing: float = source.smoothing - self._brightness: float = source.brightness - self._saturation: float = source.saturation - self._gamma: float = source.gamma self._interpolation_mode: str = source.interpolation_mode self._calibration: CalibrationConfig = source.calibration self._pixel_mapper = create_pixel_mapper( @@ -179,25 +127,21 @@ class PictureColorStripStream(ColorStripStream): ) cal_leds = self._calibration.get_total_leds() self._led_count: int = source.led_count if source.led_count > 0 else cal_leds - self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma) # Thread-safe color cache self._latest_colors: Optional[np.ndarray] = None self._colors_lock = threading.Lock() self._previous_colors: Optional[np.ndarray] = None - # Frame interpolation state - self._frame_interpolation: bool = source.frame_interpolation - self._interp_from: Optional[np.ndarray] = None - self._interp_to: Optional[np.ndarray] = None - self._interp_start: float = 0.0 - self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0 - self._last_capture_time: float = 0.0 - self._running = False self._thread: Optional[threading.Thread] = None self._last_timing: dict = {} + @property + def live_stream(self): + """Public accessor for the underlying LiveStream (used by preview WebSocket).""" + return self._live_stream + @property def target_fps(self) -> int: return self._fps @@ -239,9 +183,6 @@ class PictureColorStripStream(ColorStripStream): self._thread = None self._latest_colors = None self._previous_colors = None - self._interp_from = None - self._interp_to = None - self._last_capture_time = 0.0 logger.info("PictureColorStripStream stopped") def get_latest_colors(self) -> Optional[np.ndarray]: @@ -256,7 +197,7 @@ class PictureColorStripStream(ColorStripStream): fps = max(1, min(90, fps)) if fps != self._fps: self._fps = fps - self._interp_duration = 1.0 / fps + self._frame_time = 1.0 / fps logger.info(f"PictureColorStripStream capture FPS set to {fps}") def update_source(self, source) -> None: @@ -270,12 +211,6 @@ class PictureColorStripStream(ColorStripStream): return self._smoothing = source.smoothing - self._brightness = source.brightness - self._saturation = source.saturation - - if source.gamma != self._gamma: - self._gamma = source.gamma - self._gamma_lut = _build_gamma_lut(source.gamma) if ( source.interpolation_mode != self._interpolation_mode @@ -291,11 +226,6 @@ class PictureColorStripStream(ColorStripStream): ) self._previous_colors = None # Reset smoothing history on calibration change - if source.frame_interpolation != self._frame_interpolation: - self._frame_interpolation = source.frame_interpolation - self._interp_from = None - self._interp_to = None - logger.info("PictureColorStripStream params updated in-place") def _processing_loop(self) -> None: @@ -306,8 +236,7 @@ class PictureColorStripStream(ColorStripStream): _pool_n = 0 _frame_a = _frame_b = None # double-buffered uint8 output _use_a = True - _u16_a = _u16_b = None # uint16 scratch for smoothing / interp blending - _i32 = _i32_gray = None # int32 scratch for saturation + brightness + _u16_a = _u16_b = None # uint16 scratch for smoothing blending def _blend_u16(a, b, alpha_b, out): """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8. @@ -323,62 +252,20 @@ class PictureColorStripStream(ColorStripStream): _u16_a >>= 8 np.copyto(out, _u16_a, casting='unsafe') - def _apply_corrections(led_colors, frame_buf): - """Apply saturation, gamma, brightness using pre-allocated scratch. - - Returns the (possibly reassigned) led_colors array. - """ - nonlocal _i32 - if self._saturation != 1.0: - _apply_saturation(led_colors, self._saturation, _i32, _i32_gray, led_colors) - if self._gamma != 1.0: - led_colors = self._gamma_lut[led_colors] - if self._brightness != 1.0: - bright_int = int(self._brightness * 256) - np.copyto(_i32, led_colors, casting='unsafe') - _i32 *= bright_int - _i32 >>= 8 - np.clip(_i32, 0, 255, out=_i32) - np.copyto(frame_buf, _i32, casting='unsafe') - led_colors = frame_buf - return led_colors - try: with high_resolution_timer(): while self._running: loop_start = time.perf_counter() - fps = self._fps - frame_time = 1.0 / fps if fps > 0 else 1.0 + frame_time = self._frame_time try: frame = self._live_stream.get_latest_frame() if frame is None or frame is cached_frame: - if ( - frame is not None - and self._frame_interpolation - and self._interp_from is not None - and self._interp_to is not None - and _u16_a is not None - ): - # Interpolate between previous and current capture - t = min(1.0, (loop_start - self._interp_start) / self._interp_duration) - frame_buf = _frame_a if _use_a else _frame_b - _use_a = not _use_a - _blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf) - led_colors = _apply_corrections(frame_buf, frame_buf) - with self._colors_lock: - self._latest_colors = led_colors elapsed = time.perf_counter() - loop_start time.sleep(max(frame_time - elapsed, 0.001)) continue - interval = ( - loop_start - self._last_capture_time - if self._last_capture_time > 0 - else frame_time - ) - self._last_capture_time = loop_start cached_frame = frame t0 = time.perf_counter() @@ -410,8 +297,6 @@ class PictureColorStripStream(ColorStripStream): _frame_b = np.empty((_n, 3), dtype=np.uint8) _u16_a = np.empty((_n, 3), dtype=np.uint16) _u16_b = np.empty((_n, 3), dtype=np.uint16) - _i32 = np.empty((_n, 3), dtype=np.int32) - _i32_gray = np.empty((_n, 1), dtype=np.int32) self._previous_colors = None # Copy/pad into double-buffered frame (avoids per-frame allocations) @@ -440,38 +325,6 @@ class PictureColorStripStream(ColorStripStream): int(smoothing * 256), led_colors) t3 = time.perf_counter() - # Update interpolation buffers (smoothed colors, before corrections) - # Must be AFTER smoothing so idle-tick interpolation produces - # output consistent with new-frame ticks (both smoothed). - if self._frame_interpolation: - self._interp_from = self._interp_to - self._interp_to = led_colors.copy() - self._interp_start = loop_start - self._interp_duration = max(interval, 0.001) - - # Saturation (pre-allocated int32 scratch) - saturation = self._saturation - if saturation != 1.0: - _apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors) - t4 = time.perf_counter() - - # Gamma (LUT lookup — O(1) per pixel) - if self._gamma != 1.0: - led_colors = self._gamma_lut[led_colors] - t5 = time.perf_counter() - - # Brightness (integer math with pre-allocated int32 scratch) - brightness = self._brightness - if brightness != 1.0: - bright_int = int(brightness * 256) - np.copyto(_i32, led_colors, casting='unsafe') - _i32 *= bright_int - _i32 >>= 8 - np.clip(_i32, 0, 255, out=_i32) - np.copyto(frame_buf, _i32, casting='unsafe') - led_colors = frame_buf - t6 = time.perf_counter() - self._previous_colors = led_colors with self._colors_lock: @@ -481,10 +334,7 @@ class PictureColorStripStream(ColorStripStream): "extract_ms": (t1 - t0) * 1000, "map_leds_ms": (t2 - t1) * 1000, "smooth_ms": (t3 - t2) * 1000, - "saturation_ms": (t4 - t3) * 1000, - "gamma_ms": (t5 - t4) * 1000, - "brightness_ms": (t6 - t5) * 1000, - "total_ms": (t6 - t0) * 1000, + "total_ms": (t3 - t0) * 1000, } except Exception as e: @@ -1201,12 +1051,52 @@ class GradientColorStripStream(ColorStripStream): elif atype == "rainbow_fade": h_shift = (speed * t * 0.1) % 1.0 - for i in range(n): - r, g, b = base[i] - h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) - new_h = (h + h_shift) % 1.0 - nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3)) - buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255)) + # Vectorized RGB->HSV shift->RGB (no per-LED colorsys) + rgb_f = base.astype(np.float32) * (1.0 / 255.0) + r_f = rgb_f[:, 0] + g_f = rgb_f[:, 1] + b_f = rgb_f[:, 2] + cmax = np.maximum(np.maximum(r_f, g_f), b_f) + cmin = np.minimum(np.minimum(r_f, g_f), b_f) + delta = cmax - cmin + # Hue + h_arr = np.zeros(n, dtype=np.float32) + mask_r = (delta > 0) & (cmax == r_f) + mask_g = (delta > 0) & (cmax == g_f) & ~mask_r + mask_b = (delta > 0) & ~mask_r & ~mask_g + h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0 + h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0 + h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0 + h_arr *= (1.0 / 6.0) + h_arr %= 1.0 + # Saturation & Value with clamping + s_arr = np.where(cmax > 0, delta / cmax, np.float32(0)) + np.maximum(s_arr, 0.5, out=s_arr) + v_arr = cmax.copy() + np.maximum(v_arr, 0.3, out=v_arr) + # Shift hue + h_arr += h_shift + h_arr %= 1.0 + # Vectorized HSV->RGB + h6 = h_arr * 6.0 + hi = h6.astype(np.int32) % 6 + f_arr = h6 - np.floor(h6) + p = v_arr * (1.0 - s_arr) + q = v_arr * (1.0 - s_arr * f_arr) + tt = v_arr * (1.0 - s_arr * (1.0 - f_arr)) + ro = np.empty(n, dtype=np.float32) + go = np.empty(n, dtype=np.float32) + bo = np.empty(n, dtype=np.float32) + for sxt, rv, gv, bv in ( + (0, v_arr, tt, p), (1, q, v_arr, p), + (2, p, v_arr, tt), (3, p, q, v_arr), + (4, tt, p, v_arr), (5, v_arr, p, q), + ): + m = hi == sxt + ro[m] = rv[m]; go[m] = gv[m]; bo[m] = bv[m] + buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8) + buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8) + buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8) colors = buf if colors is not None: diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 486ffff..da5c605 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -18,6 +18,7 @@ from wled_controller.core.processing.color_strip_stream import ( PictureColorStripStream, StaticColorStripStream, ) +from wled_controller.core.processing.processed_stream import ProcessedColorStripStream from wled_controller.core.processing.effect_stream import EffectColorStripStream from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream from wled_controller.core.processing.notification_stream import NotificationColorStripStream @@ -68,7 +69,7 @@ class ColorStripStreamManager: keyed by ``{css_id}:{consumer_id}``. """ - def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None): + def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None): """ Args: color_strip_store: ColorStripStore for resolving source configs @@ -77,6 +78,7 @@ class ColorStripStreamManager: audio_source_store: AudioSourceStore for resolving audio source chains sync_clock_manager: SyncClockManager for acquiring clock runtimes value_stream_manager: ValueStreamManager for per-layer brightness sources + cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains """ self._color_strip_store = color_strip_store self._live_stream_manager = live_stream_manager @@ -85,6 +87,7 @@ class ColorStripStreamManager: self._audio_template_store = audio_template_store self._sync_clock_manager = sync_clock_manager self._value_stream_manager = value_stream_manager + self._cspt_store = cspt_store self._streams: Dict[str, _ColorStripEntry] = {} def _inject_clock(self, css_stream, source) -> Optional[str]: @@ -161,10 +164,12 @@ class ColorStripStreamManager: css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store) elif source.source_type == "composite": from wled_controller.core.processing.composite_stream import CompositeColorStripStream - css_stream = CompositeColorStripStream(source, self, self._value_stream_manager) + css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store) elif source.source_type == "mapped": from wled_controller.core.processing.mapped_stream import MappedColorStripStream css_stream = MappedColorStripStream(source, self) + elif source.source_type == "processed": + css_stream = ProcessedColorStripStream(source, self, self._cspt_store) else: stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) if not stream_cls: diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 03dbd1c..0dc8262 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -29,7 +29,7 @@ class CompositeColorStripStream(ColorStripStream): sub-stream's latest colors and blending bottom-to-top. """ - def __init__(self, source, css_manager, value_stream_manager=None): + def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None): import uuid as _uuid self._source_id: str = source.id self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races @@ -38,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream): self._auto_size: bool = source.led_count == 0 self._css_manager = css_manager self._value_stream_manager = value_stream_manager + self._cspt_store = cspt_store self._fps: int = 30 self._frame_time: float = 1.0 / 30 @@ -309,6 +310,9 @@ class CompositeColorStripStream(ColorStripStream): # ── Processing loop ───────────────────────────────────────── def _processing_loop(self) -> None: + # Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...]) + _layer_cspt_cache: Dict[int, tuple] = {} + try: while self._running: loop_start = time.perf_counter() @@ -341,6 +345,37 @@ class CompositeColorStripStream(ColorStripStream): if colors is None: continue + # Apply per-layer CSPT filters + _layer_tmpl_id = layer.get("processing_template_id") or "" + if _layer_tmpl_id and self._cspt_store: + cached = _layer_cspt_cache.get(i) + if cached is None or cached[0] != _layer_tmpl_id: + # Resolve and cache filters for this layer + try: + from wled_controller.core.filters.registry import FilterRegistry + _resolved = self._cspt_store.resolve_filter_instances( + self._cspt_store.get_template(_layer_tmpl_id).filters + ) + _filters = [ + FilterRegistry.create_instance(fi.filter_id, fi.options) + for fi in _resolved + if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True) + ] + _layer_cspt_cache[i] = (_layer_tmpl_id, _filters) + logger.info( + f"Composite layer {i} CSPT resolved {len(_filters)} filters " + f"from template {_layer_tmpl_id}" + ) + except Exception as e: + logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}") + _layer_cspt_cache[i] = (_layer_tmpl_id, []) + _layer_filters = _layer_cspt_cache[i][1] + if _layer_filters: + for _flt in _layer_filters: + _result = _flt.process_strip(colors) + if _result is not None: + colors = _result + # Resize to target LED count if needed if len(colors) != target_n: colors = self._resize_to_target(colors, target_n) diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index 059184d..252785e 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -444,21 +444,26 @@ class EffectColorStripStream(ColorStripStream): np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe') - # Bright white-ish head (2-3 LEDs — small, leave allocating) - head_mask = np.abs(indices - pos) < 1.5 - head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1) - buf[head_mask, 0] = np.clip( - buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16), - 0, 255, - ).astype(np.uint8) - buf[head_mask, 1] = np.clip( - buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16), - 0, 255, - ).astype(np.uint8) - buf[head_mask, 2] = np.clip( - buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16), - 0, 255, - ).astype(np.uint8) + # Bright white-ish head (2-3 LEDs) — direct index range to avoid + # boolean mask allocations and fancy indexing temporaries. + head_lo = max(0, int(pos - 1.5) + 1) + head_hi = min(n, int(pos + 1.5) + 1) + if head_hi > head_lo: + head_sl = slice(head_lo, head_hi) + head_dist = self._s_f32_a[head_sl] + np.subtract(indices[head_sl], pos, out=head_dist) + np.abs(head_dist, out=head_dist) + # head_brightness = clip(1 - abs_dist, 0, 1) + head_br = self._s_f32_b[head_sl] + np.subtract(1.0, head_dist, out=head_br) + np.clip(head_br, 0, 1, out=head_br) + # Additive blend towards white using scratch _s_f32_c slice + tmp = self._s_f32_c[head_sl] + for ch_idx, ch_base in enumerate((r, g, b)): + np.multiply(head_br, 255 - ch_base, out=tmp) + tmp += buf[head_sl, ch_idx] + np.clip(tmp, 0, 255, out=tmp) + np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe') # ── Plasma ─────────────────────────────────────────────────────── diff --git a/server/src/wled_controller/core/processing/live_stream_manager.py b/server/src/wled_controller/core/processing/live_stream_manager.py index d977d7b..049dd99 100644 --- a/server/src/wled_controller/core/processing/live_stream_manager.py +++ b/server/src/wled_controller/core/processing/live_stream_manager.py @@ -266,7 +266,12 @@ class LiveStreamManager: @staticmethod def _load_static_image(image_source: str) -> np.ndarray: - """Load a static image from URL or file path, return as RGB numpy array.""" + """Load a static image from URL or file path, return as RGB numpy array. + + Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s. + This is acceptable because acquire() (the only caller chain) is always + invoked from background worker threads, never from the async event loop. + """ from io import BytesIO from pathlib import Path diff --git a/server/src/wled_controller/core/processing/mapped_stream.py b/server/src/wled_controller/core/processing/mapped_stream.py index 227ac00..99a6855 100644 --- a/server/src/wled_controller/core/processing/mapped_stream.py +++ b/server/src/wled_controller/core/processing/mapped_stream.py @@ -163,6 +163,9 @@ class MappedColorStripStream(ColorStripStream): def _processing_loop(self) -> None: frame_time = self._frame_time + _pool_n = 0 + _buf_a = _buf_b = None + _use_a = True try: while self._running: loop_start = time.perf_counter() @@ -173,7 +176,14 @@ class MappedColorStripStream(ColorStripStream): time.sleep(frame_time) continue - result = np.zeros((target_n, 3), dtype=np.uint8) + if target_n != _pool_n: + _pool_n = target_n + _buf_a = np.zeros((target_n, 3), dtype=np.uint8) + _buf_b = np.zeros((target_n, 3), dtype=np.uint8) + + result = _buf_a if _use_a else _buf_b + _use_a = not _use_a + result[:] = 0 with self._sub_lock: sub_snapshot = dict(self._sub_streams) diff --git a/server/src/wled_controller/core/processing/metrics_history.py b/server/src/wled_controller/core/processing/metrics_history.py index fa36514..3cf5fa8 100644 --- a/server/src/wled_controller/core/processing/metrics_history.py +++ b/server/src/wled_controller/core/processing/metrics_history.py @@ -5,7 +5,10 @@ from collections import deque from datetime import datetime, timezone from typing import Dict, Optional +import psutil + from wled_controller.utils import get_logger +from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle logger = get_logger(__name__) @@ -18,8 +21,6 @@ def _collect_system_snapshot() -> dict: Returns a dict suitable for direct JSON serialization. """ - import psutil - mem = psutil.virtual_memory() snapshot = { "t": datetime.now(timezone.utc).isoformat(), @@ -32,8 +33,6 @@ def _collect_system_snapshot() -> dict: } try: - from wled_controller.api.routes.system import _nvml_available, _nvml, _nvml_handle - if _nvml_available: util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle) temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU) diff --git a/server/src/wled_controller/core/processing/os_notification_listener.py b/server/src/wled_controller/core/processing/os_notification_listener.py index b98290b..90791cd 100644 --- a/server/src/wled_controller/core/processing/os_notification_listener.py +++ b/server/src/wled_controller/core/processing/os_notification_listener.py @@ -258,6 +258,11 @@ class OsNotificationListener: # Recent notification history (thread-safe deque, newest first) self._history: collections.deque = collections.deque(maxlen=50) + @property + def available(self) -> bool: + """Whether a platform backend is active and listening.""" + return self._available + def start(self) -> None: global _instance _instance = self diff --git a/server/src/wled_controller/core/processing/processed_stream.py b/server/src/wled_controller/core/processing/processed_stream.py new file mode 100644 index 0000000..6b0beef --- /dev/null +++ b/server/src/wled_controller/core/processing/processed_stream.py @@ -0,0 +1,159 @@ +"""Processed color strip stream — wraps another CSS and applies a CSPT filter chain.""" + +import threading +import time +from typing import Optional + +import numpy as np + +from wled_controller.core.processing.color_strip_stream import ColorStripStream +from wled_controller.core.filters import FilterRegistry +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class ProcessedColorStripStream(ColorStripStream): + """Color strip stream that wraps an input CSS and applies CSPT filters. + + Acquires the input stream from the manager, reads its colors, applies + the filter chain from the referenced processing template, and caches + the result. + """ + + def __init__(self, source, css_manager, cspt_store=None): + self._source = source + self._css_manager = css_manager + self._cspt_store = cspt_store + self._input_stream: Optional[ColorStripStream] = None + self._consumer_id = f"__processed_{source.id}__" + self._filters = [] + self._cached_template_id = None + self._running = False + self._thread: Optional[threading.Thread] = None + self._colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + self._led_count = 0 + self._auto_size = True + self._fps = 30 + self._frame_time = 1.0 / 30 + self._resolve_count = 0 + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + if self._input_stream: + return self._input_stream.led_count + return self._led_count or 1 + + @property + def is_animated(self) -> bool: + if self._input_stream: + return self._input_stream.is_animated + return True + + def configure(self, device_led_count: int) -> None: + self._led_count = device_led_count + self._auto_size = True + if self._input_stream and hasattr(self._input_stream, 'configure'): + self._input_stream.configure(device_led_count) + + def start(self) -> None: + if self._running: + return + # Acquire input stream + input_id = self._source.input_source_id + if not input_id: + raise ValueError(f"Processed source {self._source.id} has no input_source_id") + self._input_stream = self._css_manager.acquire(input_id, self._consumer_id) + # Resolve initial filter chain + self._resolve_filters() + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, + name=f"css-processed-{self._source.id[:8]}", + daemon=True, + ) + self._thread.start() + logger.info(f"ProcessedColorStripStream started for {self._source.id}") + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + self._thread = None + # Release input stream + if self._input_stream: + input_id = self._source.input_source_id + self._css_manager.release(input_id, self._consumer_id) + self._input_stream = None + logger.info(f"ProcessedColorStripStream stopped for {self._source.id}") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._colors + + def update_source(self, source) -> None: + self._source = source + # Force re-resolve filters on next iteration + self._cached_template_id = None + + def set_clock(self, clock_runtime) -> None: + if self._input_stream and hasattr(self._input_stream, 'set_clock'): + self._input_stream.set_clock(clock_runtime) + + def _resolve_filters(self) -> None: + """Resolve the CSPT filter chain from the template.""" + template_id = self._source.processing_template_id + if template_id == self._cached_template_id: + return + self._cached_template_id = template_id + self._filters = [] + if not template_id or not self._cspt_store: + return + try: + template = self._cspt_store.get_template(template_id) + resolved = self._cspt_store.resolve_filter_instances(template.filters) + self._filters = [ + FilterRegistry.create_instance(fi.filter_id, fi.options) + for fi in resolved + ] + except Exception as e: + logger.warning(f"Failed to resolve CSPT {template_id}: {e}") + self._filters = [] + + def _processing_loop(self) -> None: + """Main loop: read input, apply filters, cache result.""" + while self._running: + t0 = time.monotonic() + + # Periodically re-resolve filters (every 30 iterations) + self._resolve_count += 1 + if self._resolve_count >= 30: + self._resolve_count = 0 + self._resolve_filters() + + colors = None + if self._input_stream: + colors = self._input_stream.get_latest_colors() + + if colors is not None and self._filters: + for flt in self._filters: + try: + result = flt.process_strip(colors) + if result is not None: + colors = result + except Exception as e: + logger.warning(f"Filter error in processed stream: {e}") + + if colors is not None: + with self._colors_lock: + self._colors = colors + + elapsed = time.monotonic() - t0 + sleep_time = self._frame_time - elapsed + if sleep_time > 0: + time.sleep(sleep_time) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 3aff4e2..a3aa025 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -86,7 +86,7 @@ class ProcessorManager: Targets are registered for processing via polymorphic TargetProcessor subclasses. """ - def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None): + def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None, cspt_store=None): """Initialize processor manager.""" self._devices: Dict[str, DeviceState] = {} self._processors: Dict[str, TargetProcessor] = {} @@ -102,6 +102,7 @@ class ProcessorManager: self._audio_source_store = audio_source_store self._audio_template_store = audio_template_store self._value_source_store = value_source_store + self._cspt_store = cspt_store self._live_stream_manager = LiveStreamManager( picture_source_store, capture_template_store, pp_template_store ) @@ -114,6 +115,7 @@ class ProcessorManager: audio_source_store=audio_source_store, audio_template_store=audio_template_store, sync_clock_manager=sync_clock_manager, + cspt_store=cspt_store, ) self._value_stream_manager = ValueStreamManager( value_source_store=value_source_store, @@ -160,6 +162,7 @@ class ProcessorManager: device_store=self._device_store, color_strip_stream_manager=self._color_strip_stream_manager, value_stream_manager=self._value_stream_manager, + cspt_store=self._cspt_store, fire_event=self.fire_event, get_device_info=self._get_device_info, ) @@ -185,8 +188,8 @@ class ProcessorManager: chroma_device_type = "chromalink" gamesense_device_type = "keyboard" if self._device_store: - dev = self._device_store.get_device(ds.device_id) - if dev: + try: + dev = self._device_store.get_device(ds.device_id) send_latency_ms = getattr(dev, "send_latency_ms", 0) rgbw = getattr(dev, "rgbw", False) dmx_protocol = getattr(dev, "dmx_protocol", "artnet") @@ -201,6 +204,8 @@ class ProcessorManager: spi_led_type = getattr(dev, "spi_led_type", "WS2812B") chroma_device_type = getattr(dev, "chroma_device_type", "chromalink") gamesense_device_type = getattr(dev, "gamesense_device_type", "keyboard") + except ValueError: + pass return DeviceInfo( device_id=ds.device_id, @@ -356,9 +361,11 @@ class ProcessorManager: # (e.g. mock devices have no real hardware to query) rgbw = h.device_rgbw if rgbw is None and self._device_store: - dev = self._device_store.get_device(device_id) - if dev: + try: + dev = self._device_store.get_device(device_id) rgbw = getattr(dev, "rgbw", False) + except ValueError: + pass return { "device_id": device_id, "device_online": h.online, @@ -529,9 +536,11 @@ class ProcessorManager: dev_name = proc.device_id tgt_name = other_id if self._device_store: - dev = self._device_store.get_device(proc.device_id) - if dev: + try: + dev = self._device_store.get_device(proc.device_id) dev_name = dev.name + except ValueError: + pass raise RuntimeError( f"Device '{dev_name}' is already being processed by target {tgt_name}" ) diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 098350d..40d6dea 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.pattern_template_store import PatternTemplateStore + from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore # --------------------------------------------------------------------------- @@ -112,6 +113,7 @@ class TargetContext: device_store: Optional["DeviceStore"] = None color_strip_stream_manager: Optional["ColorStripStreamManager"] = None value_stream_manager: Optional["ValueStreamManager"] = None + cspt_store: Optional["ColorStripProcessingTemplateStore"] = None fire_event: Callable[[dict], None] = lambda e: None get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index a4a6554..6f4b729 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -644,6 +644,12 @@ class WledTargetProcessor(TargetProcessor): self._effective_fps = self._target_fps self._device_reachable = None + # --- CSPT (Color Strip Processing Template) filter cache --- + _cspt_cached_template_id: Optional[str] = None # last resolved template ID + _cspt_filters: list = [] # list of PostprocessingFilter instances + _cspt_check_interval = 30 # re-check device template ID every N iterations + _cspt_check_counter = 0 + logger.info( f"Processing loop started for target {self._target_id} " f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}" @@ -746,6 +752,45 @@ class WledTargetProcessor(TargetProcessor): await asyncio.sleep(frame_time) continue + # --- Apply device default CSPT filters --- + _cspt_check_counter += 1 + if _cspt_check_counter >= _cspt_check_interval: + _cspt_check_counter = 0 + # Re-read the device's default template ID + _cur_cspt_id = "" + if self._ctx.device_store: + try: + _dev = self._ctx.device_store.get(self._device_id) + _cur_cspt_id = getattr(_dev, "default_css_processing_template_id", "") or "" + except Exception: + _cur_cspt_id = "" + if _cur_cspt_id != _cspt_cached_template_id: + _cspt_cached_template_id = _cur_cspt_id + _cspt_filters = [] + if _cur_cspt_id and self._ctx.cspt_store: + try: + from wled_controller.core.filters.registry import FilterRegistry + _resolved = self._ctx.cspt_store.resolve_filter_instances( + self._ctx.cspt_store.get_template(_cur_cspt_id).filters + ) + _cspt_filters = [ + FilterRegistry.create_instance(fi.filter_id, fi.options) + for fi in _resolved + if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True) + ] + logger.info( + f"CSPT resolved {len(_cspt_filters)} filters for " + f"device {self._device_id} template {_cur_cspt_id}" + ) + except Exception as e: + logger.warning(f"Failed to resolve CSPT {_cur_cspt_id}: {e}") + _cspt_filters = [] + if _cspt_filters: + for _flt in _cspt_filters: + _result = _flt.process_strip(frame) + if _result is not None: + frame = _result + cur_brightness = _effective_brightness(device_info) # Min brightness threshold: combine brightness source diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index b8950b0..50c5f78 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -31,6 +31,7 @@ from wled_controller.storage.value_source_store import ValueSourceStore from wled_controller.storage.automation_store import AutomationStore from wled_controller.storage.scene_preset_store import ScenePresetStore from wled_controller.storage.sync_clock_store import SyncClockStore +from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore from wled_controller.core.processing.sync_clock_manager import SyncClockManager from wled_controller.core.automations.automation_engine import AutomationEngine from wled_controller.core.mqtt.mqtt_service import MQTTService @@ -61,6 +62,7 @@ value_source_store = ValueSourceStore(config.storage.value_sources_file) automation_store = AutomationStore(config.storage.automations_file) scene_preset_store = ScenePresetStore(config.storage.scene_presets_file) sync_clock_store = SyncClockStore(config.storage.sync_clocks_file) +cspt_store = ColorStripProcessingTemplateStore(config.storage.color_strip_processing_templates_file) sync_clock_manager = SyncClockManager(sync_clock_store) processor_manager = ProcessorManager( @@ -74,6 +76,7 @@ processor_manager = ProcessorManager( value_source_store=value_source_store, audio_template_store=audio_template_store, sync_clock_manager=sync_clock_manager, + cspt_store=cspt_store, ) @@ -144,6 +147,7 @@ async def lifespan(app: FastAPI): auto_backup_engine=auto_backup_engine, sync_clock_store=sync_clock_store, sync_clock_manager=sync_clock_manager, + cspt_store=cspt_store, ) # Register devices in processor manager for health monitoring @@ -281,10 +285,12 @@ async def pwa_service_worker(): # Middleware: no-cache for static JS/CSS (development convenience) @app.middleware("http") async def _no_cache_static(request: Request, call_next): - response = await call_next(request) - if request.url.path.startswith("/static/") and request.url.path.endswith((".js", ".css", ".json")): + path = request.url.path + if path.startswith("/static/") and path.endswith((".js", ".css", ".json")): + response = await call_next(request) response.headers["Cache-Control"] = "no-cache, must-revalidate" - return response + return response + return await call_next(request) # Mount static files static_path = Path(__file__).parent / "static" diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 5f4f279..d3c19dd 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -636,6 +636,7 @@ textarea:focus-visible { .icon-select-grid { display: grid; + grid-auto-rows: 1fr; gap: 6px; padding: 6px; border: 1px solid var(--border-color); diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 5abcbdb..8167590 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -65,6 +65,9 @@ import { addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption, renderModalFilterList, updateCaptureDuration, cloneStream, cloneCaptureTemplate, clonePPTemplate, + showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT, + csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption, + renderCSPTModalFilterList, expandAllStreamSections, collapseAllStreamSections, } from './features/streams.js'; import { @@ -125,7 +128,7 @@ import { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, - testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, + testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, } from './features/color-strips.js'; // Layer 5: audio sources @@ -295,6 +298,17 @@ Object.assign(window, { cloneStream, cloneCaptureTemplate, clonePPTemplate, + showAddCSPTModal, + editCSPT, + closeCSPTModal, + saveCSPT, + deleteCSPT, + cloneCSPT, + csptAddFilterFromSelect, + csptToggleFilterExpand, + csptRemoveFilter, + csptUpdateFilterOption, + renderCSPTModalFilterList, showAddAudioTemplateModal, editAudioTemplate, closeAudioTemplateModal, @@ -413,7 +427,7 @@ Object.assign(window, { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, - testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, + testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, // audio sources showAudioSourceModal, diff --git a/server/src/wled_controller/static/js/core/graph-layout.js b/server/src/wled_controller/static/js/core/graph-layout.js index 0d84a0a..aed79bb 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.js +++ b/server/src/wled_controller/static/js/core/graph-layout.js @@ -116,6 +116,7 @@ export const ENTITY_COLORS = { scene_preset: '#CE93D8', automation: '#A5D6A7', pattern_template: '#BCAAA4', + cspt: '#7E57C2', }; export const ENTITY_LABELS = { @@ -132,6 +133,7 @@ export const ENTITY_LABELS = { scene_preset: 'Scene Preset', automation: 'Automation', pattern_template: 'Pattern Template', + cspt: 'Strip Processing', }; /* ── Edge type (for CSS class) ── */ @@ -145,6 +147,7 @@ function edgeType(fromKind, toKind, field) { if (fromKind === 'audio_source' || fromKind === 'audio_template') return 'audio'; if (fromKind === 'capture_template' || fromKind === 'pp_template' || fromKind === 'pattern_template') return 'template'; if (fromKind === 'scene_preset') return 'scene'; + if (fromKind === 'cspt') return 'template'; return 'default'; } @@ -238,6 +241,11 @@ function buildGraph(e) { addNode(a.id, 'automation', a.name, '', { running: a.enabled || false }); } + // 14. Color strip processing templates (CSPT) + for (const t of e.csptTemplates || []) { + addNode(t.id, 'cspt', t.name, ''); + } + // ── Edges ── // Picture source edges @@ -265,6 +273,10 @@ function buildGraph(e) { if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id'); if (s.clock_id) addEdge(s.clock_id, s.id, 'clock_id'); + // Processed type: input source and processing template edges + if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id'); + if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id'); + // Composite layers if (s.layers) { for (const layer of s.layers) { @@ -316,6 +328,20 @@ function buildGraph(e) { if (a.deactivation_scene_preset_id) addEdge(a.deactivation_scene_preset_id, a.id, 'deactivation_scene_preset_id'); } + // Composite layer → CSPT edges + for (const s of e.colorStripSources || []) { + if (s.layers) { + for (const layer of s.layers) { + if (layer.processing_template_id) addEdge(layer.processing_template_id, s.id, 'layer.processing_template_id'); + } + } + } + + // Device → CSPT default template edges + for (const d of e.devices || []) { + if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id'); + } + return { nodes, edges }; } diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index 67e86c4..72d7f0b 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -24,6 +24,7 @@ const KIND_ICONS = { output_target: P.zap, scene_preset: P.sparkles, automation: P.clipboardList, + cspt: P.wrench, }; // ── Subtype-specific icon overrides ── @@ -34,6 +35,7 @@ const SUBTYPE_ICONS = { mapped: P.mapPin, mapped_zones: P.mapPin, audio: P.music, audio_visualization: P.music, api_input: P.send, notification: P.bellRing, daylight: P.sun, candlelight: P.flame, + processed: P.sparkles, }, picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image }, value_source: { diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 0c94c36..18557bc 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -26,6 +26,7 @@ const _colorStripTypeIcons = { notification: _svg(P.bellRing), daylight: _svg(P.sun), candlelight: _svg(P.flame), + processed: _svg(P.sparkles), }; const _valueSourceTypeIcons = { static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music), @@ -102,6 +103,7 @@ export const ICON_CAPTURE_TEMPLATE = _svg(P.camera); export const ICON_PP_TEMPLATE = _svg(P.wrench); export const ICON_PATTERN_TEMPLATE = _svg(P.fileText); export const ICON_AUDIO_TEMPLATE = _svg(P.music); +export const ICON_CSPT = ''; // ── Action constants ──────────────────────────────────────── diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 7f54227..5bc91f6 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -83,6 +83,17 @@ export function set_modalFilters(v) { _modalFilters = v; } export let _ppTemplateNameManuallyEdited = false; export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; } +// CSPT (Color Strip Processing Template) state +export let _csptModalFilters = []; +export function set_csptModalFilters(v) { _csptModalFilters = v; } + +export let _csptNameManuallyEdited = false; +export function set_csptNameManuallyEdited(v) { _csptNameManuallyEdited = v; } + +export let _stripFilters = []; + +export let _cachedCSPTemplates = []; + // Stream test state export let _currentTestStreamId = null; export function set_currentTestStreamId(v) { _currentTestStreamId = v; } @@ -260,6 +271,18 @@ export const colorStripSourcesCache = new DataCache({ extractData: json => json.sources || [], }); +export const csptCache = new DataCache({ + endpoint: '/color-strip-processing-templates', + extractData: json => json.templates || [], +}); +csptCache.subscribe(v => { _cachedCSPTemplates = v; }); + +export const stripFiltersCache = new DataCache({ + endpoint: '/strip-filters', + extractData: json => json.filters || [], +}); +stripFiltersCache.subscribe(v => { _stripFilters = v; }); + export const devicesCache = new DataCache({ endpoint: '/devices', extractData: json => json.devices || [], diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index d88a32b..fa720c5 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -3,7 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.js'; -import { _cachedSyncClocks, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache } from '../core/state.js'; +import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; @@ -48,11 +48,7 @@ class CSSEditorModal extends Modal { picture_source: document.getElementById('css-editor-picture-source').value, interpolation: document.getElementById('css-editor-interpolation').value, smoothing: document.getElementById('css-editor-smoothing').value, - brightness: document.getElementById('css-editor-brightness').value, - saturation: document.getElementById('css-editor-saturation').value, - gamma: document.getElementById('css-editor-gamma').value, 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(getGradientStops()) : '[]', animation_type: document.getElementById('css-editor-animation-type').value, @@ -89,6 +85,8 @@ class CSSEditorModal extends Modal { candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value, candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value, candlelight_speed: document.getElementById('css-editor-candlelight-speed').value, + processed_input: document.getElementById('css-editor-processed-input').value, + processed_template: document.getElementById('css-editor-processed-template').value, tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []), }; } @@ -102,13 +100,15 @@ let _cssTagsInput = null; let _cssPictureSourceEntitySelect = null; let _cssAudioSourceEntitySelect = null; let _cssClockEntitySelect = null; +let _processedInputEntitySelect = null; +let _processedTemplateEntitySelect = null; /* ── Icon-grid type selector ──────────────────────────────────── */ const CSS_TYPE_KEYS = [ 'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', 'audio', - 'api_input', 'notification', 'daylight', 'candlelight', + 'api_input', 'notification', 'daylight', 'candlelight', 'processed', ]; function _buildCSSTypeItems() { @@ -159,8 +159,10 @@ export function onCSSTypeChange() { document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none'; document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none'; document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none'; + document.getElementById('css-editor-processed-section').style.display = type === 'processed' ? '' : 'none'; if (isPictureType) _ensureInterpolationIconSelect(); + if (type === 'processed') _populateProcessedSelectors(); if (type === 'effect') { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); @@ -244,6 +246,45 @@ export function onCSSClockChange() { // No-op: speed sliders removed; speed is now clock-only } +function _populateProcessedSelectors() { + const editingId = document.getElementById('css-editor-id').value; + const allSources = colorStripSourcesCache.data || []; + // Exclude self and other processed sources to prevent cycles + const inputSources = allSources.filter(s => s.id !== editingId && s.source_type !== 'processed'); + const inputSel = document.getElementById('css-editor-processed-input'); + const prevInput = inputSel.value; + inputSel.innerHTML = '' + + inputSources.map(s => ``).join(''); + inputSel.value = prevInput || ''; + if (_processedInputEntitySelect) _processedInputEntitySelect.destroy(); + if (inputSources.length > 0) { + _processedInputEntitySelect = new EntitySelect({ + target: inputSel, + getItems: () => (colorStripSourcesCache.data || []) + .filter(s => s.id !== editingId && s.source_type !== 'processed') + .map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })), + placeholder: t('palette.search'), + }); + } + + const templates = csptCache.data || []; + const tplSel = document.getElementById('css-editor-processed-template'); + const prevTpl = tplSel.value; + tplSel.innerHTML = '' + + templates.map(tp => ``).join(''); + tplSel.value = prevTpl || ''; + if (_processedTemplateEntitySelect) _processedTemplateEntitySelect.destroy(); + if (templates.length > 0) { + _processedTemplateEntitySelect = new EntitySelect({ + target: tplSel, + getItems: () => (csptCache.data || []).map(tp => ({ + value: tp.id, label: tp.name, icon: ICON_SPARKLES, + })), + placeholder: t('palette.search'), + }); + } +} + function _getAnimationPayload() { const type = document.getElementById('css-editor-animation-type').value; return { @@ -543,6 +584,7 @@ let _compositeAvailableSources = []; // non-composite sources for layer dropdow let _compositeSourceEntitySelects = []; let _compositeBrightnessEntitySelects = []; let _compositeBlendIconSelects = []; +let _compositeCSPTEntitySelects = []; function _compositeDestroyEntitySelects() { _compositeSourceEntitySelects.forEach(es => es.destroy()); @@ -551,6 +593,8 @@ function _compositeDestroyEntitySelects() { _compositeBrightnessEntitySelects = []; _compositeBlendIconSelects.forEach(is => is.destroy()); _compositeBlendIconSelects = []; + _compositeCSPTEntitySelects.forEach(es => es.destroy()); + _compositeCSPTEntitySelects = []; } function _getCompositeBlendItems() { @@ -578,6 +622,14 @@ function _getCompositeBrightnessItems() { })); } +function _getCompositeCSPTItems() { + return (_cachedCSPTemplates || []).map(tmpl => ({ + value: tmpl.id, + label: tmpl.name, + icon: ICON_SPARKLES, + })); +} + function _compositeRenderList() { const list = document.getElementById('composite-layers-list'); if (!list) return; @@ -591,6 +643,11 @@ function _compositeRenderList() { vsList.map(v => `` ).join(''); + const csptList = _cachedCSPTemplates || []; + const csptOptions = `` + + csptList.map(tmpl => + `` + ).join(''); const canRemove = _compositeLayers.length > 1; return `
@@ -625,6 +682,12 @@ function _compositeRenderList() {
+
+ + +
`; }).join(''); @@ -663,6 +726,17 @@ function _compositeRenderList() { noneLabel: t('color_strip.composite.brightness.none'), })); }); + + // Attach EntitySelect to each layer's CSPT dropdown + list.querySelectorAll('.composite-layer-cspt').forEach(sel => { + _compositeCSPTEntitySelects.push(new EntitySelect({ + target: sel, + getItems: _getCompositeCSPTItems, + placeholder: t('palette.search'), + allowNone: true, + noneLabel: t('common.none'), + })); + }); } export function compositeAddLayer() { @@ -673,6 +747,7 @@ export function compositeAddLayer() { opacity: 1.0, enabled: true, brightness_source_id: null, + processing_template_id: null, }); _compositeRenderList(); } @@ -692,6 +767,7 @@ function _compositeLayersSyncFromDom() { const opacities = list.querySelectorAll('.composite-layer-opacity'); const enableds = list.querySelectorAll('.composite-layer-enabled'); const briSrcs = list.querySelectorAll('.composite-layer-brightness'); + const csptSels = list.querySelectorAll('.composite-layer-cspt'); if (srcs.length === _compositeLayers.length) { for (let i = 0; i < srcs.length; i++) { _compositeLayers[i].source_id = srcs[i].value; @@ -699,6 +775,7 @@ function _compositeLayersSyncFromDom() { _compositeLayers[i].opacity = parseFloat(opacities[i].value); _compositeLayers[i].enabled = enableds[i].checked; _compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null; + _compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null; } } } @@ -713,6 +790,7 @@ function _compositeGetLayers() { enabled: l.enabled, }; if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id; + if (l.processing_template_id) layer.processing_template_id = l.processing_template_id; return layer; }); } @@ -726,8 +804,9 @@ function _loadCompositeState(css) { opacity: l.opacity != null ? l.opacity : 1.0, enabled: l.enabled != null ? l.enabled : true, brightness_source_id: l.brightness_source_id || null, + processing_template_id: l.processing_template_id || null, })) - : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null }]; + : [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }]; _compositeRenderList(); } @@ -1217,6 +1296,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ${numCandles} ${t('color_strip.candlelight.num_candles')} ${clockBadge} `; + } else if (source.source_type === 'processed') { + const inputSrc = (colorStripSourcesCache.data || []).find(s => s.id === source.input_source_id); + const inputName = inputSrc?.name || source.input_source_id || '—'; + const tplName = source.processing_template_id + ? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id) + : '—'; + propsHtml = ` + ${ICON_LINK_SOURCE} ${escapeHtml(inputName)} + ${ICON_SPARKLES} ${escapeHtml(tplName)} + `; } else if (isPictureAdvanced) { const cal = source.calibration || {}; const lines = cal.lines || []; @@ -1253,7 +1342,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const icon = getColorStripIcon(source.source_type); const isDaylight = source.source_type === 'daylight'; const isCandlelight = source.source_type === 'candlelight'; - const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight); + const isProcessed = source.source_type === 'processed'; + const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight && !isProcessed); const calibrationBtn = isPictureKind ? `` : ''; @@ -1427,6 +1517,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType = document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3; document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0; document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1); + } else if (sourceType === 'processed') { + await csptCache.fetch(); + await colorStripSourcesCache.fetch(); + _populateProcessedSelectors(); + document.getElementById('css-editor-processed-input').value = css.input_source_id || ''; + document.getElementById('css-editor-processed-template').value = css.processing_template_id || ''; } else { if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || ''; @@ -1437,19 +1533,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType = document.getElementById('css-editor-smoothing').value = smoothing; document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2); - const brightness = css.brightness ?? 1.0; - document.getElementById('css-editor-brightness').value = brightness; - document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2); - - const saturation = css.saturation ?? 1.0; - document.getElementById('css-editor-saturation').value = saturation; - document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2); - - const gamma = css.gamma ?? 1.0; - document.getElementById('css-editor-gamma').value = gamma; - document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2); - - document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false; } document.getElementById('css-editor-led-count').value = css.led_count ?? 0; @@ -1496,13 +1579,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType = if (_interpolationIconSelect) _interpolationIconSelect.setValue('average'); document.getElementById('css-editor-smoothing').value = 0.3; document.getElementById('css-editor-smoothing-value').textContent = '0.30'; - document.getElementById('css-editor-brightness').value = 1.0; - document.getElementById('css-editor-brightness-value').textContent = '1.00'; - document.getElementById('css-editor-saturation').value = 1.0; - document.getElementById('css-editor-saturation-value').textContent = '1.00'; - document.getElementById('css-editor-gamma').value = 1.0; - document.getElementById('css-editor-gamma-value').textContent = '1.00'; - document.getElementById('css-editor-frame-interpolation').checked = false; document.getElementById('css-editor-color').value = '#ffffff'; document.getElementById('css-editor-led-count').value = 0; _loadAnimationState(null); @@ -1536,6 +1612,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType = document.getElementById('css-editor-candlelight-num-candles').value = 3; document.getElementById('css-editor-candlelight-speed').value = 1.0; document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0'; + // Processed defaults + if (presetType === 'processed') { + await csptCache.fetch(); + await colorStripSourcesCache.fetch(); + _populateProcessedSelectors(); + } const typeIcon = getColorStripIcon(presetType || 'picture'); document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${presetType || 'picture'}`)}`; document.getElementById('css-editor-gradient-preset').value = ''; @@ -1714,15 +1796,24 @@ export async function saveCSSEditor() { speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value), }; if (!cssId) payload.source_type = 'candlelight'; + } else if (sourceType === 'processed') { + const inputId = document.getElementById('css-editor-processed-input').value; + const templateId = document.getElementById('css-editor-processed-template').value; + if (!inputId) { + cssEditorModal.showError(t('color_strip.processed.error.no_input')); + return; + } + payload = { + name, + input_source_id: inputId, + processing_template_id: templateId || null, + }; + if (!cssId) payload.source_type = 'processed'; } else if (sourceType === 'picture_advanced') { payload = { name, interpolation_mode: document.getElementById('css-editor-interpolation').value, smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), - brightness: parseFloat(document.getElementById('css-editor-brightness').value), - saturation: parseFloat(document.getElementById('css-editor-saturation').value), - gamma: parseFloat(document.getElementById('css-editor-gamma').value), - frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, }; if (!cssId) payload.source_type = 'picture_advanced'; @@ -1732,10 +1823,6 @@ export async function saveCSSEditor() { picture_source_id: document.getElementById('css-editor-picture-source').value, interpolation_mode: document.getElementById('css-editor-interpolation').value, smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), - brightness: parseFloat(document.getElementById('css-editor-brightness').value), - saturation: parseFloat(document.getElementById('css-editor-saturation').value), - gamma: parseFloat(document.getElementById('css-editor-gamma').value), - frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, }; if (!cssId) payload.source_type = 'picture'; @@ -1906,6 +1993,9 @@ let _cssTestIsComposite = false; let _cssTestLayerData = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array } let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messages let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers) +let _cssTestCSPTMode = false; // true when testing a CSPT template +let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode +let _csptTestInputEntitySelect = null; function _getCssTestLedCount() { const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY), 10); @@ -1918,6 +2008,48 @@ function _getCssTestFps() { } export function testColorStrip(sourceId) { + _cssTestCSPTMode = false; + _cssTestCSPTId = null; + // Hide CSPT input selector + const csptGroup = document.getElementById('css-test-cspt-input-group'); + if (csptGroup) csptGroup.style.display = 'none'; + _openTestModal(sourceId); +} + +export async function testCSPT(templateId) { + _cssTestCSPTMode = true; + _cssTestCSPTId = templateId; + + // Populate input source selector + await colorStripSourcesCache.fetch(); + const sources = colorStripSourcesCache.data || []; + const nonProcessed = sources.filter(s => s.source_type !== 'processed'); + const sel = document.getElementById('css-test-cspt-input-select'); + sel.innerHTML = nonProcessed.map(s => + `` + ).join(''); + // EntitySelect for input source picker + if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy(); + _csptTestInputEntitySelect = new EntitySelect({ + target: sel, + getItems: () => (colorStripSourcesCache.data || []) + .filter(s => s.source_type !== 'processed') + .map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })), + placeholder: t('palette.search'), + }); + // Show CSPT input selector + const csptGroup = document.getElementById('css-test-cspt-input-group'); + if (csptGroup) csptGroup.style.display = ''; + + const inputId = sel.value; + if (!inputId) { + showToast(t('color_strip.processed.error.no_input'), 'error'); + return; + } + _openTestModal(inputId); +} + +function _openTestModal(sourceId) { // Clean up any previous session fully if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } @@ -1966,7 +2098,12 @@ function _cssTestConnect(sourceId, ledCount, fps) { if (!fps) fps = _getCssTestFps(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiKey = localStorage.getItem('wled_api_key') || ''; - const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; + let wsUrl; + if (_cssTestCSPTMode && _cssTestCSPTId) { + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`; + } else { + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; + } _cssTestWs = new WebSocket(wsUrl); _cssTestWs.binaryType = 'arraybuffer'; @@ -2171,6 +2308,14 @@ export function applyCssTestSettings() { _cssTestMeta = null; _cssTestLayerData = null; + // In CSPT mode, read selected input source + if (_cssTestCSPTMode) { + const inputSel = document.getElementById('css-test-cspt-input-select'); + if (inputSel && inputSel.value) { + _cssTestSourceId = inputSel.value; + } + } + // Reconnect (generation counter ignores stale frames from old WS) _cssTestConnect(_cssTestSourceId, leds, fps); } diff --git a/server/src/wled_controller/static/js/features/device-discovery.js b/server/src/wled_controller/static/js/features/device-discovery.js index b0d69be..7e9d6be 100644 --- a/server/src/wled_controller/static/js/features/device-discovery.js +++ b/server/src/wled_controller/static/js/features/device-discovery.js @@ -5,6 +5,7 @@ import { _discoveryScanRunning, set_discoveryScanRunning, _discoveryCache, set_discoveryCache, + csptCache, } from '../core/state.js'; import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js'; import { devicesCache } from '../core/state.js'; @@ -12,7 +13,8 @@ import { t } from '../core/i18n.js'; import { showToast, desktopFocus } from '../core/ui.js'; import { Modal } from '../core/modal.js'; import { _computeMaxFps, _renderFpsHint } from './devices.js'; -import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY } from '../core/icons.js'; +import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.js'; +import { EntitySelect } from '../core/entity-palette.js'; import { IconSelect, showTypePicker } from '../core/icon-select.js'; class AddDeviceModal extends Modal { @@ -30,6 +32,7 @@ class AddDeviceModal extends Modal { sendLatency: document.getElementById('device-send-latency')?.value || '0', zones: JSON.stringify(_getCheckedZones('device-zone-list')), zoneMode: _getZoneMode(), + csptId: document.getElementById('device-css-processing-template')?.value || '', dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet', dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0', dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1', @@ -53,6 +56,7 @@ function _buildDeviceTypeItems() { } let _deviceTypeIconSelect = null; +let _csptEntitySelect = null; function _ensureDeviceTypeIconSelect() { const sel = document.getElementById('device-type'); @@ -61,6 +65,30 @@ function _ensureDeviceTypeIconSelect() { _deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 }); } +function _ensureCsptEntitySelect() { + const sel = document.getElementById('device-css-processing-template'); + if (!sel) return; + const templates = csptCache.data || []; + // Populate native + onchange="${updateFn}(${index}, '${opt.key}', this.checked)"> `; } else if (opt.type === 'select' && Array.isArray(opt.choices)) { - // Exclude the template being edited from filter_template choices (prevent self-reference) - const editingId = document.getElementById('pp-template-id')?.value || ''; - const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId) + const editingId = document.getElementById(editingIdInputId)?.value || ''; + const filteredChoices = (fi.filter_id === selfRefFilterId && opt.key === 'template_id' && editingId) ? opt.choices.filter(c => c.value !== editingId) : opt.choices; - // Auto-correct if current value doesn't match any choice let selectVal = currentVal; if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) { selectVal = filteredChoices[0].value; fi.options[opt.key] = selectVal; } + const hasPaletteColors = filteredChoices.some(c => c.colors); const options = filteredChoices.map(c => `` ).join(''); + const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : ''; + const isTemplateRef = opt.key === 'template_id'; + const entityAttr = isTemplateRef ? ' data-entity-select="template"' : ''; html += `
- ${options}
`; @@ -2253,7 +2361,7 @@ export function renderModalFilterList() { + onchange="${updateFn}(${index}, '${opt.key}', this.value)"> `; } else { html += `
@@ -2263,7 +2371,7 @@ export function renderModalFilterList() { + oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
`; } } @@ -2273,18 +2381,72 @@ export function renderModalFilterList() { }); container.innerHTML = html; - _initFilterDrag(); + _initFilterDragForContainer(containerId, filtersArr, () => { + _renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId); + }); + // Initialize palette icon grids on select elements + _initFilterPaletteGrids(container); } -/* ── PP filter drag-and-drop reordering ── */ +/** Stored IconSelect instances for filter option selects (keyed by select element id). */ +const _filterOptionIconSelects = {}; + +function _paletteSwatchHTML(hexStr) { + const hexColors = hexStr.split(',').map(s => s.trim()); + if (hexColors.length === 1) { + return ``; + } + const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', '); + return ``; +} + +function _initFilterPaletteGrids(container) { + // Palette-colored grids (e.g. palette quantization preset) + container.querySelectorAll('select[data-palette-grid]').forEach(sel => { + if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy(); + try { + const choices = JSON.parse(sel.dataset.paletteGrid); + const items = choices.map(c => ({ + value: c.value, + label: c.label, + icon: _paletteSwatchHTML(c.colors || ''), + })); + _filterOptionIconSelects[sel.id] = new IconSelect({ target: sel, items, columns: 2 }); + } catch { /* ignore parse errors */ } + }); + // Template reference selects → EntitySelect (searchable palette) + container.querySelectorAll('select[data-entity-select]').forEach(sel => { + if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy(); + const icon = sel.dataset.entitySelect === 'template' ? ICON_PP_TEMPLATE : ICON_CSPT; + _filterOptionIconSelects[sel.id] = new EntitySelect({ + target: sel, + getItems: () => Array.from(sel.options).map(opt => ({ + value: opt.value, + label: opt.textContent, + icon, + })), + placeholder: t('palette.search'), + }); + }); +} + +export function renderModalFilterList() { + _renderFilterListGeneric('pp-filter-list', _modalFilters, _availableFilters, '', 'pp-template-id', 'filter_template'); +} + +export function renderCSPTModalFilterList() { + _renderFilterListGeneric('cspt-filter-list', _csptModalFilters, _stripFilters, 'cspt', 'cspt-id', 'css_filter_template'); +} + +/* ── Generic filter drag-and-drop reordering ── */ const _FILTER_DRAG_THRESHOLD = 5; const _FILTER_SCROLL_EDGE = 60; const _FILTER_SCROLL_SPEED = 12; let _filterDragState = null; -function _initFilterDrag() { - const container = document.getElementById('pp-filter-list'); +function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) { + const container = document.getElementById(containerId); if (!container) return; container.addEventListener('pointerdown', (e) => { @@ -2306,6 +2468,8 @@ function _initFilterDrag() { offsetY: 0, fromIndex, scrollRaf: null, + filtersArr, + rerenderFn, }; const onMove = (ev) => _onFilterDragMove(ev); @@ -2402,12 +2566,11 @@ function _onFilterDragEnd() { ds.clone.remove(); document.body.classList.remove('pp-filter-dragging'); - // Reorder _modalFilters array + // Reorder filters array if (toIndex !== ds.fromIndex) { - const [item] = _modalFilters.splice(ds.fromIndex, 1); - _modalFilters.splice(toIndex, 0, item); - renderModalFilterList(); - _autoGeneratePPTemplateName(); + const [item] = ds.filtersArr.splice(ds.fromIndex, 1); + ds.filtersArr.splice(toIndex, 0, item); + ds.rerenderFn(); } } @@ -2430,17 +2593,19 @@ function _filterAutoScroll(clientY, ds) { ds.scrollRaf = requestAnimationFrame(scroll); } -export function addFilterFromSelect() { - const select = document.getElementById('pp-add-filter-select'); +/** + * Generic: add a filter from a select element into a filters array. + */ +function _addFilterGeneric(selectId, filtersArr, filterDefs, iconSelect, renderFn, autoNameFn) { + const select = document.getElementById(selectId); const filterId = select.value; if (!filterId) return; - const filterDef = _availableFilters.find(f => f.filter_id === filterId); + const filterDef = filterDefs.find(f => f.filter_id === filterId); if (!filterDef) return; const options = {}; for (const opt of filterDef.options_schema) { - // For select options with empty default, use the first choice's value if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) { options[opt.key] = opt.choices[0].value; } else { @@ -2448,40 +2613,17 @@ export function addFilterFromSelect() { } } - _modalFilters.push({ filter_id: filterId, options, _expanded: true }); + filtersArr.push({ filter_id: filterId, options, _expanded: true }); select.value = ''; - if (_filterIconSelect) _filterIconSelect.setValue(''); - renderModalFilterList(); - _autoGeneratePPTemplateName(); + if (iconSelect) iconSelect.setValue(''); + renderFn(); + if (autoNameFn) autoNameFn(); } -export function toggleFilterExpand(index) { - if (_modalFilters[index]) { - _modalFilters[index]._expanded = !_modalFilters[index]._expanded; - renderModalFilterList(); - } -} - -export function removeFilter(index) { - _modalFilters.splice(index, 1); - renderModalFilterList(); - _autoGeneratePPTemplateName(); -} - -export function moveFilter(index, direction) { - const newIndex = index + direction; - if (newIndex < 0 || newIndex >= _modalFilters.length) return; - const tmp = _modalFilters[index]; - _modalFilters[index] = _modalFilters[newIndex]; - _modalFilters[newIndex] = tmp; - renderModalFilterList(); - _autoGeneratePPTemplateName(); -} - -export function updateFilterOption(filterIndex, optionKey, value) { - if (_modalFilters[filterIndex]) { - const fi = _modalFilters[filterIndex]; - const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); +function _updateFilterOptionGeneric(filterIndex, optionKey, value, filtersArr, filterDefs) { + if (filtersArr[filterIndex]) { + const fi = filtersArr[filterIndex]; + const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id); if (filterDef) { const optDef = filterDef.options_schema.find(o => o.key === optionKey); if (optDef && optDef.type === 'bool') { @@ -2501,6 +2643,49 @@ export function updateFilterOption(filterIndex, optionKey, value) { } } +// ── PP filter actions ── +export function addFilterFromSelect() { + _addFilterGeneric('pp-add-filter-select', _modalFilters, _availableFilters, _filterIconSelect, renderModalFilterList, _autoGeneratePPTemplateName); +} + +export function toggleFilterExpand(index) { + if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); } +} + +export function removeFilter(index) { + _modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName(); +} + +export function moveFilter(index, direction) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= _modalFilters.length) return; + const tmp = _modalFilters[index]; + _modalFilters[index] = _modalFilters[newIndex]; + _modalFilters[newIndex] = tmp; + renderModalFilterList(); _autoGeneratePPTemplateName(); +} + +export function updateFilterOption(filterIndex, optionKey, value) { + _updateFilterOptionGeneric(filterIndex, optionKey, value, _modalFilters, _availableFilters); +} + +// ── CSPT filter actions ── +export function csptAddFilterFromSelect() { + _addFilterGeneric('cspt-add-filter-select', _csptModalFilters, _stripFilters, _csptFilterIconSelect, renderCSPTModalFilterList, _autoGenerateCSPTName); +} + +export function csptToggleFilterExpand(index) { + if (_csptModalFilters[index]) { _csptModalFilters[index]._expanded = !_csptModalFilters[index]._expanded; renderCSPTModalFilterList(); } +} + +export function csptRemoveFilter(index) { + _csptModalFilters.splice(index, 1); renderCSPTModalFilterList(); _autoGenerateCSPTName(); +} + +export function csptUpdateFilterOption(filterIndex, optionKey, value) { + _updateFilterOptionGeneric(filterIndex, optionKey, value, _csptModalFilters, _stripFilters); +} + function collectFilters() { return _modalFilters.map(fi => ({ filter_id: fi.filter_id, @@ -2689,5 +2874,208 @@ export async function closePPTemplateModal() { await ppTemplateModal.close(); } +// ===== Color Strip Processing Templates (CSPT) ===== + +let _csptFilterIconSelect = null; + +async function loadStripFilters() { + await stripFiltersCache.fetch(); +} + +async function loadCSPTemplates() { + try { + if (_stripFilters.length === 0) await stripFiltersCache.fetch(); + await csptCache.fetch(); + renderPictureSourcesList(_cachedStreams); + } catch (error) { + console.error('Error loading CSPT:', error); + } +} + +function _populateCSPTFilterSelect() { + const select = document.getElementById('cspt-add-filter-select'); + select.innerHTML = ``; + const items = []; + for (const f of _stripFilters) { + const name = _getStripFilterName(f.filter_id); + select.innerHTML += ``; + const pathData = _FILTER_ICONS[f.filter_id] || P.wrench; + items.push({ + value: f.filter_id, + icon: `${pathData}`, + label: name, + desc: t(`filters.${f.filter_id}.desc`), + }); + } + if (_csptFilterIconSelect) { + _csptFilterIconSelect.updateItems(items); + } else if (items.length > 0) { + _csptFilterIconSelect = new IconSelect({ + target: select, + items, + columns: 3, + placeholder: t('filters.select_type'), + onChange: () => csptAddFilterFromSelect(), + }); + } +} + +function _autoGenerateCSPTName() { + if (_csptNameManuallyEdited) return; + if (document.getElementById('cspt-id').value) return; + const nameInput = document.getElementById('cspt-name'); + if (_csptModalFilters.length > 0) { + const filterNames = _csptModalFilters.map(f => _getStripFilterName(f.filter_id)).join(' + '); + nameInput.value = filterNames; + } else { + nameInput.value = ''; + } +} + +function collectCSPTFilters() { + return _csptModalFilters.map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + })); +} + +export async function showAddCSPTModal(cloneData = null) { + if (_stripFilters.length === 0) await loadStripFilters(); + + document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`; + document.getElementById('cspt-form').reset(); + document.getElementById('cspt-id').value = ''; + document.getElementById('cspt-error').style.display = 'none'; + + if (cloneData) { + set_csptModalFilters((cloneData.filters || []).map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + }))); + set_csptNameManuallyEdited(true); + } else { + set_csptModalFilters([]); + set_csptNameManuallyEdited(false); + } + document.getElementById('cspt-name').oninput = () => { set_csptNameManuallyEdited(true); }; + + _populateCSPTFilterSelect(); + renderCSPTModalFilterList(); + + if (cloneData) { + document.getElementById('cspt-name').value = (cloneData.name || '') + ' (Copy)'; + document.getElementById('cspt-description').value = cloneData.description || ''; + } + + if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; } + _csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') }); + _csptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []); + + csptModal.open(); + csptModal.snapshot(); +} + +export async function editCSPT(templateId) { + try { + if (_stripFilters.length === 0) await loadStripFilters(); + + const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`); + if (!response.ok) throw new Error(`Failed to load template: ${response.status}`); + const tmpl = await response.json(); + + document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.edit')}`; + document.getElementById('cspt-id').value = templateId; + document.getElementById('cspt-name').value = tmpl.name; + document.getElementById('cspt-description').value = tmpl.description || ''; + document.getElementById('cspt-error').style.display = 'none'; + + set_csptModalFilters((tmpl.filters || []).map(fi => ({ + filter_id: fi.filter_id, + options: { ...fi.options }, + }))); + + _populateCSPTFilterSelect(); + renderCSPTModalFilterList(); + + if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; } + _csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') }); + _csptTagsInput.setValue(tmpl.tags || []); + + csptModal.open(); + csptModal.snapshot(); + } catch (error) { + console.error('Error loading CSPT:', error); + showToast(t('css_processing.error.load') + ': ' + error.message, 'error'); + } +} + +export async function saveCSPT() { + const templateId = document.getElementById('cspt-id').value; + const name = document.getElementById('cspt-name').value.trim(); + const description = document.getElementById('cspt-description').value.trim(); + const errorEl = document.getElementById('cspt-error'); + + if (!name) { showToast(t('css_processing.error.required'), 'error'); return; } + + const payload = { name, filters: collectCSPTFilters(), description: description || null, tags: _csptTagsInput ? _csptTagsInput.getValue() : [] }; + + try { + let response; + if (templateId) { + response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) }); + } else { + response = await fetchWithAuth('/color-strip-processing-templates', { method: 'POST', body: JSON.stringify(payload) }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save template'); + } + + showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success'); + csptModal.forceClose(); + await loadCSPTemplates(); + } catch (error) { + console.error('Error saving CSPT:', error); + errorEl.textContent = error.message; + errorEl.style.display = 'block'; + } +} + +export async function cloneCSPT(templateId) { + try { + const resp = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`); + if (!resp.ok) throw new Error('Failed to load template'); + const tmpl = await resp.json(); + showAddCSPTModal(tmpl); + } catch (error) { + if (error.isAuth) return; + console.error('Failed to clone CSPT:', error); + showToast(t('css_processing.error.clone_failed') + ': ' + error.message, 'error'); + } +} + +export async function deleteCSPT(templateId) { + const confirmed = await showConfirm(t('css_processing.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'DELETE' }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to delete template'); + } + showToast(t('css_processing.deleted'), 'success'); + await loadCSPTemplates(); + } catch (error) { + console.error('Error deleting CSPT:', error); + showToast(t('css_processing.error.delete') + ': ' + error.message, 'error'); + } +} + +export async function closeCSPTModal() { + await csptModal.close(); +} + // Exported helpers used by other modules export { updateCaptureDuration, buildTestStatsHtml }; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 76da6d7..48c9766 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -198,6 +198,8 @@ "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", "device.gamesense.peripheral.indicator": "Indicator", "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", + "device.css_processing_template": "Strip Processing Template:", + "device.css_processing_template.hint": "Default processing template applied to all color strip outputs on this device", "device.dmx_protocol": "DMX Protocol:", "device.dmx_protocol.hint": "Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568", "device.dmx_protocol.artnet.desc": "UDP unicast, port 6454", @@ -417,6 +419,7 @@ "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", "streams.group.raw": "Screen Capture", "streams.group.processed": "Processed", + "streams.group.css_processing": "Processing Templates", "streams.group.color_strip": "Color Strips", "streams.group.audio": "Audio", "streams.section.streams": "Sources", @@ -485,12 +488,16 @@ "filters.color_correction.desc": "White balance and color temperature", "filters.filter_template": "Filter Template", "filters.filter_template.desc": "Embed another processing template", + "filters.css_filter_template": "Strip Filter Template", + "filters.css_filter_template.desc": "Embed another strip processing template", "filters.frame_interpolation": "Frame Interpolation", "filters.frame_interpolation.desc": "Blend between frames for smoother output", "filters.noise_gate": "Noise Gate", "filters.noise_gate.desc": "Suppress small color changes below threshold", "filters.palette_quantization": "Palette Quantization", "filters.palette_quantization.desc": "Reduce colors to a limited palette", + "filters.reverse": "Reverse", + "filters.reverse.desc": "Reverse the LED order in the strip", "postprocessing.description_label": "Description (optional):", "postprocessing.description_placeholder": "Describe this template...", "postprocessing.created": "Template created successfully", @@ -506,6 +513,21 @@ "postprocessing.test.running": "Testing processing template...", "postprocessing.test.error.no_stream": "Please select a source", "postprocessing.test.error.failed": "Processing template test failed", + "css_processing.title": "Strip Processing Templates", + "css_processing.add": "Add Strip Processing Template", + "css_processing.edit": "Edit Strip Processing Template", + "css_processing.name": "Template Name:", + "css_processing.name_placeholder": "My Strip Processing Template", + "css_processing.description_label": "Description (optional):", + "css_processing.description_placeholder": "Describe this template...", + "css_processing.created": "Strip processing template created", + "css_processing.updated": "Strip processing template updated", + "css_processing.deleted": "Strip processing template deleted", + "css_processing.delete.confirm": "Are you sure you want to delete this strip processing template?", + "css_processing.error.required": "Please fill in all required fields", + "css_processing.error.load": "Error loading strip processing template", + "css_processing.error.delete": "Error deleting strip processing template", + "css_processing.error.clone_failed": "Failed to clone strip processing template", "device.button.stream_selector": "Source Settings", "device.stream_settings.title": "Source Settings", "device.stream_selector.label": "Source:", @@ -1027,6 +1049,14 @@ "color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.", "color_strip.candlelight.speed": "Flicker Speed:", "color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.", + "color_strip.type.processed": "Processed", + "color_strip.type.processed.desc": "Apply a processing template to another source", + "color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.", + "color_strip.processed.input": "Input Source:", + "color_strip.processed.input.hint": "The color strip source whose output will be processed", + "color_strip.processed.template": "Processing Template:", + "color_strip.processed.template.hint": "Filter chain to apply to the input source output", + "color_strip.processed.error.no_input": "Please select an input source", "color_strip.composite.layers": "Layers:", "color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.", "color_strip.composite.add_layer": "+ Add Layer", @@ -1043,6 +1073,7 @@ "color_strip.composite.opacity": "Opacity", "color_strip.composite.brightness": "Brightness", "color_strip.composite.brightness.none": "— None —", + "color_strip.composite.processing": "Processing", "color_strip.composite.enabled": "Enabled", "color_strip.composite.error.min_layers": "At least 1 layer is required", "color_strip.composite.error.no_source": "Each layer must have a source selected", @@ -1182,6 +1213,7 @@ "streams.group.value": "Value Sources", "streams.group.sync": "Sync Clocks", "tree.group.picture": "Picture", + "tree.group.strip": "Color Strip", "tree.group.utility": "Utility", "value_source.group.title": "Value Sources", "value_source.select_type": "Select Value Source Type", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1c9ada9..a807f2e 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -169,6 +169,8 @@ "device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)", "device.send_latency": "Задержка отправки (мс):", "device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах", + "device.css_processing_template": "Шаблон Обработки Полос:", + "device.css_processing_template.hint": "Шаблон обработки по умолчанию, применяемый ко всем цветовым полосам на этом устройстве", "device.mqtt_topic": "MQTT Топик:", "device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)", "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", @@ -366,6 +368,7 @@ "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", "streams.group.raw": "Захват Экрана", "streams.group.processed": "Обработанные", + "streams.group.css_processing": "Шаблоны Обработки", "streams.group.color_strip": "Цветовые Полосы", "streams.group.audio": "Аудио", "streams.section.streams": "Источники", @@ -434,12 +437,16 @@ "filters.color_correction.desc": "Баланс белого и цветовая температура", "filters.filter_template": "Шаблон фильтров", "filters.filter_template.desc": "Встроить другой шаблон обработки", + "filters.css_filter_template": "Шаблон Фильтра Полос", + "filters.css_filter_template.desc": "Встроить другой шаблон обработки полос", "filters.frame_interpolation": "Интерполяция кадров", "filters.frame_interpolation.desc": "Сглаживание между кадрами", "filters.noise_gate": "Шумоподавление", "filters.noise_gate.desc": "Подавление малых изменений цвета ниже порога", "filters.palette_quantization": "Квантизация палитры", "filters.palette_quantization.desc": "Сокращение цветов до ограниченной палитры", + "filters.reverse": "Реверс", + "filters.reverse.desc": "Изменить порядок светодиодов на обратный", "postprocessing.description_label": "Описание (необязательно):", "postprocessing.description_placeholder": "Опишите этот шаблон...", "postprocessing.created": "Шаблон успешно создан", @@ -455,6 +462,21 @@ "postprocessing.test.running": "Тестирование шаблона фильтра...", "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник", "postprocessing.test.error.failed": "Тест шаблона фильтра не удался", + "css_processing.title": "Шаблоны Обработки Полос", + "css_processing.add": "Добавить Шаблон Обработки Полос", + "css_processing.edit": "Редактировать Шаблон Обработки Полос", + "css_processing.name": "Имя Шаблона:", + "css_processing.name_placeholder": "Мой Шаблон Обработки Полос", + "css_processing.description_label": "Описание (необязательно):", + "css_processing.description_placeholder": "Опишите этот шаблон...", + "css_processing.created": "Шаблон обработки полос создан", + "css_processing.updated": "Шаблон обработки полос обновлён", + "css_processing.deleted": "Шаблон обработки полос удалён", + "css_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки полос?", + "css_processing.error.required": "Заполните все обязательные поля", + "css_processing.error.load": "Ошибка загрузки шаблона обработки полос", + "css_processing.error.delete": "Ошибка удаления шаблона обработки полос", + "css_processing.error.clone_failed": "Не удалось клонировать шаблон обработки полос", "device.button.stream_selector": "Настройки источника", "device.stream_settings.title": "Настройки источника", "device.stream_selector.label": "Источник:", @@ -976,6 +998,14 @@ "color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.", "color_strip.candlelight.speed": "Скорость мерцания:", "color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.", + "color_strip.type.processed": "Обработанный", + "color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику", + "color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.", + "color_strip.processed.input": "Входной источник:", + "color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан", + "color_strip.processed.template": "Шаблон обработки:", + "color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника", + "color_strip.processed.error.no_input": "Выберите входной источник", "color_strip.composite.layers": "Слои:", "color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", "color_strip.composite.add_layer": "+ Добавить слой", @@ -992,6 +1022,7 @@ "color_strip.composite.opacity": "Непрозрачность", "color_strip.composite.brightness": "Яркость", "color_strip.composite.brightness.none": "— Нет —", + "color_strip.composite.processing": "Обработка", "color_strip.composite.enabled": "Включён", "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", @@ -1131,6 +1162,7 @@ "streams.group.value": "Источники значений", "streams.group.sync": "Часы синхронизации", "tree.group.picture": "Изображения", + "tree.group.strip": "Цветовые Полосы", "tree.group.utility": "Утилиты", "value_source.group.title": "Источники значений", "value_source.select_type": "Выберите тип источника значений", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 2ce76c4..f868702 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -169,6 +169,8 @@ "device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)", "device.send_latency": "发送延迟(毫秒):", "device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)", + "device.css_processing_template": "色带处理模板:", + "device.css_processing_template.hint": "应用于此设备所有色带输出的默认处理模板", "device.mqtt_topic": "MQTT 主题:", "device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)", "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", @@ -366,6 +368,7 @@ "streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。", "streams.group.raw": "屏幕采集", "streams.group.processed": "已处理", + "streams.group.css_processing": "处理模板", "streams.group.color_strip": "色带源", "streams.group.audio": "音频", "streams.section.streams": "源", @@ -434,12 +437,16 @@ "filters.color_correction.desc": "白平衡和色温调整", "filters.filter_template": "滤镜模板", "filters.filter_template.desc": "嵌入另一个处理模板", + "filters.css_filter_template": "色带滤镜模板", + "filters.css_filter_template.desc": "嵌入另一个色带处理模板", "filters.frame_interpolation": "帧插值", "filters.frame_interpolation.desc": "帧间混合以获得更平滑的输出", "filters.noise_gate": "噪声门", "filters.noise_gate.desc": "抑制低于阈值的细微色彩变化", "filters.palette_quantization": "调色板量化", "filters.palette_quantization.desc": "将颜色减少到有限调色板", + "filters.reverse": "反转", + "filters.reverse.desc": "反转色带中的LED顺序", "postprocessing.description_label": "描述(可选):", "postprocessing.description_placeholder": "描述此模板...", "postprocessing.created": "模板创建成功", @@ -455,6 +462,21 @@ "postprocessing.test.running": "正在测试处理模板...", "postprocessing.test.error.no_stream": "请选择一个源", "postprocessing.test.error.failed": "处理模板测试失败", + "css_processing.title": "色带处理模板", + "css_processing.add": "添加色带处理模板", + "css_processing.edit": "编辑色带处理模板", + "css_processing.name": "模板名称:", + "css_processing.name_placeholder": "我的色带处理模板", + "css_processing.description_label": "描述(可选):", + "css_processing.description_placeholder": "描述此模板...", + "css_processing.created": "色带处理模板已创建", + "css_processing.updated": "色带处理模板已更新", + "css_processing.deleted": "色带处理模板已删除", + "css_processing.delete.confirm": "确定要删除此色带处理模板吗?", + "css_processing.error.required": "请填写所有必填字段", + "css_processing.error.load": "加载色带处理模板出错", + "css_processing.error.delete": "删除色带处理模板出错", + "css_processing.error.clone_failed": "克隆色带处理模板失败", "device.button.stream_selector": "源设置", "device.stream_settings.title": "源设置", "device.stream_selector.label": "源:", @@ -976,6 +998,14 @@ "color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。", "color_strip.candlelight.speed": "闪烁速度:", "color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。", + "color_strip.type.processed": "已处理", + "color_strip.type.processed.desc": "将处理模板应用于另一个源", + "color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。", + "color_strip.processed.input": "输入源:", + "color_strip.processed.input.hint": "将被处理的色带源", + "color_strip.processed.template": "处理模板:", + "color_strip.processed.template.hint": "应用于输入源输出的滤镜链", + "color_strip.processed.error.no_input": "请选择输入源", "color_strip.composite.layers": "图层:", "color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", "color_strip.composite.add_layer": "+ 添加图层", @@ -992,6 +1022,7 @@ "color_strip.composite.opacity": "不透明度", "color_strip.composite.brightness": "亮度", "color_strip.composite.brightness.none": "— 无 —", + "color_strip.composite.processing": "处理", "color_strip.composite.enabled": "启用", "color_strip.composite.error.min_layers": "至少需要 1 个图层", "color_strip.composite.error.no_source": "每个图层必须选择一个源", @@ -1131,6 +1162,7 @@ "streams.group.value": "值源", "streams.group.sync": "同步时钟", "tree.group.picture": "图片", + "tree.group.strip": "色带", "tree.group.utility": "工具", "value_source.group.title": "值源", "value_source.select_type": "选择值源类型", diff --git a/server/src/wled_controller/storage/base_store.py b/server/src/wled_controller/storage/base_store.py index 905730e..2aa9743 100644 --- a/server/src/wled_controller/storage/base_store.py +++ b/server/src/wled_controller/storage/base_store.py @@ -1,6 +1,7 @@ """Base class for JSON entity stores — eliminates boilerplate across 12+ stores.""" import json +import threading from pathlib import Path from typing import Callable, Dict, Generic, List, TypeVar @@ -32,6 +33,7 @@ class BaseJsonStore(Generic[T]): self.file_path = Path(file_path) self._items: Dict[str, T] = {} self._deserializer = deserializer + self._lock = threading.Lock() self._load() # ── I/O ──────────────────────────────────────────────────────── @@ -69,6 +71,12 @@ class BaseJsonStore(Generic[T]): ) def _save(self) -> None: + """Persist all items to disk atomically. + + Note: This is synchronous blocking I/O. When called from async route + handlers, it briefly blocks the event loop (typically < 5ms for small + stores). Acceptable for user-initiated CRUD; not suitable for hot loops. + """ try: data = { "version": self._version, @@ -105,7 +113,11 @@ class BaseJsonStore(Generic[T]): # ── Helpers ──────────────────────────────────────────────────── def _check_name_unique(self, name: str, exclude_id: str = None) -> None: - """Raise ValueError if *name* is empty or already taken.""" + """Raise ValueError if *name* is empty or already taken. + + Callers should hold ``self._lock`` when calling this + mutating + ``_items`` to prevent race conditions between concurrent requests. + """ if not name or not name.strip(): raise ValueError("Name is required") for item_id, item in self._items.items(): diff --git a/server/src/wled_controller/storage/color_strip_processing_template.py b/server/src/wled_controller/storage/color_strip_processing_template.py new file mode 100644 index 0000000..841046e --- /dev/null +++ b/server/src/wled_controller/storage/color_strip_processing_template.py @@ -0,0 +1,53 @@ +"""Color strip processing template data model.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import List, Optional + +from wled_controller.core.filters.filter_instance import FilterInstance + + +@dataclass +class ColorStripProcessingTemplate: + """Processing template for color strip sources — ordered list of filters + applied to 1D LED arrays (N, 3) uint8. + """ + + id: str + name: str + filters: List[FilterInstance] + 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.""" + return { + "id": self.id, + "name": self.name, + "filters": [f.to_dict() for f in self.filters], + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), + "description": self.description, + "tags": self.tags, + } + + @classmethod + def from_dict(cls, data: dict) -> "ColorStripProcessingTemplate": + """Create template from dictionary.""" + filters = [FilterInstance.from_dict(f) for f in data.get("filters", [])] + + return cls( + id=data["id"], + name=data["name"], + filters=filters, + created_at=datetime.fromisoformat(data["created_at"]) + if isinstance(data.get("created_at"), str) + 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.now(timezone.utc)), + description=data.get("description"), + tags=data.get("tags", []), + ) diff --git a/server/src/wled_controller/storage/color_strip_processing_template_store.py b/server/src/wled_controller/storage/color_strip_processing_template_store.py new file mode 100644 index 0000000..ee12412 --- /dev/null +++ b/server/src/wled_controller/storage/color_strip_processing_template_store.py @@ -0,0 +1,170 @@ +"""Color strip processing template storage using JSON files.""" + +import uuid +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.color_strip_processing_template import ColorStripProcessingTemplate +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class ColorStripProcessingTemplateStore(BaseJsonStore[ColorStripProcessingTemplate]): + """Storage for color strip processing templates. + + All templates are persisted to the JSON file. + On startup, if no templates exist, a default one is auto-created. + """ + + _json_key = "color_strip_processing_templates" + _entity_name = "Color strip processing template" + _version = "1.0.0" + + def __init__(self, file_path: str): + super().__init__(file_path, ColorStripProcessingTemplate.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 color strip processing template if none exist.""" + if self._items: + return + + now = datetime.now(timezone.utc) + template_id = f"cspt_{uuid.uuid4().hex[:8]}" + + template = ColorStripProcessingTemplate( + id=template_id, + name="Default", + filters=[ + FilterInstance("brightness", {"value": 1.0}), + FilterInstance("gamma", {"value": 2.2}), + ], + created_at=now, + updated_at=now, + description="Default color strip processing template", + ) + + self._items[template_id] = template + self._save() + logger.info(f"Auto-created initial color strip processing template: {template.name} ({template_id})") + + def _validate_strip_filters(self, filters: List[FilterInstance]) -> None: + """Validate that all filters support strip processing.""" + for fi in filters: + if not FilterRegistry.is_registered(fi.filter_id): + raise ValueError(f"Unknown filter type: '{fi.filter_id}'") + filter_cls = FilterRegistry.get(fi.filter_id) + if not getattr(filter_cls, "supports_strip", True): + raise ValueError( + f"Filter '{fi.filter_id}' does not support strip processing" + ) + + def create_template( + self, + name: str, + filters: Optional[List[FilterInstance]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> ColorStripProcessingTemplate: + self._check_name_unique(name) + + if filters is None: + filters = [] + + self._validate_strip_filters(filters) + + template_id = f"cspt_{uuid.uuid4().hex[:8]}" + now = datetime.now(timezone.utc) + + template = ColorStripProcessingTemplate( + id=template_id, + name=name, + filters=filters, + created_at=now, + updated_at=now, + description=description, + tags=tags or [], + ) + + self._items[template_id] = template + self._save() + + logger.info(f"Created color strip processing template: {name} ({template_id})") + return template + + def update_template( + self, + template_id: str, + name: Optional[str] = None, + filters: Optional[List[FilterInstance]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> ColorStripProcessingTemplate: + template = self.get(template_id) + + if name is not None: + self._check_name_unique(name, exclude_id=template_id) + template.name = name + if filters is not None: + self._validate_strip_filters(filters) + template.filters = filters + if description is not None: + template.description = description + if tags is not None: + template.tags = tags + + template.updated_at = datetime.now(timezone.utc) + self._save() + + logger.info(f"Updated color strip processing template: {template_id}") + return template + + def resolve_filter_instances(self, filter_instances, _visited=None): + """Recursively resolve filter instances, expanding css_filter_template references. + + Returns a flat list of FilterInstance objects with no css_filter_template entries. + """ + if _visited is None: + _visited = set() + resolved = [] + for fi in filter_instances: + if fi.filter_id == "css_filter_template": + template_id = fi.options.get("template_id", "") + if not template_id or template_id in _visited: + continue + try: + ref_template = self.get_template(template_id) + _visited.add(template_id) + resolved.extend(self.resolve_filter_instances(ref_template.filters, _visited)) + _visited.discard(template_id) + except ValueError: + logger.warning(f"Referenced CSS filter template '{template_id}' not found, skipping") + else: + resolved.append(fi) + return resolved + + def get_references(self, template_id: str, device_store=None, css_store=None) -> List[str]: + """Return names of entities that reference this template.""" + refs = [] + if device_store: + for dev in device_store.get_all(): + if getattr(dev, "default_css_processing_template_id", "") == template_id: + refs.append(f"Device: {dev.name}") + if css_store: + for src in css_store.get_all(): + layers = getattr(src, "layers", None) + if layers: + for layer in layers: + if isinstance(layer, dict) and layer.get("processing_template_id") == template_id: + refs.append(f"Composite layer in: {src.name}") + break + return refs diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 11b62d1..a0214db 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -19,13 +19,19 @@ Current types: from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import List, Optional +from typing import Dict, List, Optional, Type from wled_controller.core.capture.calibration import ( CalibrationConfig, calibration_from_dict, calibration_to_dict, ) +from wled_controller.storage.utils import resolve_ref + + +def _validate_rgb(value, default: list) -> list: + """Return value if it's a 3-element list, otherwise return default.""" + return value if isinstance(value, list) and len(value) == 3 else list(default) @dataclass @@ -51,7 +57,7 @@ class ColorStripSource: return False def to_dict(self) -> dict: - """Convert source to dictionary. Subclasses extend this.""" + """Convert source to dictionary. Subclasses extend this with their own fields.""" return { "id": self.id, "name": self.name, @@ -61,48 +67,40 @@ class ColorStripSource: "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, - "brightness": None, - "saturation": None, - "gamma": None, - "smoothing": None, - "interpolation_mode": None, - "calibration": None, - "led_count": None, - "color": None, - "stops": None, - "animation": None, - "colors": None, - "effect_type": None, - "palette": None, - "intensity": None, - "scale": None, - "mirror": None, - "layers": None, - "zones": None, - "visualization_mode": None, - "audio_source_id": None, - "sensitivity": None, - "color_peak": None, - "fallback_color": None, - "timeout": None, - "notification_effect": None, - "duration_ms": None, - "default_color": None, - "app_colors": None, - "app_filter_mode": None, - "app_filter_list": None, - "os_listener": None, - # daylight-type fields - "speed": None, - "use_real_time": None, - "latitude": None, - # candlelight-type fields - "num_candles": None, } + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description: Optional[str] = None, + clock_id: Optional[str] = None, + tags: Optional[List[str]] = None, + **kwargs) -> "ColorStripSource": + """Create an instance from keyword arguments. + + Base implementation — subclasses override to extract type-specific fields. + This should not be called directly; use create_instance() instead. + """ + raise NotImplementedError(f"create_from_kwargs not implemented on {cls.__name__}") + + def apply_update(self, **kwargs) -> None: + """Apply type-specific field updates. Subclasses override this. + + Only fields present in kwargs (and not None) are updated. + Base class handles common fields (name, description, clock_id, tags) + in the store; this method handles subclass-specific fields only. + """ + pass + + @staticmethod + def create_instance(source_type: str, **kwargs) -> "ColorStripSource": + """Factory: create the correct subclass based on source_type.""" + cls = _SOURCE_TYPE_MAP.get(source_type) + if cls is None: + # Default to PictureColorStripSource for unknown types + cls = PictureColorStripSource + return cls.create_from_kwargs(source_type=source_type, **kwargs) + @staticmethod def from_dict(data: dict) -> "ColorStripSource": """Factory: dispatch to the correct subclass based on source_type.""" @@ -262,6 +260,15 @@ class ColorStripSource: latitude=float(data.get("latitude") or 50.0), ) + if source_type == "processed": + return ProcessedColorStripSource( + id=sid, name=name, source_type="processed", + created_at=created_at, updated_at=updated_at, description=description, + clock_id=clock_id, tags=tags, + input_source_id=data.get("input_source_id") or "", + processing_template_id=data.get("processing_template_id") or "", + ) + if source_type == "candlelight": raw_color = data.get("color") color = ( @@ -282,14 +289,10 @@ class ColorStripSource: _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, - gamma=data["gamma"] if data.get("gamma") is not None else 1.0, smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3, interpolation_mode=data.get("interpolation_mode") or "average", calibration=calibration, led_count=data.get("led_count") or 0, - frame_interpolation=bool(data.get("frame_interpolation", False)), ) if source_type == "picture_advanced": @@ -311,17 +314,27 @@ class ColorStripSource: def _picture_base_to_dict(source, d: dict) -> dict: """Populate dict with fields common to both picture source types.""" d["fps"] = source.fps - d["brightness"] = source.brightness - d["saturation"] = source.saturation - d["gamma"] = source.gamma d["smoothing"] = source.smoothing d["interpolation_mode"] = source.interpolation_mode d["calibration"] = calibration_to_dict(source.calibration) d["led_count"] = source.led_count - d["frame_interpolation"] = source.frame_interpolation return d +def _apply_picture_update(source, **kwargs) -> None: + """Apply update fields common to both picture source types.""" + if kwargs.get("fps") is not None: + source.fps = kwargs["fps"] + if kwargs.get("smoothing") is not None: + source.smoothing = kwargs["smoothing"] + if kwargs.get("interpolation_mode") is not None: + source.interpolation_mode = kwargs["interpolation_mode"] + if kwargs.get("calibration") is not None: + source.calibration = kwargs["calibration"] + if kwargs.get("led_count") is not None: + source.led_count = kwargs["led_count"] + + @dataclass class PictureColorStripSource(ColorStripSource): """Color strip source driven by a single PictureSource (simple 4-edge calibration). @@ -337,29 +350,50 @@ class PictureColorStripSource(ColorStripSource): picture_source_id: str = "" fps: int = 30 - brightness: float = 1.0 # color correction multiplier (0.0–2.0; 1.0 = unchanged) - saturation: float = 1.0 # 1.0 = unchanged, 0.0 = grayscale, 2.0 = double saturation - gamma: float = 1.0 # 1.0 = no correction; <1 = brighter, >1 = darker mids smoothing: float = 0.3 # temporal smoothing (0.0 = none, 1.0 = full) interpolation_mode: str = "average" # "average" | "median" | "dominant" calibration: CalibrationConfig = field( default_factory=lambda: CalibrationConfig(layout="clockwise", start_position="bottom_left") ) led_count: int = 0 # explicit LED count; 0 = auto (derived from calibration) - frame_interpolation: bool = False # blend between consecutive captured frames def to_dict(self) -> dict: d = super().to_dict() d["picture_source_id"] = self.picture_source_id return _picture_base_to_dict(self, d) + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + picture_source_id="", fps=30, + smoothing=0.3, + interpolation_mode="average", calibration=None, + led_count=0, **_kwargs): + if calibration is None: + calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") + return cls( + id=id, name=name, source_type=source_type, + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + picture_source_id=picture_source_id, fps=fps, + smoothing=smoothing, interpolation_mode=interpolation_mode, + calibration=calibration, led_count=led_count, + ) + + def apply_update(self, **kwargs) -> None: + picture_source_id = kwargs.get("picture_source_id") + if picture_source_id is not None: + self.picture_source_id = resolve_ref(picture_source_id, self.picture_source_id) + _apply_picture_update(self, **kwargs) + @dataclass class AdvancedPictureColorStripSource(ColorStripSource): """Color strip source with line-based calibration across multiple picture sources. Each calibration line references its own picture source and edge, enabling - LED strips that span multiple monitors. No single picture_source_id — the + LED strips that span multiple monitors. No single picture_source_id -- the picture sources are defined per-line in the calibration config. """ @@ -369,27 +403,44 @@ class AdvancedPictureColorStripSource(ColorStripSource): return True fps: int = 30 - brightness: float = 1.0 - saturation: float = 1.0 - gamma: float = 1.0 smoothing: float = 0.3 interpolation_mode: str = "average" calibration: CalibrationConfig = field( default_factory=lambda: CalibrationConfig(mode="advanced") ) led_count: int = 0 - frame_interpolation: bool = False def to_dict(self) -> dict: d = super().to_dict() return _picture_base_to_dict(self, d) + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + fps=30, + smoothing=0.3, interpolation_mode="average", + calibration=None, led_count=0, **_kwargs): + if calibration is None: + calibration = CalibrationConfig(mode="advanced") + return cls( + id=id, name=name, source_type="picture_advanced", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + fps=fps, + smoothing=smoothing, interpolation_mode=interpolation_mode, + calibration=calibration, led_count=led_count, + ) + + def apply_update(self, **kwargs) -> None: + _apply_picture_update(self, **kwargs) + @dataclass class StaticColorStripSource(ColorStripSource): """Color strip source that fills all LEDs with a single static color. - No capture or processing — the entire LED strip is set to one constant + No capture or processing -- the entire LED strip is set to one constant RGB color. Useful for solid-color accents or as a placeholder while a PictureColorStripSource is being configured. """ @@ -403,12 +454,33 @@ class StaticColorStripSource(ColorStripSource): d["animation"] = self.animation return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + color=None, animation=None, **_kwargs): + rgb = _validate_rgb(color, [255, 255, 255]) + return cls( + id=id, name=name, source_type="static", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + color=rgb, animation=animation, + ) + + def apply_update(self, **kwargs) -> None: + color = kwargs.get("color") + if color is not None: + if isinstance(color, list) and len(color) == 3: + self.color = color + if kwargs.get("animation") is not None: + self.animation = kwargs["animation"] + @dataclass class GradientColorStripSource(ColorStripSource): """Color strip source that produces a linear gradient across all LEDs. - The gradient is defined by color stops at relative positions (0.0–1.0). + The gradient is defined by color stops at relative positions (0.0-1.0). Each stop has a primary color; optionally a second "right" color to create a hard discontinuity (bidirectional stop) at that position. @@ -428,6 +500,29 @@ class GradientColorStripSource(ColorStripSource): d["animation"] = self.animation return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + stops=None, animation=None, **_kwargs): + return cls( + id=id, name=name, source_type="gradient", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + stops=stops if isinstance(stops, list) else [ + {"position": 0.0, "color": [255, 0, 0]}, + {"position": 1.0, "color": [0, 0, 255]}, + ], + animation=animation, + ) + + def apply_update(self, **kwargs) -> None: + stops = kwargs.get("stops") + if stops is not None and isinstance(stops, list): + self.stops = stops + if kwargs.get("animation") is not None: + self.animation = kwargs["animation"] + @dataclass class ColorCycleColorStripSource(ColorStripSource): @@ -448,6 +543,27 @@ class ColorCycleColorStripSource(ColorStripSource): d["colors"] = [list(c) for c in self.colors] return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + colors=None, **_kwargs): + default_colors = [ + [255, 0, 0], [255, 255, 0], [0, 255, 0], + [0, 255, 255], [0, 0, 255], [255, 0, 255], + ] + return cls( + id=id, name=name, source_type="color_cycle", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, + ) + + def apply_update(self, **kwargs) -> None: + colors = kwargs.get("colors") + if colors is not None and isinstance(colors, list) and len(colors) >= 2: + self.colors = colors + @dataclass class EffectColorStripSource(ColorStripSource): @@ -461,8 +577,8 @@ class EffectColorStripSource(ColorStripSource): effect_type: str = "fire" # fire | meteor | plasma | noise | aurora palette: str = "fire" # named color palette color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head - intensity: float = 1.0 # effect-specific intensity (0.1–2.0) - scale: float = 1.0 # spatial scale / zoom (0.5–5.0) + intensity: float = 1.0 # effect-specific intensity (0.1-2.0) + scale: float = 1.0 # spatial scale / zoom (0.5-5.0) mirror: bool = False # bounce mode (meteor) def to_dict(self) -> dict: @@ -475,6 +591,39 @@ class EffectColorStripSource(ColorStripSource): d["mirror"] = self.mirror return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + effect_type="fire", palette="fire", color=None, + intensity=1.0, scale=1.0, mirror=False, **_kwargs): + rgb = _validate_rgb(color, [255, 80, 0]) + return cls( + id=id, name=name, source_type="effect", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + effect_type=effect_type or "fire", palette=palette or "fire", + color=rgb, + intensity=float(intensity) if intensity else 1.0, + scale=float(scale) if scale else 1.0, + mirror=bool(mirror), + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("effect_type") is not None: + self.effect_type = kwargs["effect_type"] + if kwargs.get("palette") is not None: + self.palette = kwargs["palette"] + color = kwargs.get("color") + if color is not None and isinstance(color, list) and len(color) == 3: + self.color = color + if kwargs.get("intensity") is not None: + self.intensity = float(kwargs["intensity"]) + if kwargs.get("scale") is not None: + self.scale = float(kwargs["scale"]) + if kwargs.get("mirror") is not None: + self.mirror = bool(kwargs["mirror"]) + @dataclass class AudioColorStripSource(ColorStripSource): @@ -487,8 +636,8 @@ class AudioColorStripSource(ColorStripSource): visualization_mode: str = "spectrum" # spectrum | beat_pulse | vu_meter audio_source_id: str = "" # references a MonoAudioSource - sensitivity: float = 1.0 # gain multiplier (0.1–5.0) - smoothing: float = 0.3 # temporal smoothing (0.0–1.0) + sensitivity: float = 1.0 # gain multiplier (0.1-5.0) + smoothing: float = 0.3 # temporal smoothing (0.0-1.0) palette: str = "rainbow" # named color palette color: list = field(default_factory=lambda: [0, 255, 0]) # base RGB for VU meter color_peak: list = field(default_factory=lambda: [255, 0, 0]) # peak RGB for VU meter @@ -508,6 +657,52 @@ class AudioColorStripSource(ColorStripSource): d["mirror"] = self.mirror return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + visualization_mode="spectrum", audio_source_id="", + sensitivity=1.0, smoothing=0.3, palette="rainbow", + color=None, color_peak=None, led_count=0, + mirror=False, **_kwargs): + rgb = _validate_rgb(color, [0, 255, 0]) + peak = _validate_rgb(color_peak, [255, 0, 0]) + return cls( + id=id, name=name, source_type="audio", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + visualization_mode=visualization_mode or "spectrum", + audio_source_id=audio_source_id or "", + sensitivity=float(sensitivity) if sensitivity else 1.0, + smoothing=float(smoothing) if smoothing else 0.3, + palette=palette or "rainbow", + color=rgb, color_peak=peak, led_count=led_count, + mirror=bool(mirror), + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("visualization_mode") is not None: + self.visualization_mode = kwargs["visualization_mode"] + audio_source_id = kwargs.get("audio_source_id") + if audio_source_id is not None: + self.audio_source_id = resolve_ref(audio_source_id, self.audio_source_id) + if kwargs.get("sensitivity") is not None: + self.sensitivity = float(kwargs["sensitivity"]) + if kwargs.get("smoothing") is not None: + self.smoothing = float(kwargs["smoothing"]) + if kwargs.get("palette") is not None: + self.palette = kwargs["palette"] + color = kwargs.get("color") + if color is not None and isinstance(color, list) and len(color) == 3: + self.color = color + color_peak = kwargs.get("color_peak") + if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3: + self.color_peak = color_peak + if kwargs.get("led_count") is not None: + self.led_count = kwargs["led_count"] + if kwargs.get("mirror") is not None: + self.mirror = bool(kwargs["mirror"]) + @dataclass class CompositeColorStripSource(ColorStripSource): @@ -528,6 +723,26 @@ class CompositeColorStripSource(ColorStripSource): d["led_count"] = self.led_count return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + layers=None, led_count=0, **_kwargs): + return cls( + id=id, name=name, source_type="composite", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + layers=layers if isinstance(layers, list) else [], + led_count=led_count, + ) + + def apply_update(self, **kwargs) -> None: + layers = kwargs.get("layers") + if layers is not None and isinstance(layers, list): + self.layers = layers + if kwargs.get("led_count") is not None: + self.led_count = kwargs["led_count"] + @dataclass class MappedColorStripSource(ColorStripSource): @@ -549,6 +764,26 @@ class MappedColorStripSource(ColorStripSource): d["led_count"] = self.led_count return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + zones=None, led_count=0, **_kwargs): + return cls( + id=id, name=name, source_type="mapped", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + zones=zones if isinstance(zones, list) else [], + led_count=led_count, + ) + + def apply_update(self, **kwargs) -> None: + zones = kwargs.get("zones") + if zones is not None and isinstance(zones, list): + self.zones = zones + if kwargs.get("led_count") is not None: + self.led_count = kwargs["led_count"] + @dataclass class ApiInputColorStripSource(ColorStripSource): @@ -571,6 +806,30 @@ class ApiInputColorStripSource(ColorStripSource): d["timeout"] = self.timeout return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + led_count=0, fallback_color=None, timeout=None, + **_kwargs): + fb = _validate_rgb(fallback_color, [0, 0, 0]) + return cls( + id=id, name=name, source_type="api_input", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + led_count=led_count, fallback_color=fb, + timeout=float(timeout) if timeout is not None else 5.0, + ) + + def apply_update(self, **kwargs) -> None: + fallback_color = kwargs.get("fallback_color") + if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3: + self.fallback_color = fallback_color + if kwargs.get("timeout") is not None: + self.timeout = float(kwargs["timeout"]) + if kwargs.get("led_count") is not None: + self.led_count = kwargs["led_count"] + @dataclass class NotificationColorStripSource(ColorStripSource): @@ -586,7 +845,7 @@ class NotificationColorStripSource(ColorStripSource): notification_effect: str = "flash" # flash | pulse | sweep duration_ms: int = 1500 # effect duration in milliseconds default_color: str = "#FFFFFF" # hex color for notifications without app match - app_colors: dict = field(default_factory=dict) # app name → hex color + app_colors: dict = field(default_factory=dict) # app name -> hex color app_filter_mode: str = "off" # off | whitelist | blacklist app_filter_list: list = field(default_factory=list) # app names for filter os_listener: bool = False # whether to listen for OS notifications @@ -602,6 +861,45 @@ class NotificationColorStripSource(ColorStripSource): d["os_listener"] = self.os_listener return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + notification_effect=None, duration_ms=None, + default_color=None, app_colors=None, + app_filter_mode=None, app_filter_list=None, + os_listener=None, **_kwargs): + return cls( + id=id, name=name, source_type="notification", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + notification_effect=notification_effect or "flash", + duration_ms=int(duration_ms) if duration_ms is not None else 1500, + default_color=default_color or "#FFFFFF", + app_colors=app_colors if isinstance(app_colors, dict) else {}, + app_filter_mode=app_filter_mode or "off", + app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], + os_listener=bool(os_listener) if os_listener is not None else False, + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("notification_effect") is not None: + self.notification_effect = kwargs["notification_effect"] + if kwargs.get("duration_ms") is not None: + self.duration_ms = int(kwargs["duration_ms"]) + if kwargs.get("default_color") is not None: + self.default_color = kwargs["default_color"] + app_colors = kwargs.get("app_colors") + if app_colors is not None and isinstance(app_colors, dict): + self.app_colors = app_colors + if kwargs.get("app_filter_mode") is not None: + self.app_filter_mode = kwargs["app_filter_mode"] + app_filter_list = kwargs.get("app_filter_list") + if app_filter_list is not None and isinstance(app_filter_list, list): + self.app_filter_list = app_filter_list + if kwargs.get("os_listener") is not None: + self.os_listener = bool(kwargs["os_listener"]) + @dataclass class DaylightColorStripSource(ColorStripSource): @@ -614,7 +912,7 @@ class DaylightColorStripSource(ColorStripSource): When use_real_time is True, the current wall-clock hour determines the color; speed is ignored. When False, speed controls how fast - a full 24-hour cycle plays (1.0 ≈ 4 minutes per full cycle). + a full 24-hour cycle plays (1.0 = 4 minutes per full cycle). """ speed: float = 1.0 # cycle speed (ignored when use_real_time) @@ -628,6 +926,29 @@ class DaylightColorStripSource(ColorStripSource): d["latitude"] = self.latitude return d + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + speed=None, use_real_time=None, latitude=None, + **_kwargs): + return cls( + id=id, name=name, source_type="daylight", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + speed=float(speed) if speed is not None else 1.0, + use_real_time=bool(use_real_time) if use_real_time is not None else False, + latitude=float(latitude) if latitude is not None else 50.0, + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("speed") is not None: + self.speed = float(kwargs["speed"]) + if kwargs.get("use_real_time") is not None: + self.use_real_time = bool(kwargs["use_real_time"]) + if kwargs.get("latitude") is not None: + self.latitude = float(kwargs["latitude"]) + @dataclass class CandlelightColorStripSource(ColorStripSource): @@ -639,7 +960,7 @@ class CandlelightColorStripSource(ColorStripSource): """ color: list = field(default_factory=lambda: [255, 147, 41]) # warm candle base [R,G,B] - intensity: float = 1.0 # flicker intensity (0.1–2.0) + intensity: float = 1.0 # flicker intensity (0.1-2.0) num_candles: int = 3 # number of independent candle sources speed: float = 1.0 # flicker speed multiplier @@ -650,3 +971,93 @@ class CandlelightColorStripSource(ColorStripSource): d["num_candles"] = self.num_candles d["speed"] = self.speed return d + + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + color=None, intensity=1.0, num_candles=None, + speed=None, **_kwargs): + rgb = _validate_rgb(color, [255, 147, 41]) + return cls( + id=id, name=name, source_type="candlelight", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + color=rgb, + intensity=float(intensity) if intensity else 1.0, + num_candles=int(num_candles) if num_candles is not None else 3, + speed=float(speed) if speed is not None else 1.0, + ) + + def apply_update(self, **kwargs) -> None: + color = kwargs.get("color") + if color is not None and isinstance(color, list) and len(color) == 3: + self.color = color + if kwargs.get("intensity") is not None: + self.intensity = float(kwargs["intensity"]) + if kwargs.get("num_candles") is not None: + self.num_candles = int(kwargs["num_candles"]) + if kwargs.get("speed") is not None: + self.speed = float(kwargs["speed"]) + + +@dataclass +class ProcessedColorStripSource(ColorStripSource): + """Color strip source that takes another CSS and applies a processing template. + + Wraps an existing color strip source and pipes its output through a + ColorStripProcessingTemplate filter chain. Useful for applying brightness, + gamma, palette quantization, etc. to any source without modifying the + original. + """ + + input_source_id: str = "" # ID of the input color strip source + processing_template_id: str = "" # ID of the CSPT to apply + + def to_dict(self) -> dict: + d = super().to_dict() + d["input_source_id"] = self.input_source_id + d["processing_template_id"] = self.processing_template_id + return d + + @classmethod + def create_from_kwargs(cls, *, id: str, name: str, source_type: str, + created_at: datetime, updated_at: datetime, + description=None, clock_id=None, tags=None, + input_source_id="", processing_template_id="", + **_kwargs): + return cls( + id=id, name=name, source_type="processed", + created_at=created_at, updated_at=updated_at, + description=description, clock_id=clock_id, tags=tags or [], + input_source_id=input_source_id, + processing_template_id=processing_template_id, + ) + + def apply_update(self, **kwargs) -> None: + input_source_id = kwargs.get("input_source_id") + if input_source_id is not None: + self.input_source_id = resolve_ref(input_source_id, self.input_source_id) + processing_template_id = kwargs.get("processing_template_id") + if processing_template_id is not None: + self.processing_template_id = resolve_ref(processing_template_id, self.processing_template_id) + + +# -- Source type registry -- +# Maps source_type string to its subclass for factory dispatch. +_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { + "picture": PictureColorStripSource, + "picture_advanced": AdvancedPictureColorStripSource, + "static": StaticColorStripSource, + "gradient": GradientColorStripSource, + "color_cycle": ColorCycleColorStripSource, + "effect": EffectColorStripSource, + "audio": AudioColorStripSource, + "composite": CompositeColorStripSource, + "mapped": MappedColorStripSource, + "api_input": ApiInputColorStripSource, + "notification": NotificationColorStripSource, + "daylight": DaylightColorStripSource, + "candlelight": CandlelightColorStripSource, + "processed": ProcessedColorStripSource, +} diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 7d07588..f3a08af 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -4,7 +4,6 @@ import uuid 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.utils import resolve_ref from wled_controller.storage.color_strip_source import ( @@ -21,6 +20,7 @@ from wled_controller.storage.color_strip_source import ( MappedColorStripSource, NotificationColorStripSource, PictureColorStripSource, + ProcessedColorStripSource, StaticColorStripSource, ) from wled_controller.utils import get_logger @@ -45,56 +45,13 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): """Get a color strip source by ID (alias for get()).""" return self.get(source_id) - def create_source( - self, - name: str, - source_type: str = "picture", - picture_source_id: str = "", - fps: int = 30, - brightness: float = 1.0, - saturation: float = 1.0, - gamma: float = 1.0, - smoothing: float = 0.3, - interpolation_mode: str = "average", - calibration=None, - led_count: int = 0, - color: Optional[list] = None, - stops: Optional[list] = None, - description: Optional[str] = None, - frame_interpolation: bool = False, - animation: Optional[dict] = None, - colors: Optional[list] = None, - effect_type: str = "fire", - palette: str = "fire", - intensity: float = 1.0, - scale: float = 1.0, - mirror: bool = False, - layers: Optional[list] = None, - zones: Optional[list] = None, - visualization_mode: str = "spectrum", - audio_source_id: str = "", - sensitivity: float = 1.0, - color_peak: Optional[list] = None, - fallback_color: Optional[list] = None, - timeout: Optional[float] = None, - clock_id: Optional[str] = None, - notification_effect: Optional[str] = None, - duration_ms: Optional[int] = None, - default_color: Optional[str] = None, - app_colors: Optional[dict] = None, - app_filter_mode: Optional[str] = None, - app_filter_list: Optional[list] = None, - os_listener: Optional[bool] = None, - # daylight-type fields - speed: Optional[float] = None, - use_real_time: Optional[bool] = None, - latitude: Optional[float] = None, - # candlelight-type fields - num_candles: Optional[int] = None, - tags: Optional[List[str]] = None, - ) -> ColorStripSource: + def create_source(self, name: str, source_type: str = "picture", + **kwargs) -> ColorStripSource: """Create a new color strip source. + All type-specific parameters are passed as keyword arguments and + forwarded to the subclass ``create_from_kwargs`` factory method. + Raises: ValueError: If validation fails """ @@ -108,271 +65,30 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): source_id = f"css_{uuid.uuid4().hex[:8]}" now = datetime.now(timezone.utc) - if source_type == "static": - rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255] - source = StaticColorStripSource( - id=source_id, - name=name, - source_type="static", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - color=rgb, - animation=animation, - ) - elif source_type == "gradient": - source = GradientColorStripSource( - id=source_id, - name=name, - source_type="gradient", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - stops=stops if isinstance(stops, list) else [ - {"position": 0.0, "color": [255, 0, 0]}, - {"position": 1.0, "color": [0, 0, 255]}, - ], - animation=animation, - ) - elif source_type == "color_cycle": - default_colors = [ - [255, 0, 0], [255, 255, 0], [0, 255, 0], - [0, 255, 255], [0, 0, 255], [255, 0, 255], - ] - source = ColorCycleColorStripSource( - id=source_id, - name=name, - source_type="color_cycle", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, - ) - elif source_type == "effect": - rgb = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0] - source = EffectColorStripSource( - id=source_id, - name=name, - source_type="effect", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - effect_type=effect_type or "fire", - palette=palette or "fire", - color=rgb, - intensity=float(intensity) if intensity else 1.0, - scale=float(scale) if scale else 1.0, - mirror=bool(mirror), - ) - elif source_type == "audio": - rgb = color if isinstance(color, list) and len(color) == 3 else [0, 255, 0] - peak = color_peak if isinstance(color_peak, list) and len(color_peak) == 3 else [255, 0, 0] - source = AudioColorStripSource( - id=source_id, - name=name, - source_type="audio", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - visualization_mode=visualization_mode or "spectrum", - audio_source_id=audio_source_id or "", - sensitivity=float(sensitivity) if sensitivity else 1.0, - smoothing=float(smoothing) if smoothing else 0.3, - palette=palette or "rainbow", - color=rgb, - color_peak=peak, - led_count=led_count, - mirror=bool(mirror), - ) - elif source_type == "composite": - source = CompositeColorStripSource( - id=source_id, - name=name, - source_type="composite", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - layers=layers if isinstance(layers, list) else [], - led_count=led_count, - ) - elif source_type == "mapped": - source = MappedColorStripSource( - id=source_id, - name=name, - source_type="mapped", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - zones=zones if isinstance(zones, list) else [], - led_count=led_count, - ) - elif source_type == "api_input": - fb = fallback_color if isinstance(fallback_color, list) and len(fallback_color) == 3 else [0, 0, 0] - source = ApiInputColorStripSource( - id=source_id, - name=name, - source_type="api_input", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - led_count=led_count, - fallback_color=fb, - timeout=float(timeout) if timeout is not None else 5.0, - ) - elif source_type == "notification": - source = NotificationColorStripSource( - id=source_id, - name=name, - source_type="notification", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - notification_effect=notification_effect or "flash", - duration_ms=int(duration_ms) if duration_ms is not None else 1500, - default_color=default_color or "#FFFFFF", - app_colors=app_colors if isinstance(app_colors, dict) else {}, - app_filter_mode=app_filter_mode or "off", - app_filter_list=app_filter_list if isinstance(app_filter_list, list) else [], - os_listener=bool(os_listener) if os_listener is not None else False, - ) - elif source_type == "daylight": - source = DaylightColorStripSource( - id=source_id, - name=name, - source_type="daylight", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - speed=float(speed) if speed is not None else 1.0, - use_real_time=bool(use_real_time) if use_real_time is not None else False, - latitude=float(latitude) if latitude is not None else 50.0, - ) - elif source_type == "candlelight": - rgb = color if isinstance(color, list) and len(color) == 3 else [255, 147, 41] - source = CandlelightColorStripSource( - id=source_id, - name=name, - source_type="candlelight", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - color=rgb, - intensity=float(intensity) if intensity else 1.0, - num_candles=int(num_candles) if num_candles is not None else 3, - speed=float(speed) if speed is not None else 1.0, - ) - elif source_type == "picture_advanced": - if calibration is None: - calibration = CalibrationConfig(mode="advanced") - source = AdvancedPictureColorStripSource( - id=source_id, - name=name, - source_type="picture_advanced", - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - fps=fps, - brightness=brightness, - saturation=saturation, - gamma=gamma, - smoothing=smoothing, - interpolation_mode=interpolation_mode, - calibration=calibration, - led_count=led_count, - frame_interpolation=frame_interpolation, - ) - else: - if calibration is None: - calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") - source = PictureColorStripSource( - id=source_id, - name=name, - source_type=source_type, - created_at=now, - updated_at=now, - description=description, - clock_id=clock_id, - picture_source_id=picture_source_id, - fps=fps, - brightness=brightness, - saturation=saturation, - gamma=gamma, - smoothing=smoothing, - interpolation_mode=interpolation_mode, - calibration=calibration, - led_count=led_count, - frame_interpolation=frame_interpolation, - ) + tags = kwargs.pop("tags", None) or [] + + source = ColorStripSource.create_instance( + source_type, + id=source_id, + name=name, + created_at=now, + updated_at=now, + tags=tags, + **kwargs, + ) - source.tags = tags or [] self._items[source_id] = source self._save() logger.info(f"Created color strip source: {name} ({source_id}, type={source_type})") return source - def update_source( - self, - source_id: str, - name: Optional[str] = None, - picture_source_id: Optional[str] = None, - fps: Optional[int] = None, - brightness: Optional[float] = None, - saturation: Optional[float] = None, - gamma: Optional[float] = None, - smoothing: Optional[float] = None, - interpolation_mode: Optional[str] = None, - calibration=None, - led_count: Optional[int] = None, - color: Optional[list] = None, - stops: Optional[list] = None, - description: Optional[str] = None, - frame_interpolation: Optional[bool] = None, - animation: Optional[dict] = None, - colors: Optional[list] = None, - effect_type: Optional[str] = None, - palette: Optional[str] = None, - intensity: Optional[float] = None, - scale: Optional[float] = None, - mirror: Optional[bool] = None, - layers: Optional[list] = None, - zones: Optional[list] = None, - visualization_mode: Optional[str] = None, - audio_source_id: Optional[str] = None, - sensitivity: Optional[float] = None, - color_peak: Optional[list] = None, - fallback_color: Optional[list] = None, - timeout: Optional[float] = None, - clock_id: Optional[str] = None, - notification_effect: Optional[str] = None, - duration_ms: Optional[int] = None, - default_color: Optional[str] = None, - app_colors: Optional[dict] = None, - app_filter_mode: Optional[str] = None, - app_filter_list: Optional[list] = None, - os_listener: Optional[bool] = None, - # daylight-type fields - speed: Optional[float] = None, - use_real_time: Optional[bool] = None, - latitude: Optional[float] = None, - # candlelight-type fields - num_candles: Optional[int] = None, - tags: Optional[List[str]] = None, - ) -> ColorStripSource: + def update_source(self, source_id: str, **kwargs) -> ColorStripSource: """Update an existing color strip source. + All type-specific parameters are passed as keyword arguments and + forwarded to the subclass ``apply_update`` method. + Raises: ValueError: If source not found """ @@ -381,136 +97,28 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): source = self._items[source_id] + # -- Common fields handled here (before dispatching to subclass) -- + name = kwargs.pop("name", None) if name is not None: 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 + description = kwargs.pop("description", None) if description is not None: source.description = description + clock_id = kwargs.pop("clock_id", None) if clock_id is not None: source.clock_id = resolve_ref(clock_id, source.clock_id) + tags = kwargs.pop("tags", 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 = resolve_ref(picture_source_id, source.picture_source_id) - if fps is not None: - source.fps = fps - if brightness is not None: - source.brightness = brightness - if saturation is not None: - source.saturation = saturation - if gamma is not None: - source.gamma = gamma - if smoothing is not None: - source.smoothing = smoothing - if interpolation_mode is not None: - source.interpolation_mode = interpolation_mode - if calibration is not None: - source.calibration = calibration - if led_count is not None: - source.led_count = led_count - if frame_interpolation is not None: - source.frame_interpolation = frame_interpolation - elif isinstance(source, StaticColorStripSource): - if color is not None: - if isinstance(color, list) and len(color) == 3: - source.color = color - if animation is not None: - source.animation = animation - elif isinstance(source, GradientColorStripSource): - if stops is not None and isinstance(stops, list): - source.stops = stops - if animation is not None: - source.animation = animation - elif isinstance(source, ColorCycleColorStripSource): - if colors is not None and isinstance(colors, list) and len(colors) >= 2: - source.colors = colors - elif isinstance(source, EffectColorStripSource): - if effect_type is not None: - source.effect_type = effect_type - if palette is not None: - source.palette = palette - if color is not None and isinstance(color, list) and len(color) == 3: - source.color = color - if intensity is not None: - source.intensity = float(intensity) - if scale is not None: - source.scale = float(scale) - if mirror is not None: - source.mirror = bool(mirror) - elif isinstance(source, AudioColorStripSource): - if visualization_mode is not None: - source.visualization_mode = visualization_mode - if audio_source_id is not None: - source.audio_source_id = resolve_ref(audio_source_id, source.audio_source_id) - if sensitivity is not None: - source.sensitivity = float(sensitivity) - if smoothing is not None: - source.smoothing = float(smoothing) - if palette is not None: - source.palette = palette - if color is not None and isinstance(color, list) and len(color) == 3: - source.color = color - if color_peak is not None and isinstance(color_peak, list) and len(color_peak) == 3: - source.color_peak = color_peak - if led_count is not None: - source.led_count = led_count - if mirror is not None: - source.mirror = bool(mirror) - elif isinstance(source, CompositeColorStripSource): - if layers is not None and isinstance(layers, list): - source.layers = layers - if led_count is not None: - source.led_count = led_count - elif isinstance(source, MappedColorStripSource): - if zones is not None and isinstance(zones, list): - source.zones = zones - if led_count is not None: - source.led_count = led_count - elif isinstance(source, ApiInputColorStripSource): - if fallback_color is not None and isinstance(fallback_color, list) and len(fallback_color) == 3: - source.fallback_color = fallback_color - if timeout is not None: - source.timeout = float(timeout) - if led_count is not None: - source.led_count = led_count - elif isinstance(source, NotificationColorStripSource): - if notification_effect is not None: - source.notification_effect = notification_effect - if duration_ms is not None: - source.duration_ms = int(duration_ms) - if default_color is not None: - source.default_color = default_color - if app_colors is not None and isinstance(app_colors, dict): - source.app_colors = app_colors - if app_filter_mode is not None: - source.app_filter_mode = app_filter_mode - if app_filter_list is not None and isinstance(app_filter_list, list): - source.app_filter_list = app_filter_list - if os_listener is not None: - source.os_listener = bool(os_listener) - elif isinstance(source, DaylightColorStripSource): - if speed is not None: - source.speed = float(speed) - if use_real_time is not None: - source.use_real_time = bool(use_real_time) - if latitude is not None: - source.latitude = float(latitude) - elif isinstance(source, CandlelightColorStripSource): - if color is not None and isinstance(color, list) and len(color) == 3: - source.color = color - if intensity is not None: - source.intensity = float(intensity) - if num_candles is not None: - source.num_candles = int(num_candles) - if speed is not None: - source.speed = float(speed) + # -- Type-specific fields -- + source.apply_update(**kwargs) source.updated_at = datetime.now(timezone.utc) self._save() @@ -539,3 +147,12 @@ class ColorStripStore(BaseJsonStore[ColorStripSource]): names.append(source.name) break return names + + def get_processed_referencing(self, source_id: str) -> List[str]: + """Return names of processed sources that reference a given source as input.""" + names = [] + for source in self._items.values(): + if isinstance(source, ProcessedColorStripSource): + if source.input_source_id == source_id: + names.append(source.name) + return names diff --git a/server/src/wled_controller/storage/device_store.py b/server/src/wled_controller/storage/device_store.py index d21b7b3..f1443c9 100644 --- a/server/src/wled_controller/storage/device_store.py +++ b/server/src/wled_controller/storage/device_store.py @@ -6,7 +6,8 @@ from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional -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__) @@ -52,6 +53,8 @@ class Device: chroma_device_type: str = "chromalink", # SteelSeries GameSense fields gamesense_device_type: str = "keyboard", + # Default color strip processing template + default_css_processing_template_id: str = "", created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, ): @@ -80,6 +83,7 @@ class Device: self.spi_led_type = spi_led_type self.chroma_device_type = chroma_device_type self.gamesense_device_type = gamesense_device_type + self.default_css_processing_template_id = default_css_processing_template_id self.created_at = created_at or datetime.now(timezone.utc) self.updated_at = updated_at or datetime.now(timezone.utc) @@ -133,6 +137,8 @@ class Device: d["chroma_device_type"] = self.chroma_device_type if self.gamesense_device_type != "keyboard": d["gamesense_device_type"] = self.gamesense_device_type + if self.default_css_processing_template_id: + d["default_css_processing_template_id"] = self.default_css_processing_template_id return d @classmethod @@ -164,78 +170,44 @@ class Device: spi_led_type=data.get("spi_led_type", "WS2812B"), chroma_device_type=data.get("chroma_device_type", "chromalink"), gamesense_device_type=data.get("gamesense_device_type", "keyboard"), + default_css_processing_template_id=data.get("default_css_processing_template_id", ""), 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())), ) -class DeviceStore: +# Fields that can be updated (all Device.__init__ params except identity/timestamps) +_UPDATABLE_FIELDS = { + k for k in Device.__init__.__code__.co_varnames + if k not in ('self', 'device_id', 'created_at', 'updated_at') +} + + +class DeviceStore(BaseJsonStore[Device]): """Persistent storage for WLED devices.""" + _json_key = "devices" + _entity_name = "Device" + def __init__(self, storage_file: str | Path): - self.storage_file = Path(storage_file) - self._devices: Dict[str, Device] = {} + super().__init__(file_path=str(storage_file), deserializer=Device.from_dict) + logger.info(f"Device store initialized with {len(self._items)} devices") - # Ensure directory exists - self.storage_file.parent.mkdir(parents=True, exist_ok=True) + # ── Backward-compat aliases ────────────────────────────────── - # Load existing devices - self.load() + def get_device(self, device_id: str) -> Device: + """Get device by ID. Raises ValueError if not found.""" + return self.get(device_id) - logger.info(f"Device store initialized with {len(self._devices)} devices") + def get_all_devices(self) -> List[Device]: + """Get all devices.""" + return self.get_all() - def load(self): - """Load devices from storage file.""" - if not self.storage_file.exists(): - logger.info("Storage file does not exist, starting with empty store") - return + def delete_device(self, device_id: str) -> None: + """Delete device. Raises ValueError if not found.""" + self.delete(device_id) - try: - with open(self.storage_file, "r") as f: - data = json.load(f) - - devices_data = data.get("devices", {}) - self._devices = { - device_id: Device.from_dict(device_data) - for device_id, device_data in devices_data.items() - } - - logger.info(f"Loaded {len(self._devices)} devices from storage") - - except json.JSONDecodeError as e: - logger.error(f"Failed to parse storage file: {e}") - raise - except Exception as e: - logger.error(f"Failed to load devices: {e}") - raise - - def load_raw(self) -> dict: - """Load raw JSON data from storage (for migration).""" - if not self.storage_file.exists(): - return {} - try: - with open(self.storage_file, "r") as f: - return json.load(f) - except Exception: - return {} - - def save(self): - """Save devices to storage file.""" - try: - data = { - "devices": { - device_id: device.to_dict() - for device_id, device in self._devices.items() - } - } - - atomic_write_json(self.storage_file, data) - - logger.debug(f"Saved {len(self._devices)} devices to storage") - - except Exception as e: - logger.error(f"Failed to save devices: {e}") - raise + # ── Create / Update ────────────────────────────────────────── def create_device( self, @@ -295,122 +267,48 @@ class DeviceStore: gamesense_device_type=gamesense_device_type, ) - self._devices[device_id] = device - self.save() + self._items[device_id] = device + self._save() logger.info(f"Created device {device_id}: {name}") return device - def get_device(self, device_id: str) -> Optional[Device]: - """Get device by ID.""" - return self._devices.get(device_id) + def update_device(self, device_id: str, **kwargs) -> Device: + """Update device fields. - def get_all_devices(self) -> List[Device]: - """Get all devices.""" - return list(self._devices.values()) + Pass any updatable Device field as a keyword argument. + ``None`` values are ignored (no change). + """ + device = self.get(device_id) # raises ValueError if not found - def update_device( - self, - device_id: str, - name: Optional[str] = None, - url: Optional[str] = None, - led_count: Optional[int] = None, - enabled: Optional[bool] = None, - baud_rate: Optional[int] = None, - auto_shutdown: Optional[bool] = None, - send_latency_ms: Optional[int] = None, - rgbw: Optional[bool] = None, - zone_mode: Optional[str] = None, - tags: Optional[List[str]] = None, - dmx_protocol: Optional[str] = None, - dmx_start_universe: Optional[int] = None, - dmx_start_channel: Optional[int] = None, - espnow_peer_mac: Optional[str] = None, - espnow_channel: Optional[int] = None, - hue_username: Optional[str] = None, - hue_client_key: Optional[str] = None, - hue_entertainment_group_id: Optional[str] = None, - spi_speed_hz: Optional[int] = None, - spi_led_type: Optional[str] = None, - chroma_device_type: Optional[str] = None, - gamesense_device_type: Optional[str] = None, - ) -> Device: - """Update device.""" - device = self._devices.get(device_id) - if not device: - raise ValueError(f"Device {device_id} not found") - - if name is not None: - device.name = name - if url is not None: - device.url = url - if led_count is not None: - device.led_count = led_count - if enabled is not None: - device.enabled = enabled - if baud_rate is not None: - device.baud_rate = baud_rate - if auto_shutdown is not None: - device.auto_shutdown = auto_shutdown - if send_latency_ms is not None: - device.send_latency_ms = send_latency_ms - if rgbw is not None: - device.rgbw = rgbw - if zone_mode is not None: - device.zone_mode = zone_mode - if tags is not None: - device.tags = tags - if dmx_protocol is not None: - device.dmx_protocol = dmx_protocol - if dmx_start_universe is not None: - device.dmx_start_universe = dmx_start_universe - if dmx_start_channel is not None: - device.dmx_start_channel = dmx_start_channel - if espnow_peer_mac is not None: - device.espnow_peer_mac = espnow_peer_mac - if espnow_channel is not None: - device.espnow_channel = espnow_channel - if hue_username is not None: - device.hue_username = hue_username - if hue_client_key is not None: - device.hue_client_key = hue_client_key - if hue_entertainment_group_id is not None: - device.hue_entertainment_group_id = hue_entertainment_group_id - if spi_speed_hz is not None: - device.spi_speed_hz = spi_speed_hz - if spi_led_type is not None: - device.spi_led_type = spi_led_type - if chroma_device_type is not None: - device.chroma_device_type = chroma_device_type - if gamesense_device_type is not None: - device.gamesense_device_type = gamesense_device_type + for key, value in kwargs.items(): + if value is not None and key in _UPDATABLE_FIELDS: + setattr(device, key, value) device.updated_at = datetime.now(timezone.utc) - self.save() + self._save() logger.info(f"Updated device {device_id}") return device - def delete_device(self, device_id: str): - """Delete device.""" - if device_id not in self._devices: - raise ValueError(f"Device {device_id} not found") - - del self._devices[device_id] - self.save() - - logger.info(f"Deleted device {device_id}") + # ── Unique helpers ─────────────────────────────────────────── def device_exists(self, device_id: str) -> bool: """Check if device exists.""" - return device_id in self._devices - - def count(self) -> int: - """Get number of devices.""" - return len(self._devices) + return device_id in self._items def clear(self): """Clear all devices (for testing).""" - self._devices.clear() - self.save() + self._items.clear() + self._save() logger.warning("Cleared all devices from storage") + + def load_raw(self) -> dict: + """Load raw JSON data from storage (for migration).""" + if not self.file_path.exists(): + return {} + try: + with open(self.file_path, "r") as f: + return json.load(f) + except Exception: + return {} diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index a77d6de..be3b1c2 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -178,6 +178,7 @@ {% include 'modals/test-pp-template.html' %} {% include 'modals/stream.html' %} {% include 'modals/pp-template.html' %} + {% include 'modals/cspt-modal.html' %} {% include 'modals/automation-editor.html' %} {% include 'modals/scene-preset-editor.html' %} {% include 'modals/audio-source-editor.html' %} diff --git a/server/src/wled_controller/templates/modals/add-device.html b/server/src/wled_controller/templates/modals/add-device.html index 3d875e1..727c9b5 100644 --- a/server/src/wled_controller/templates/modals/add-device.html +++ b/server/src/wled_controller/templates/modals/add-device.html @@ -256,6 +256,16 @@ +
+
+ + +
+ + +
diff --git a/server/src/wled_controller/templates/modals/cspt-modal.html b/server/src/wled_controller/templates/modals/cspt-modal.html new file mode 100644 index 0000000..3c5864d --- /dev/null +++ b/server/src/wled_controller/templates/modals/cspt-modal.html @@ -0,0 +1,40 @@ + + diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index d9b1adb..a4a0a57 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -35,6 +35,7 @@ + @@ -74,58 +75,6 @@ -
-
- - -
- - -
- -
- Color Corrections -
-
-
- - -
- - -
- -
-
- - -
- - -
- -
-
- - -
- - -
-
-
@@ -608,6 +557,30 @@ + + +
diff --git a/server/src/wled_controller/templates/modals/device-settings.html b/server/src/wled_controller/templates/modals/device-settings.html index 7c2faae..cc91226 100644 --- a/server/src/wled_controller/templates/modals/device-settings.html +++ b/server/src/wled_controller/templates/modals/device-settings.html @@ -165,6 +165,17 @@
+
+
+ + +
+ + +
+ diff --git a/server/src/wled_controller/templates/modals/test-css-source.html b/server/src/wled_controller/templates/modals/test-css-source.html index c66c256..3e01d40 100644 --- a/server/src/wled_controller/templates/modals/test-css-source.html +++ b/server/src/wled_controller/templates/modals/test-css-source.html @@ -44,6 +44,12 @@ + + +
diff --git a/server/src/wled_controller/utils/gpu.py b/server/src/wled_controller/utils/gpu.py new file mode 100644 index 0000000..0e6ecc3 --- /dev/null +++ b/server/src/wled_controller/utils/gpu.py @@ -0,0 +1,24 @@ +"""GPU monitoring state (NVIDIA via pynvml). + +Extracted from system routes so core modules can access GPU metrics +without importing from the API layer. +""" + +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +nvml_available = False +nvml = None +nvml_handle = None + +try: + import pynvml as _pynvml_mod + + _pynvml_mod.nvmlInit() + nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0) + nvml_available = True + nvml = _pynvml_mod + logger.info(f"NVIDIA GPU monitoring enabled: {nvml.nvmlDeviceGetName(nvml_handle)}") +except Exception: + logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)") diff --git a/server/tests/test_device_store.py b/server/tests/test_device_store.py index 4a29688..c8acf1d 100644 --- a/server/tests/test_device_store.py +++ b/server/tests/test_device_store.py @@ -4,8 +4,6 @@ import pytest from pathlib import Path from wled_controller.storage.device_store import Device, DeviceStore -from wled_controller.core.processing.processing_settings import ProcessingSettings -from wled_controller.core.capture.calibration import create_default_calibration @pytest.fixture @@ -51,8 +49,6 @@ def test_device_to_dict(): assert data["name"] == "Test Device" assert data["url"] == "http://192.168.1.100" assert data["led_count"] == 150 - assert "settings" in data - assert "calibration" in data def test_device_from_dict(): @@ -63,11 +59,6 @@ def test_device_from_dict(): "url": "http://192.168.1.100", "led_count": 150, "enabled": True, - "settings": { - "display_index": 0, - "fps": 30, - "border_width": 10, - }, } device = Device.from_dict(data) @@ -130,9 +121,9 @@ def test_get_device(device_store): def test_get_device_not_found(device_store): - """Test retrieving non-existent device.""" - device = device_store.get_device("nonexistent") - assert device is None + """Test retrieving non-existent device raises ValueError.""" + with pytest.raises(ValueError, match="not found"): + device_store.get_device("nonexistent") def test_get_all_devices(device_store): @@ -165,41 +156,17 @@ def test_update_device(device_store): assert updated.enabled is False -def test_update_device_settings(device_store): - """Test updating device settings.""" +def test_update_device_led_count(device_store): + """Test updating device LED count.""" device = device_store.create_device( name="Test WLED", url="http://192.168.1.100", led_count=150, ) - new_settings = ProcessingSettings(fps=60, border_width=20) + updated = device_store.update_device(device.id, led_count=200) - updated = device_store.update_device( - device.id, - settings=new_settings, - ) - - assert updated.settings.fps == 60 - assert updated.settings.border_width == 20 - - -def test_update_device_calibration(device_store): - """Test updating device calibration.""" - device = device_store.create_device( - name="Test WLED", - url="http://192.168.1.100", - led_count=150, - ) - - new_calibration = create_default_calibration(150) - - updated = device_store.update_device( - device.id, - calibration=new_calibration, - ) - - assert updated.calibration is not None + assert updated.led_count == 200 def test_update_device_not_found(device_store): @@ -219,7 +186,8 @@ def test_delete_device(device_store): device_store.delete_device(device.id) assert device_store.count() == 0 - assert device_store.get_device(device.id) is None + with pytest.raises(ValueError, match="not found"): + device_store.get_device(device.id) def test_delete_device_not_found(device_store): @@ -273,33 +241,28 @@ def test_clear(device_store): assert device_store.count() == 0 -def test_update_led_count_resets_calibration(device_store): - """Test that updating LED count resets calibration.""" +def test_update_device_ignores_none_values(device_store): + """Test that update_device ignores None kwargs.""" device = device_store.create_device( name="Test WLED", url="http://192.168.1.100", led_count=150, ) - original_calibration = device.calibration + updated = device_store.update_device(device.id, name=None, led_count=200) - # Update LED count - updated = device_store.update_device(device.id, led_count=200) - - # Calibration should be reset for new LED count - assert updated.calibration.get_total_leds() == 200 - assert updated.calibration != original_calibration + assert updated.name == "Test WLED" # unchanged + assert updated.led_count == 200 -def test_update_calibration_led_count_mismatch(device_store): - """Test updating calibration with mismatched LED count fails.""" +def test_update_device_ignores_unknown_fields(device_store): + """Test that update_device silently ignores unknown kwargs.""" device = device_store.create_device( name="Test WLED", url="http://192.168.1.100", led_count=150, ) - wrong_calibration = create_default_calibration(100) - - with pytest.raises(ValueError, match="does not match"): - device_store.update_device(device.id, calibration=wrong_calibration) + # Should not raise even with an unknown kwarg + updated = device_store.update_device(device.id, nonexistent_field="foo") + assert updated.name == "Test WLED"