Introduce ColorStripSource as first-class entity

Extracts color processing and calibration out of WledPictureTarget into a
new PictureColorStripSource entity, enabling multiple LED targets to share
one capture/processing pipeline.

New entities & processing:
- storage/color_strip_source.py: ColorStripSource + PictureColorStripSource models
- storage/color_strip_store.py: JSON-backed CRUD store (prefix css_)
- core/processing/color_strip_stream.py: ColorStripStream ABC + PictureColorStripStream (runs border-extract → map → smooth → brightness/sat/gamma in background thread)
- core/processing/color_strip_stream_manager.py: ref-counted shared stream manager

Modified storage/processing:
- WledPictureTarget simplified to device_id + color_strip_source_id + standby_interval + state_check_interval
- Device model: calibration field removed
- WledTargetProcessor: acquires ColorStripStream from manager instead of running its own pipeline
- ProcessorManager: wires ColorStripStreamManager into TargetContext

API layer:
- New routes: GET/POST/PUT/DELETE /api/v1/color-strip-sources, PUT calibration/test
- Removed calibration endpoints from /devices
- Updated /picture-targets CRUD for new target structure

Frontend:
- New color-strips.js module with CSS editor modal and card rendering
- Calibration modal extended with CSS mode (css-id hidden field + device picker)
- targets.js: Color Strip Sources section added to LED tab; target editor/card updated
- app.js: imports and window globals for CSS + showCSSCalibration
- en.json / ru.json: color_strip.* and targets.section.color_strips keys added

Data migration runs at startup: existing WledPictureTargets are converted to
reference a new PictureColorStripSource created from their old settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 15:49:47 +03:00
parent c4e0257389
commit 7de3546b14
33 changed files with 2325 additions and 814 deletions

View File

@@ -9,6 +9,7 @@ from .routes.postprocessing import router as postprocessing_router
from .routes.picture_sources import router as picture_sources_router
from .routes.pattern_templates import router as pattern_templates_router
from .routes.picture_targets import router as picture_targets_router
from .routes.color_strip_sources import router as color_strip_sources_router
from .routes.profiles import router as profiles_router
router = APIRouter()
@@ -18,6 +19,7 @@ router.include_router(templates_router)
router.include_router(postprocessing_router)
router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(color_strip_sources_router)
router.include_router(picture_targets_router)
router.include_router(profiles_router)

View File

