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

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