Add multi-segment LED targets, replace single color strip source + skip fields

Each target now has a segments list where each segment maps a color strip
source to a pixel range (start/end) on the device with optional reverse.
This enables composing multiple visualizations on a single LED strip.
Old targets auto-migrate from the single source format on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 12:49:26 +03:00
parent bbd2ac9910
commit 9d593379b8
14 changed files with 593 additions and 368 deletions

View File

@@ -32,6 +32,7 @@ from wled_controller.api.schemas.picture_targets import (
PictureTargetUpdate,
TargetMetricsResponse,
TargetProcessingState,
TargetSegmentSchema,
)
from wled_controller.config import get_config
from wled_controller.core.capture_engines import EngineRegistry
@@ -93,12 +94,18 @@ def _target_to_response(target) -> PictureTargetResponse:
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id,
segments=[
TargetSegmentSchema(
color_strip_source_id=s.color_strip_source_id,
start=s.start,
end=s.end,
reverse=s.reverse,
)
for s in target.segments
],
fps=target.fps,
keepalive_interval=target.keepalive_interval,
state_check_interval=target.state_check_interval,
led_skip_start=target.led_skip_start,
led_skip_end=target.led_skip_end,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -150,12 +157,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,
segments=[s.model_dump() for s in data.segments] if data.segments else None,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
led_skip_start=data.led_skip_start,
led_skip_end=data.led_skip_end,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
description=data.description,
@@ -262,17 +267,15 @@ async def update_target(
kc_settings = _kc_schema_to_settings(data.key_colors_settings)
# Update in store
segments_dicts = [s.model_dump() for s in data.segments] if data.segments is not None else None
target = target_store.update_target(
target_id=target_id,
name=data.name,
device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id,
segments=segments_dicts,
fps=data.fps,
keepalive_interval=data.keepalive_interval,
state_check_interval=data.state_check_interval,
led_skip_start=data.led_skip_start,
led_skip_end=data.led_skip_end,
picture_source_id=data.picture_source_id,
key_colors_settings=kc_settings,
description=data.description,
)
@@ -284,10 +287,8 @@ async def update_target(
settings_changed=(data.fps is not None or
data.keepalive_interval is not None or
data.state_check_interval is not None or
data.led_skip_start is not None or
data.led_skip_end is not None or
data.key_colors_settings is not None),
source_changed=data.color_strip_source_id is not None,
segments_changed=data.segments is not None,
device_changed=data.device_id is not None,
)
except ValueError:
@@ -755,20 +756,23 @@ async def start_target_overlay(
# can start even when processing is not currently running.
calibration = None
display_info = None
if isinstance(target, WledPictureTarget) and target.color_strip_source_id:
try:
css = color_strip_store.get_source(target.color_strip_source_id)
if isinstance(css, PictureColorStripSource) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
display_index = _resolve_display_index(css.picture_source_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
if isinstance(target, WledPictureTarget) and target.segments:
# Use the first segment's CSS for calibration/overlay
first_css_id = target.segments[0].color_strip_source_id
if first_css_id:
try:
css = color_strip_store.get_source(first_css_id)
if isinstance(css, PictureColorStripSource) and css.calibration:
calibration = css.calibration
# Resolve the display this CSS is capturing
from wled_controller.api.routes.color_strip_sources import _resolve_display_index
display_index = _resolve_display_index(css.picture_source_id, picture_source_store)
displays = get_available_displays()
if displays:
display_index = min(display_index, len(displays) - 1)
display_info = displays[display_index]
except Exception as e:
logger.warning(f"Could not pre-load CSS calibration for overlay on {target_id}: {e}")
await manager.start_overlay(target_id, target.name, calibration=calibration, display_info=display_info)
return {"status": "started", "target_id": target_id}

View File

@@ -45,6 +45,15 @@ class KeyColorsResponse(BaseModel):
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class TargetSegmentSchema(BaseModel):
"""A segment mapping a color strip source to a pixel range on the device."""
color_strip_source_id: str = Field(default="", description="Color strip source ID")
start: int = Field(default=0, ge=0, description="Start pixel (inclusive)")
end: int = Field(default=0, ge=0, description="End pixel (exclusive, 0 = auto-fit)")
reverse: bool = Field(default=False, description="Reverse pixel order within segment")
class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
@@ -52,12 +61,10 @@ class PictureTargetCreate(BaseModel):
target_type: str = Field(default="led", description="Target type (led, 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")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
fps: int = Field(default=30, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_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)
led_skip_start: int = Field(default=0, ge=0, description="Number of LEDs at the start to keep black")
led_skip_end: int = Field(default=0, ge=0, description="Number of LEDs at the end to keep black")
# 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)")
@@ -70,12 +77,10 @@ class PictureTargetUpdate(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# 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")
segments: Optional[List[TargetSegmentSchema]] = Field(None, description="LED segments")
fps: Optional[int] = Field(None, ge=1, le=90, description="Target send FPS (1-90)")
keepalive_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)
led_skip_start: Optional[int] = Field(None, ge=0, description="Number of LEDs at the start to keep black")
led_skip_end: Optional[int] = Field(None, ge=0, description="Number of LEDs at the end to keep black")
# 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)")
@@ -90,12 +95,10 @@ class PictureTargetResponse(BaseModel):
target_type: str = Field(description="Target type")
# LED target fields
device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_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)")
led_skip_start: int = Field(default=0, description="LEDs skipped at start")
led_skip_end: int = Field(default=0, description="LEDs skipped at end")
# 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")
@@ -116,7 +119,7 @@ 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")
segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments")
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)")