@@ -7,6 +7,7 @@ from wled_controller.storage.postprocessing_template_store import Postprocessing
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.profile_store import ProfileStore
from wled_controller.core.profiles.profile_engine import ProfileEngine
@@ -17,6 +18,7 @@ _pp_template_store: PostprocessingTemplateStore | None = None
_pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_color_strip_store: ColorStripStore | None = None
_processor_manager: ProcessorManager | None = None
_profile_store: ProfileStore | None = None
_profile_engine: ProfileEngine | None = None
@@ -64,6 +66,13 @@ def get_picture_target_store() -> PictureTargetStore:
return _picture_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
def get_processor_manager() -> ProcessorManager:
"""Get processor manager dependency."""
if _processor_manager is None:
@@ -93,13 +102,14 @@ def init_dependencies(
pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None,
color_strip_store: ColorStripStore | None = None,
profile_store: ProfileStore | None = None,
profile_engine: ProfileEngine | None = None,
):
"""Initialize global dependencies."""
global _device_store, _template_store, _processor_manager
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
global _profile_store, _profile_engine
global _color_strip_store, _profile_store, _profile_engine
_device_store = device_store
_template_store = template_store
_processor_manager = processor_manager
@@ -107,5 +117,6 @@ def init_dependencies(
_pattern_template_store = pattern_template_store
_picture_source_store = picture_source_store
_picture_target_store = picture_target_store
_color_strip_store = color_strip_store
_profile_store = profile_store
_profile_engine = profile_engine

View File

@@ -0,0 +1,258 @@
"""Color strip source routes: CRUD and calibration test."""
from fastapi import APIRouter, Depends, HTTPException
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
get_color_strip_store,
get_picture_target_store,
get_processor_manager,
)
from wled_controller.api.schemas.color_strip_sources import (
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
ColorStripSourceUpdate,
CSSCalibrationTestRequest,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
CalibrationTestModeResponse,
)
from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
def _css_to_response(source) -> ColorStripSourceResponse:
"""Convert a ColorStripSource to a ColorStripSourceResponse."""
calibration = None
if isinstance(source, PictureColorStripSource) and source.calibration:
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
return ColorStripSourceResponse(
id=source.id,
name=source.name,
source_type=source.source_type,
picture_source_id=getattr(source, "picture_source_id", None),
fps=getattr(source, "fps", None),
brightness=getattr(source, "brightness", None),
saturation=getattr(source, "saturation", None),
gamma=getattr(source, "gamma", None),
smoothing=getattr(source, "smoothing", None),
interpolation_mode=getattr(source, "interpolation_mode", None),
calibration=calibration,
description=source.description,
created_at=source.created_at,
updated_at=source.updated_at,
)
# ===== CRUD ENDPOINTS =====
@router.get("/api/v1/color-strip-sources", response_model=ColorStripSourceListResponse, tags=["Color Strip Sources"])
async def list_color_strip_sources(
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
):
"""List all color strip sources."""
sources = store.get_all_sources()
responses = [_css_to_response(s) for s in sources]
return ColorStripSourceListResponse(sources=responses, count=len(responses))
@router.post("/api/v1/color-strip-sources", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"], status_code=201)
async def create_color_strip_source(
data: ColorStripSourceCreate,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
):
"""Create a new color strip source."""
try:
calibration = None
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
source = store.create_source(
name=data.name,
source_type=data.source_type,
picture_source_id=data.picture_source_id,
fps=data.fps,
brightness=data.brightness,
saturation=data.saturation,
gamma=data.gamma,
smoothing=data.smoothing,
interpolation_mode=data.interpolation_mode,
calibration=calibration,
description=data.description,
)
return _css_to_response(source)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
async def get_color_strip_source(
source_id: str,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
):
"""Get a color strip source by ID."""
try:
source = store.get_source(source_id)
return _css_to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/color-strip-sources/{source_id}", response_model=ColorStripSourceResponse, tags=["Color Strip Sources"])
async def update_color_strip_source(
source_id: str,
data: ColorStripSourceUpdate,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update a color strip source and hot-reload any running streams."""
try:
calibration = None
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
source = store.update_source(
source_id=source_id,
name=data.name,
picture_source_id=data.picture_source_id,
fps=data.fps,
brightness=data.brightness,
saturation=data.saturation,
gamma=data.gamma,
smoothing=data.smoothing,
interpolation_mode=data.interpolation_mode,
calibration=calibration,
description=data.description,
)
# Hot-reload running stream (no restart needed for in-place param changes)
try:
manager._color_strip_stream_manager.update_source(source_id, source)
except Exception as e:
logger.warning(f"Could not hot-reload CSS stream {source_id}: {e}")
return _css_to_response(source)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/color-strip-sources/{source_id}", status_code=204, tags=["Color Strip Sources"])
async def delete_color_strip_source(
source_id: str,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Delete a color strip source. Returns 409 if referenced by any LED target."""
try:
if target_store.is_referenced_by_color_strip_source(source_id):
raise HTTPException(
status_code=409,
detail="Color strip source is referenced by one or more LED targets. "
"Delete or reassign the targets first.",
)
store.delete_source(source_id)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete color strip source: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== CALIBRATION TEST =====
@router.put(
"/api/v1/color-strip-sources/{source_id}/calibration/test",
response_model=CalibrationTestModeResponse,
tags=["Color Strip Sources"],
)
async def test_css_calibration(
source_id: str,
body: CSSCalibrationTestRequest,
_auth: AuthRequired,
store: ColorStripStore = Depends(get_color_strip_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Temporarily light up LED edges to verify calibration.
Pass a device_id and an edges dict with RGB colors.
Send an empty edges dict to exit test mode.
"""
try:
# Validate device exists in manager
if body.device_id not in manager._devices:
raise HTTPException(status_code=404, detail=f"Device {body.device_id} not found")
# Validate edge names and colors
valid_edges = {"top", "right", "bottom", "left"}
for edge_name, color in body.edges.items():
if edge_name not in valid_edges:
raise HTTPException(
status_code=400,
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(sorted(valid_edges))}"
)
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
raise HTTPException(
status_code=400,
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255.",
)
# Get CSS calibration to send the right pixel pattern
calibration = None
if body.edges:
try:
source = store.get_source(source_id)
if isinstance(source, PictureColorStripSource) and source.calibration:
calibration = source.calibration
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
await manager.set_test_mode(body.device_id, body.edges, calibration)
active_edges = list(body.edges.keys())
logger.info(
f"CSS calibration test mode {'activated' if active_edges else 'deactivated'} "
f"for device {body.device_id} via CSS {source_id}: {active_edges}"
)
return CalibrationTestModeResponse(
test_mode=len(active_edges) > 0,
active_edges=active_edges,
device_id=body.device_id,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to set CSS calibration test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -15,9 +15,6 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.devices import (
Calibration as CalibrationSchema,
CalibrationTestModeRequest,
CalibrationTestModeResponse,
DeviceCreate,
DeviceListResponse,
DeviceResponse,
@@ -27,10 +24,6 @@ from wled_controller.api.schemas.devices import (
DiscoverDevicesResponse,
StaticColorUpdate,
)
from wled_controller.core.capture.calibration import (
calibration_from_dict,
calibration_to_dict,
)
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
@@ -54,7 +47,6 @@ def _device_to_response(device) -> DeviceResponse:
auto_shutdown=device.auto_shutdown,
static_color=list(device.static_color) if device.static_color else None,
capabilities=sorted(get_device_capabilities(device.device_type)),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -133,7 +125,6 @@ async def create_device(
device_id=device.id,
device_url=device.url,
led_count=device.led_count,
calibration=device.calibration,
device_type=device.device_type,
baud_rate=device.baud_rate,
auto_shutdown=device.auto_shutdown,
@@ -534,105 +525,3 @@ async def set_device_color(
return {"color": list(color) if color else None}
# ===== CALIBRATION ENDPOINTS =====
@router.get("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def get_calibration(
device_id: str,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
):
"""Get calibration configuration for a device."""
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
return CalibrationSchema(**calibration_to_dict(device.calibration))
@router.put("/api/v1/devices/{device_id}/calibration", response_model=CalibrationSchema, tags=["Calibration"])
async def update_calibration(
device_id: str,
calibration_data: CalibrationSchema,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update calibration configuration for a device."""
try:
# Convert schema to CalibrationConfig
calibration_dict = calibration_data.model_dump()
calibration = calibration_from_dict(calibration_dict)
# Update in storage
device = store.update_device(device_id, calibration=calibration)
# Update in manager (also updates active target's cached calibration)
try:
manager.update_calibration(device_id, calibration)
except ValueError:
pass
return CalibrationSchema(**calibration_to_dict(device.calibration))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update calibration: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/api/v1/devices/{device_id}/calibration/test",
response_model=CalibrationTestModeResponse,
tags=["Calibration"],
)
async def set_calibration_test_mode(
device_id: str,
body: CalibrationTestModeRequest,
_auth: AuthRequired,
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Toggle calibration test mode for specific edges."""
try:
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Validate edge names and colors
valid_edges = {"top", "right", "bottom", "left"}
for edge_name, color in body.edges.items():
if edge_name not in valid_edges:
raise HTTPException(
status_code=400,
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
)
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
raise HTTPException(
status_code=400,
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
)
await manager.set_test_mode(device_id, body.edges)
active_edges = list(body.edges.keys())
logger.info(
f"Test mode {'activated' if active_edges else 'deactivated'} "
f"for device {device_id}: {active_edges}"
)
return CalibrationTestModeResponse(
test_mode=len(active_edges) > 0,
active_edges=active_edges,
device_id=device_id,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to set test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -29,7 +29,6 @@ from wled_controller.api.schemas.picture_targets import (
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings as ProcessingSettingsSchema,
TargetMetricsResponse,
TargetProcessingState,
)
@@ -37,7 +36,6 @@ 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
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
@@ -61,32 +59,6 @@ logger = get_logger(__name__)
router = APIRouter()
def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
"""Convert schema ProcessingSettings to core ProcessingSettings."""
return ProcessingSettings(
display_index=schema.display_index,
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness,
smoothing=schema.smoothing,
standby_interval=schema.standby_interval,
state_check_interval=schema.state_check_interval,
)
def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchema:
"""Convert core ProcessingSettings to schema ProcessingSettings."""
return ProcessingSettingsSchema(
display_index=settings.display_index,
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness,
smoothing=settings.smoothing,
standby_interval=settings.standby_interval,
state_check_interval=settings.state_check_interval,
)
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
@@ -117,8 +89,9 @@ def _target_to_response(target) -> PictureTargetResponse:
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
picture_source_id=target.picture_source_id,
settings=_settings_to_schema(target.settings),
color_strip_source_id=target.color_strip_source_id,
standby_interval=target.standby_interval,
state_check_interval=target.state_check_interval,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -163,8 +136,6 @@ async def create_target(
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Create in store
@@ -172,8 +143,10 @@ async def create_target(
name=data.name,
target_type=data.target_type,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
standby_interval=data.standby_interval,
state_check_interval=data.state_check_interval,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
@@ -237,8 +210,6 @@ async def update_target(
if not device:
raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found")
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Update in store
@@ -246,8 +217,10 @@ async def update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
standby_interval=data.standby_interval,
state_check_interval=data.state_check_interval,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
@@ -256,8 +229,10 @@ async def update_target(
try:
target.sync_with_manager(
manager,
settings_changed=data.settings is not None or data.key_colors_settings is not None,
source_changed=data.picture_source_id is not None,
settings_changed=(data.standby_interval is not None or
data.state_check_interval is not None or
data.key_colors_settings is not None),
source_changed=data.color_strip_source_id is not None,
device_changed=data.device_id is not None,
)
except ValueError:
@@ -375,76 +350,6 @@ async def get_target_state(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/settings", tags=["Settings"])
async def get_target_settings(
target_id: str,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
):
"""Get processing settings for a target."""
try:
target = target_store.get_target(target_id)
if isinstance(target, KeyColorsPictureTarget):
return _kc_settings_to_schema(target.settings)
if isinstance(target, WledPictureTarget):
return _settings_to_schema(target.settings)
return ProcessingSettingsSchema()
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
async def update_target_settings(
target_id: str,
settings: ProcessingSettingsSchema,
_auth: AuthRequired,
target_store: PictureTargetStore = Depends(get_picture_target_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Update processing settings for a target.
Merges with existing settings so callers can send partial updates.
"""
try:
target = target_store.get_target(target_id)
if not isinstance(target, WledPictureTarget):
raise HTTPException(status_code=400, detail="Target does not support processing settings")
existing = target.settings
sent = settings.model_fields_set
# Merge: only override fields the client explicitly provided
new_settings = ProcessingSettings(
display_index=settings.display_index if 'display_index' in sent else existing.display_index,
fps=settings.fps if 'fps' in sent else existing.fps,
interpolation_mode=settings.interpolation_mode if 'interpolation_mode' in sent else existing.interpolation_mode,
brightness=settings.brightness if 'brightness' in sent else existing.brightness,
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing,
standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval,
state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
)
# Update in store
target_store.update_target(target_id, settings=new_settings)
# Update in manager
try:
manager.update_target_settings(target_id, new_settings)
except ValueError:
pass
return _settings_to_schema(new_settings)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to update target settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/metrics", response_model=TargetMetricsResponse, tags=["Metrics"])
async def get_target_metrics(
target_id: str,

View File

@@ -23,12 +23,18 @@ from .devices import (
DeviceStateResponse,
DeviceUpdate,
)
from .color_strip_sources import (
ColorStripSourceCreate,
ColorStripSourceListResponse,
ColorStripSourceResponse,
ColorStripSourceUpdate,
CSSCalibrationTestRequest,
)
from .picture_targets import (
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
PictureTargetUpdate,
ProcessingSettings,
TargetMetricsResponse,
TargetProcessingState,
)
@@ -89,11 +95,15 @@ __all__ = [
"DeviceResponse",
"DeviceStateResponse",
"DeviceUpdate",
"ColorStripSourceCreate",
"ColorStripSourceListResponse",
"ColorStripSourceResponse",
"ColorStripSourceUpdate",
"CSSCalibrationTestRequest",
"PictureTargetCreate",
"PictureTargetListResponse",
"PictureTargetResponse",
"PictureTargetUpdate",
"ProcessingSettings",
"TargetMetricsResponse",
"TargetProcessingState",
"EngineInfo",

View File

@@ -0,0 +1,80 @@
"""Color strip source schemas (CRUD)."""
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
from wled_controller.api.schemas.devices import Calibration
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"] = Field(default="picture", description="Source type")
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
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)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class ColorStripSourceUpdate(BaseModel):
"""Request to update a color strip source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90)
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")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
class ColorStripSourceResponse(BaseModel):
"""Color strip source response."""
id: str = Field(description="Source ID")
name: str = Field(description="Source name")
source_type: str = Field(description="Source type")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS")
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")
description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
class ColorStripSourceListResponse(BaseModel):
"""List of color strip sources."""
sources: List[ColorStripSourceResponse] = Field(description="List of color strip sources")
count: int = Field(description="Number of sources")
class CSSCalibrationTestRequest(BaseModel):
"""Request to run a calibration test for a color strip source on a specific device."""
device_id: str = Field(description="Device ID to send test pixels to")
edges: dict = Field(
default_factory=lambda: {
"top": [255, 0, 0],
"right": [0, 255, 0],
"bottom": [0, 100, 255],
"left": [255, 255, 0],
},
description="Map of edge names to RGB colors. Empty dict = exit test mode.",
)

View File

@@ -104,7 +104,6 @@ class DeviceResponse(BaseModel):
auto_shutdown: bool = Field(default=False, description="Restore device to idle state when targets stop")
static_color: Optional[List[int]] = Field(None, description="Static idle color [R, G, B]")
capabilities: List[str] = Field(default_factory=list, description="Device type capabilities")
calibration: Optional[Calibration] = Field(None, description="Calibration configuration")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")

View File

@@ -1,26 +1,11 @@
"""Picture target schemas (CRUD, processing state, settings, metrics)."""
"""Picture target schemas (CRUD, processing state, metrics)."""
from datetime import datetime
from typing import Dict, List, Literal, Optional
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
class ProcessingSettings(BaseModel):
"""Processing settings for a picture target."""
display_index: int = Field(default=0, description="Display to capture", ge=0)
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0)
standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0)
state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks"
)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
class KeyColorRectangleSchema(BaseModel):
@@ -65,9 +50,13 @@ class PictureTargetCreate(BaseModel):
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="led", description="Target type (led, key_colors)")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -76,9 +65,13 @@ class PictureTargetUpdate(BaseModel):
"""Request to update a picture target."""
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
device_id: Optional[str] = Field(None, description="WLED device ID")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
# LED target fields
device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
# KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -89,10 +82,14 @@ class PictureTargetResponse(BaseModel):
id: str = Field(description="Target ID")
name: str = Field(description="Target name")
target_type: str = Field(description="Target type")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
standby_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
# KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings")
description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -110,21 +107,18 @@ class TargetProcessingState(BaseModel):
target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
fps_target: int = Field(default=0, description="Target FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
timing_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)")
timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)")
display_index: int = Field(default=0, description="Current display index")
display_index: Optional[int] = Field(None, description="Current display index")
overlay_active: bool = Field(default=False, description="Whether visualization overlay is active")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
@@ -147,7 +141,7 @@ class TargetMetricsResponse(BaseModel):
device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")
fps_target: Optional[int] = Field(None, description="Target FPS")
uptime_seconds: float = Field(description="Processing uptime in seconds")
frames_processed: int = Field(description="Total frames processed")
errors_count: int = Field(description="Total error count")