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

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