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

@@ -6,12 +6,6 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from wled_controller.core.capture.calibration import (
CalibrationConfig,
calibration_from_dict,
calibration_to_dict,
create_default_calibration,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -20,8 +14,9 @@ logger = get_logger(__name__)
class Device:
"""Represents a WLED device configuration.
A device is a holder of connection state and calibration options.
Processing settings and picture source assignments live on PictureTargets.
A device holds connection state and output settings.
Calibration, processing settings, and picture source assignments
now live on ColorStripSource and WledPictureTarget respectively.
"""
def __init__(
@@ -36,7 +31,6 @@ class Device:
software_brightness: int = 255,
auto_shutdown: bool = False,
static_color: Optional[Tuple[int, int, int]] = None,
calibration: Optional[CalibrationConfig] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
):
@@ -50,9 +44,10 @@ class Device:
self.software_brightness = software_brightness
self.auto_shutdown = auto_shutdown
self.static_color = static_color
self.calibration = calibration or create_default_calibration(led_count)
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
# Preserved from old JSON for migration — not written back
self._legacy_calibration = None
def to_dict(self) -> dict:
"""Convert device to dictionary."""
@@ -63,7 +58,6 @@ class Device:
"led_count": self.led_count,
"enabled": self.enabled,
"device_type": self.device_type,
"calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@@ -81,20 +75,13 @@ class Device:
def from_dict(cls, data: dict) -> "Device":
"""Create device from dictionary.
Backward-compatible: ignores legacy 'settings' and 'picture_source_id'
fields that have been migrated to PictureTarget.
Backward-compatible: reads legacy 'calibration' field and stores it
in _legacy_calibration for migration use only.
"""
calibration_data = data.get("calibration")
calibration = (
calibration_from_dict(calibration_data)
if calibration_data
else create_default_calibration(data["led_count"])
)
static_color_raw = data.get("static_color")
static_color = tuple(static_color_raw) if static_color_raw else None
return cls(
device = cls(
device_id=data["id"],
name=data["name"],
url=data["url"],
@@ -105,11 +92,21 @@ class Device:
software_brightness=data.get("software_brightness", 255),
auto_shutdown=data.get("auto_shutdown", False),
static_color=static_color,
calibration=calibration,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)
# Preserve old calibration for migration (never written back by to_dict)
calibration_data = data.get("calibration")
if calibration_data:
try:
from wled_controller.core.capture.calibration import calibration_from_dict
device._legacy_calibration = calibration_from_dict(calibration_data)
except Exception:
pass
return device
class DeviceStore:
"""Persistent storage for WLED devices."""
@@ -129,7 +126,7 @@ class DeviceStore:
def load(self):
"""Load devices from storage file."""
if not self.storage_file.exists():
logger.info(f"Storage file does not exist, starting with empty store")
logger.info("Storage file does not exist, starting with empty store")
return
try:
@@ -190,7 +187,6 @@ class DeviceStore:
led_count: int,
device_type: str = "wled",
baud_rate: Optional[int] = None,
calibration: Optional[CalibrationConfig] = None,
auto_shutdown: bool = False,
) -> Device:
"""Create a new device."""
@@ -203,7 +199,6 @@ class DeviceStore:
led_count=led_count,
device_type=device_type,
baud_rate=baud_rate,
calibration=calibration,
auto_shutdown=auto_shutdown,
)
@@ -229,7 +224,6 @@ class DeviceStore:
led_count: Optional[int] = None,
enabled: Optional[bool] = None,
baud_rate: Optional[int] = None,
calibration: Optional[CalibrationConfig] = None,
auto_shutdown: Optional[bool] = None,
) -> Device:
"""Update device."""
@@ -249,13 +243,6 @@ class DeviceStore:
device.baud_rate = baud_rate
if auto_shutdown is not None:
device.auto_shutdown = auto_shutdown
if calibration is not None:
if calibration.get_total_leds() != device.led_count:
raise ValueError(
f"Calibration LED count ({calibration.get_total_leds()}) "
f"does not match device LED count ({device.led_count})"
)
device.calibration = calibration
device.updated_at = datetime.utcnow()
self.save()