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) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 02:16:59 +03:00
parent 7e78323c9c
commit 294d704eb0
72 changed files with 2992 additions and 1416 deletions

33
TODO.md
View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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,
})

View File

@@ -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:

View File

@@ -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),

View File

@@ -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

View File

@@ -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=<api_key>. 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

View File

@@ -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=<api_key>``.
"""
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}")

View File

@@ -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:

View File

@@ -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=<api_key>.
"""
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":

View File

@@ -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=<api_key>."""
# 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=<api_key>."""
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=<api_key>."""
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

View File

@@ -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

View File

@@ -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")

View File

@@ -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))

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"

View File

@@ -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",

View File

@@ -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]:

View File

@@ -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."""

View File

@@ -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

View File

@@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter):
filter_id = "downscaler"
filter_name = "Downscaler"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -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]:

View File

@@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter):
filter_id = "flip"
filter_name = "Flip"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:

View File

@@ -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

View File

@@ -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",

View File

@@ -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()

View File

@@ -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 ───────────────────────────────────────────────────

View File

@@ -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.22.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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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 ───────────────────────────────────────────────────────

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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}"
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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: {

View File

@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>';
// ── Action constants ────────────────────────────────────────

View File

@@ -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 || [],

View File

@@ -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 = '<option value="">—</option>' +
inputSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).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 = '<option value="">—</option>' +
templates.map(tp => `<option value="${tp.id}">${escapeHtml(tp.name)}</option>`).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 =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none')}</option>` +
csptList.map(tmpl =>
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item">
@@ -625,6 +682,12 @@ function _compositeRenderList() {
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.processing')}:</span>
</label>
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
</div>
</div>
`;
}).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) {
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
${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 = `
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
`;
} 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
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
@@ -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 =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).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);
}

View File

@@ -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 <select> options
sel.innerHTML = `<option value="">—</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_csptEntitySelect) _csptEntitySelect.destroy();
if (templates.length > 0) {
_csptEntitySelect = new EntitySelect({
target: sel,
getItems: () => (csptCache.data || []).map(tp => ({
value: tp.id,
label: tp.name,
icon: ICON_TEMPLATE,
desc: '',
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: '—',
});
}
}
/* ── Icon-grid DMX protocol selector ─────────────────────────── */
function _buildDmxProtocolItems() {
@@ -583,6 +611,8 @@ export function showAddDevice(presetType = null) {
const scanBtn = document.getElementById('scan-network-btn');
if (scanBtn) scanBtn.disabled = false;
_ensureDeviceTypeIconSelect();
// Populate CSPT template selector
csptCache.fetch().then(() => _ensureCsptEntitySelect());
// Pre-select type and hide the type selector (already chosen)
document.getElementById('device-type').value = presetType;
@@ -775,6 +805,8 @@ export async function handleAddDevice(event) {
if (isGameSenseDevice(deviceType)) {
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard';
}
const csptId = document.getElementById('device-css-processing-template')?.value;
if (csptId) body.default_css_processing_template_id = csptId;
if (lastTemplateId) body.capture_template_id = lastTemplateId;
const response = await fetchWithAuth('/devices', {

View File

@@ -4,6 +4,7 @@
import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js';
import { devicesCache } from '../core/state.js';
@@ -11,11 +12,36 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH } from '../core/icons.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { EntitySelect } from '../core/entity-palette.js';
let _deviceTagsInput = null;
let _settingsCsptEntitySelect = null;
function _ensureSettingsCsptSelect() {
const sel = document.getElementById('settings-css-processing-template');
if (!sel) return;
const templates = csptCache.data || [];
sel.innerHTML = `<option value="">—</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy();
if (templates.length > 0) {
_settingsCsptEntitySelect = new EntitySelect({
target: sel,
getItems: () => (csptCache.data || []).map(tp => ({
value: tp.id,
label: tp.name,
icon: ICON_TEMPLATE,
desc: '',
})),
placeholder: window.t ? t('palette.search') : 'Search...',
allowNone: true,
noneLabel: '—',
});
}
}
class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); }
@@ -38,6 +64,7 @@ class DeviceSettingsModal extends Modal {
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet',
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0',
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1',
csptId: document.getElementById('settings-css-processing-template')?.value || '',
};
}
@@ -394,6 +421,12 @@ export async function showSettings(deviceId) {
});
_deviceTagsInput.setValue(device.tags || []);
// CSPT template selector
await csptCache.fetch();
_ensureSettingsCsptSelect();
const csptSel = document.getElementById('settings-css-processing-template');
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
settingsModal.snapshot();
settingsModal.open();
@@ -407,7 +440,7 @@ export async function showSettings(deviceId) {
}
export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
@@ -449,6 +482,8 @@ export async function saveDeviceSettings() {
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10);
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10);
}
const csptId = document.getElementById('settings-css-processing-template')?.value || '';
body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify(body)

View File

@@ -11,7 +11,7 @@ import {
streamsCache, audioSourcesCache, audioTemplatesCache,
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
automationsCacheObj,
automationsCacheObj, csptCache,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast, showConfirm } from '../core/ui.js';
@@ -515,13 +515,13 @@ async function _fetchAllEntities() {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
batchStatesResp,
csptTemplates, batchStatesResp,
] = await Promise.all([
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
automationsCacheObj.fetch(),
automationsCacheObj.fetch(), csptCache.fetch(),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
]);
@@ -540,6 +540,7 @@ async function _fetchAllEntities() {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
csptTemplates,
};
}

View File

@@ -23,6 +23,10 @@ import {
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
_cachedCSPTemplates,
_csptModalFilters, set_csptModalFilters,
_csptNameManuallyEdited, set_csptNameManuallyEdited,
_stripFilters,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
@@ -31,6 +35,7 @@ import {
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -48,7 +53,7 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -61,6 +66,7 @@ let _captureTemplateTagsInput = null;
let _streamTagsInput = null;
let _ppTemplateTagsInput = null;
let _audioTemplateTagsInput = null;
let _csptTagsInput = null;
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
@@ -74,6 +80,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -170,13 +177,34 @@ class AudioTemplateModal extends Modal {
}
}
class CSPTEditorModal extends Modal {
constructor() { super('cspt-modal'); }
snapshotValues() {
return {
name: document.getElementById('cspt-name').value,
description: document.getElementById('cspt-description').value,
filters: JSON.stringify(_csptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
tags: JSON.stringify(_csptTagsInput ? _csptTagsInput.getValue() : []),
};
}
onForceClose() {
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
set_csptModalFilters([]);
set_csptNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
const streamModal = new StreamEditorModal();
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new PPTemplateEditorModal();
const testPPTemplateModal = new Modal('test-pp-template-modal');
let _ppTestSourceEntitySelect = null;
const audioTemplateModal = new AudioTemplateModal();
const csptModal = new CSPTEditorModal();
// ===== Capture Templates =====
@@ -1179,6 +1207,7 @@ export async function loadPictureSources() {
syncClocksCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
@@ -1220,6 +1249,7 @@ const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
css_processing: [csCSPTemplates],
color_strip: [csColorStrips],
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
value: [csValueSources],
@@ -1365,6 +1395,32 @@ function renderPictureSourcesList(streams) {
});
};
const renderCSPTCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
@@ -1372,6 +1428,9 @@ function renderPictureSourcesList(streams) {
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
// CSPT templates
const csptTemplates = csptCache.data;
// Color strip sources (maps needed for card rendering)
const colorStrips = colorStripSourcesCache.data;
const pictureSourceMap = {};
@@ -1383,6 +1442,7 @@ function renderPictureSourcesList(streams) {
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
@@ -1400,8 +1460,11 @@ function renderPictureSourcesList(streams) {
]
},
{
key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip',
count: colorStrips.length,
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length },
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length },
]
},
{
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
@@ -1515,6 +1578,7 @@ function renderPictureSourcesList(streams) {
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -1522,6 +1586,7 @@ function renderPictureSourcesList(streams) {
raw: rawStreams.length,
static_image: staticImageStreams.length,
processed: processedStreams.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
value: _cachedValueSources.length,
@@ -1531,6 +1596,7 @@ function renderPictureSourcesList(streams) {
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
@@ -1544,6 +1610,7 @@ function renderPictureSourcesList(streams) {
let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
@@ -1553,7 +1620,7 @@ function renderPictureSourcesList(streams) {
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -1562,6 +1629,7 @@ function renderPictureSourcesList(streams) {
'raw-streams': 'raw', 'raw-templates': 'raw',
'static-streams': 'static_image',
'proc-streams': 'processed', 'proc-templates': 'processed',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio',
'value-sources': 'value',
@@ -2073,6 +2141,18 @@ export async function showTestPPTemplateModal(templateId) {
select.value = lastStream;
}
// EntitySelect for source stream picker
if (_ppTestSourceEntitySelect) _ppTestSourceEntitySelect.destroy();
_ppTestSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedStreams.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
testPPTemplateModal.open();
}
@@ -2134,6 +2214,16 @@ function _getFilterName(filterId) {
return translated;
}
function _getStripFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
if (translated === key) {
const def = _stripFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
let _filterIconSelect = null;
const _FILTER_ICONS = {
@@ -2149,6 +2239,7 @@ const _FILTER_ICONS = {
frame_interpolation: P.fastForward,
noise_gate: P.volume2,
palette_quantization: P.sparkles,
css_filter_template: P.fileText,
};
function _populateFilterSelect() {
@@ -2179,17 +2270,32 @@ function _populateFilterSelect() {
}
}
export function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
/**
* Generic filter list renderer — shared by PP template and CSPT modals.
* @param {string} containerId - DOM container ID for filter cards
* @param {Array} filtersArr - mutable array of {filter_id, options, _expanded}
* @param {Array} filterDefs - available filter definitions (with options_schema)
* @param {string} prefix - handler prefix: '' for PP, 'cspt' for CSPT
* @param {string} editingIdInputId - ID of hidden input holding the editing template ID
* @param {string} selfRefFilterId - filter_id that should exclude self ('filter_template' or 'css_filter_template')
*/
function _renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId) {
const container = document.getElementById(containerId);
if (filtersArr.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
return;
}
const toggleFn = prefix ? `${prefix}ToggleFilterExpand` : 'toggleFilterExpand';
const removeFn = prefix ? `${prefix}RemoveFilter` : 'removeFilter';
const updateFn = prefix ? `${prefix}UpdateFilterOption` : 'updateFilterOption';
const inputPrefix = prefix ? `${prefix}-filter` : 'filter';
const nameFn = prefix ? _getStripFilterName : _getFilterName;
let html = '';
_modalFilters.forEach((fi, index) => {
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
const filterName = _getFilterName(fi.filter_id);
filtersArr.forEach((fi, index) => {
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
const filterName = nameFn(fi.filter_id);
const isExpanded = fi._expanded === true;
let summary = '';
@@ -2201,13 +2307,13 @@ export function renderModalFilterList() {
}
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<div class="pp-filter-card-header" onclick="${toggleFn}(${index})">
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
@@ -2215,35 +2321,37 @@ export function renderModalFilterList() {
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `filter-${index}-${opt.key}`;
const inputId = `${inputPrefix}-${index}-${opt.key}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} 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 =>
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).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 += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
<select id="${inputId}"${gridAttr}${entityAttr}
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
@@ -2253,7 +2361,7 @@ export function renderModalFilterList() {
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
maxlength="${maxLen}" class="pp-filter-text-input"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
</div>`;
} else {
html += `<div class="pp-filter-option">
@@ -2263,7 +2371,7 @@ export function renderModalFilterList() {
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
@@ -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 `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
}
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
}
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 = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
for (const f of _stripFilters) {
const name = _getStripFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
items.push({
value: f.filter_id,
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
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 };

View File

@@ -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",

View File

@@ -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": "Выберите тип источника значений",

View File

@@ -169,6 +169,8 @@
"device.led_type.hint": "RGB3通道或 RGBW4通道带独立白色",
"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": "选择值源类型",

View File

@@ -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():

View File

@@ -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", []),
)

View File

@@ -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

View File

@@ -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.02.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.01.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.12.0)
scale: float = 1.0 # spatial scale / zoom (0.55.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.15.0)
smoothing: float = 0.3 # temporal smoothing (0.01.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.12.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,
}

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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' %}

View File

@@ -256,6 +256,16 @@
<option value="indicator">Indicator</option>
</select>
</div>
<div class="form-group" id="device-cspt-group">
<div class="label-row">
<label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
<select id="device-css-processing-template">
<option value=""></option>
</select>
</div>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>
</div>

View File

@@ -0,0 +1,40 @@
<!-- Color Strip Processing Template Modal -->
<div id="cspt-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="cspt-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="cspt-modal-title" data-i18n="css_processing.add">Add Strip Processing Template</h2>
<button class="modal-close-btn" onclick="closeCSPTModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<input type="hidden" id="cspt-id">
<form id="cspt-form">
<div class="form-group">
<label for="cspt-name" data-i18n="css_processing.name">Template Name:</label>
<input type="text" id="cspt-name" data-i18n-placeholder="css_processing.name_placeholder" placeholder="My Strip Processing Template" required>
<div id="cspt-tags-container"></div>
</div>
<!-- Dynamic filter list -->
<div id="cspt-filter-list" class="pp-filter-list"></div>
<!-- Add filter control -->
<div class="pp-add-filter-row">
<select id="cspt-add-filter-select" class="pp-add-filter-select">
<option value="" data-i18n="filters.select_type">Select filter type...</option>
</select>
</div>
<div class="form-group">
<label for="cspt-description" data-i18n="css_processing.description_label">Description (optional):</label>
<input type="text" id="cspt-description" data-i18n-placeholder="css_processing.description_placeholder" placeholder="Describe this template...">
</div>
<div id="cspt-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCSPTModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveCSPT()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>

View File

@@ -35,6 +35,7 @@
<option value="notification" data-i18n="color_strip.type.notification">Notification</option>
<option value="daylight" data-i18n="color_strip.type.daylight">Daylight Cycle</option>
<option value="candlelight" data-i18n="color_strip.type.candlelight">Candlelight</option>
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
</select>
</div>
@@ -74,58 +75,6 @@
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-frame-interpolation" data-i18n="color_strip.frame_interpolation">Frame Interpolation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.frame_interpolation.hint">Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-frame-interpolation">
<span class="settings-toggle-slider"></span>
</label>
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label for="css-editor-brightness">
<span data-i18n="color_strip.brightness">Brightness:</span>
<span id="css-editor-brightness-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-saturation">
<span data-i18n="color_strip.saturation">Saturation:</span>
<span id="css-editor-saturation-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gamma">
<span data-i18n="color_strip.gamma">Gamma:</span>
<span id="css-editor-gamma-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, &lt;1=brighter midtones, &gt;1=darker midtones)</small>
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
</div>
</details>
</div>
<!-- Static-color-specific fields -->
@@ -608,6 +557,30 @@
</div>
</div>
<!-- Processed type fields -->
<div id="css-editor-processed-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-processed-input" data-i18n="color_strip.processed.input">Input Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.input.hint">The color strip source whose output will be processed</small>
<select id="css-editor-processed-input">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-processed-template" data-i18n="color_strip.processed.template">Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.processed.template.hint">Filter chain to apply to the input source output</small>
<select id="css-editor-processed-template">
<option value=""></option>
</select>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">

View File

@@ -165,6 +165,17 @@
</div>
</div>
<div class="form-group" id="settings-cspt-group">
<div class="label-row">
<label for="settings-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
<select id="settings-css-processing-template">
<option value=""></option>
</select>
</div>
<div id="device-settings-error" class="error-message" style="display: none;"></div>
</form>
</div>

View File

@@ -44,6 +44,12 @@
<canvas id="css-test-layers-axis" class="css-test-strip-axis"></canvas>
</div>
<!-- CSPT test: input source selector (hidden by default) -->
<div id="css-test-cspt-input-group" style="display:none" class="css-test-led-control">
<label for="css-test-cspt-input-select" data-i18n="color_strip.processed.input">Input Source:</label>
<select id="css-test-cspt-input-select" class="css-test-cspt-select" onchange="applyCssTestSettings()"></select>
</div>
<!-- LED count & FPS controls -->
<div class="css-test-led-control">
<span id="css-test-led-group">

View File

@@ -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)")

View File

@@ -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"