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

@@ -16,13 +16,13 @@ from wled_controller.api import router
from wled_controller.api.dependencies import init_dependencies
from wled_controller.config import get_config
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.core.processing.processing_settings import ProcessingSettings
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.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
from wled_controller.utils import setup_logging, get_logger
@@ -41,6 +41,7 @@ pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_te
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
color_strip_store = ColorStripStore(config.storage.color_strip_sources_file)
profile_store = ProfileStore(config.storage.profiles_file)
processor_manager = ProcessorManager(
@@ -49,6 +50,7 @@ processor_manager = ProcessorManager(
pp_template_store=pp_template_store,
pattern_template_store=pattern_template_store,
device_store=device_store,
color_strip_store=color_strip_store,
)
@@ -69,22 +71,10 @@ def _migrate_devices_to_targets():
migrated = 0
for device_id, device_data in devices_raw.items():
legacy_source_id = device_data.get("picture_source_id", "")
legacy_settings = device_data.get("settings", {})
if not legacy_source_id and not legacy_settings:
if not legacy_source_id:
continue
# Build ProcessingSettings from legacy data
from wled_controller.core.processing.processing_settings import DEFAULT_STATE_CHECK_INTERVAL
settings = ProcessingSettings(
display_index=legacy_settings.get("display_index", 0),
fps=legacy_settings.get("fps", 30),
brightness=legacy_settings.get("brightness", 1.0),
smoothing=legacy_settings.get("smoothing", 0.3),
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
state_check_interval=legacy_settings.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
device_name = device_data.get("name", device_id)
target_name = f"{device_name} Target"
@@ -93,8 +83,6 @@ def _migrate_devices_to_targets():
name=target_name,
target_type="wled",
device_id=device_id,
picture_source_id=legacy_source_id,
settings=settings,
description=f"Auto-migrated from device {device_name}",
)
migrated += 1
@@ -106,6 +94,65 @@ def _migrate_devices_to_targets():
logger.info(f"Migration complete: created {migrated} picture target(s) from legacy device settings")
def _migrate_targets_to_color_strips():
"""One-time migration: create ColorStripSources from legacy WledPictureTarget data.
For each WledPictureTarget that has a legacy _legacy_picture_source_id (from old JSON)
but no color_strip_source_id, create a ColorStripSource and link it.
"""
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.core.capture.calibration import create_default_calibration
migrated = 0
for target in picture_target_store.get_all_targets():
if not isinstance(target, WledPictureTarget):
continue
if target.color_strip_source_id:
continue # already migrated
if not target._legacy_picture_source_id:
continue # no legacy source to migrate
legacy_settings = target._legacy_settings or {}
# Try to get calibration from device (old location)
device = device_store.get_device(target.device_id) if target.device_id else None
calibration = getattr(device, "_legacy_calibration", None) if device else None
if calibration is None:
calibration = create_default_calibration(0)
css_name = f"{target.name} Strip"
# Ensure unique name
existing_names = {s.name for s in color_strip_store.get_all_sources()}
if css_name in existing_names:
css_name = f"{target.name} Strip (migrated)"
try:
css = color_strip_store.create_source(
name=css_name,
source_type="picture",
picture_source_id=target._legacy_picture_source_id,
fps=legacy_settings.get("fps", 30),
brightness=legacy_settings.get("brightness", 1.0),
smoothing=legacy_settings.get("smoothing", 0.3),
interpolation_mode=legacy_settings.get("interpolation_mode", "average"),
calibration=calibration,
)
# Update target to reference the new CSS
target.color_strip_source_id = css.id
target.standby_interval = legacy_settings.get("standby_interval", 1.0)
target.state_check_interval = legacy_settings.get("state_check_interval", 30)
picture_target_store._save()
migrated += 1
logger.info(f"Migrated target {target.id} -> CSS {css.id} ({css_name})")
except Exception as e:
logger.error(f"Failed to migrate target {target.id} to CSS: {e}")
if migrated > 0:
logger.info(f"CSS migration complete: created {migrated} color strip source(s) from legacy targets")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager.
@@ -137,6 +184,7 @@ async def lifespan(app: FastAPI):
# Run migrations
_migrate_devices_to_targets()
_migrate_targets_to_color_strips()
# Create profile engine (needs processor_manager)
profile_engine = ProfileEngine(profile_store, processor_manager)
@@ -148,6 +196,7 @@ async def lifespan(app: FastAPI):
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,
)
@@ -160,7 +209,6 @@ async def lifespan(app: FastAPI):
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,
software_brightness=device.software_brightness,