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

View File

@@ -45,6 +45,15 @@ class KeyColorsResponse(BaseModel):
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp") 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): class PictureTargetCreate(BaseModel):
"""Request to create a picture target.""" """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)") target_type: str = Field(default="led", description="Target type (led, key_colors)")
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") 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)") 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) 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) 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 # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (for key_colors targets)") 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)") 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) name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
# LED target fields # LED target fields
device_id: Optional[str] = Field(None, description="LED device ID") 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)") 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) 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) 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 # KC target fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID (for key_colors targets)") 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)") 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") target_type: str = Field(description="Target type")
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") 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") fps: Optional[int] = Field(None, description="Target send FPS")
keepalive_interval: float = Field(default=1.0, description="Keepalive interval (s)") 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)") 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 # KC target fields
picture_source_id: str = Field(default="", description="Picture source ID (key_colors)") picture_source_id: str = Field(default="", description="Picture source ID (key_colors)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings") 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") target_id: str = Field(description="Target ID")
device_id: Optional[str] = Field(None, description="Device 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") processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)") fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")

View File

@@ -270,12 +270,10 @@ class ProcessorManager:
self, self,
target_id: str, target_id: str,
device_id: str, device_id: str,
color_strip_source_id: str = "", segments: Optional[list] = None,
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
led_skip_start: int = 0,
led_skip_end: int = 0,
): ):
"""Register a WLED target processor.""" """Register a WLED target processor."""
if target_id in self._processors: if target_id in self._processors:
@@ -286,12 +284,10 @@ class ProcessorManager:
proc = WledTargetProcessor( proc = WledTargetProcessor(
target_id=target_id, target_id=target_id,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, segments=segments,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
ctx=self._build_context(), ctx=self._build_context(),
) )
self._processors[target_id] = proc self._processors[target_id] = proc
@@ -337,10 +333,10 @@ class ProcessorManager:
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
proc.update_source(picture_source_id) proc.update_source(picture_source_id)
def update_target_color_strip_source(self, target_id: str, color_strip_source_id: str): def update_target_segments(self, target_id: str, segments: list):
"""Update the color strip source for a WLED target.""" """Update the segments for a WLED target."""
proc = self._get_processor(target_id) proc = self._get_processor(target_id)
proc.update_color_strip_source(color_strip_source_id) proc.update_segments(segments)
def update_target_device(self, target_id: str, device_id: str): def update_target_device(self, target_id: str, device_id: str):
"""Update the device for a target.""" """Update the device for a target."""

View File

@@ -160,8 +160,8 @@ class TargetProcessor(ABC):
"""Update device association. Raises for targets without devices.""" """Update device association. Raises for targets without devices."""
raise ValueError(f"Target {self._target_id} does not support device assignment") raise ValueError(f"Target {self._target_id} does not support device assignment")
def update_color_strip_source(self, color_strip_source_id: str) -> None: def update_segments(self, segments: list) -> None:
"""Update color strip source. No-op for targets that don't use CSS.""" """Update segments. No-op for targets that don't use segments."""
pass pass
# ----- Device / display info (overridden by device-aware subclasses) ----- # ----- Device / display info (overridden by device-aware subclasses) -----

View File

@@ -1,4 +1,4 @@
"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP.""" """WLED/LED target processor — gets colors from ColorStripStreams, sends via DDP."""
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,7 @@ import asyncio
import collections import collections
import time import time
from datetime import datetime from datetime import datetime
from typing import Optional from typing import List, Optional, Tuple
import numpy as np import numpy as np
@@ -24,46 +24,67 @@ from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__) logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Resolved segment info used inside the processing loop
# ---------------------------------------------------------------------------
def _resolve_segments(segments: List[dict], device_led_count: int) -> List[dict]:
"""Resolve auto-fit segments based on device LED count.
A single segment with ``end == 0`` auto-fits to the full device.
Multiple segments with ``end == 0`` are left as-is (invalid but we
clamp gracefully).
"""
resolved = []
for seg in segments:
css_id = seg.get("color_strip_source_id", "")
start = max(0, seg.get("start", 0))
end = seg.get("end", 0)
reverse = seg.get("reverse", False)
if end <= 0:
end = device_led_count
end = min(end, device_led_count)
start = min(start, end)
resolved.append({"css_id": css_id, "start": start, "end": end, "reverse": reverse})
return resolved
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# WledTargetProcessor # WledTargetProcessor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class WledTargetProcessor(TargetProcessor): class WledTargetProcessor(TargetProcessor):
"""Streams LED colors from a ColorStripStream to a WLED/LED device. """Streams LED colors from one or more ColorStripStreams to a WLED/LED device.
The ColorStripStream handles all capture and color processing. Each segment maps a CSS source to a pixel range on the device.
This processor only applies device software_brightness and sends pixels. Gaps between segments stay black.
""" """
def __init__( def __init__(
self, self,
target_id: str, target_id: str,
device_id: str, device_id: str,
color_strip_source_id: str, segments: Optional[List[dict]] = None,
fps: int, fps: int = 30,
keepalive_interval: float, keepalive_interval: float = 1.0,
state_check_interval: int, state_check_interval: int = 30,
led_skip_start: int = 0,
led_skip_end: int = 0,
ctx: TargetContext = None, ctx: TargetContext = None,
): ):
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._device_id = device_id self._device_id = device_id
self._color_strip_source_id = color_strip_source_id
self._target_fps = fps if fps > 0 else 30 self._target_fps = fps if fps > 0 else 30
self._keepalive_interval = keepalive_interval self._keepalive_interval = keepalive_interval
self._state_check_interval = state_check_interval self._state_check_interval = state_check_interval
self._led_skip_start = max(0, led_skip_start) self._segments = list(segments) if segments else []
self._led_skip_end = max(0, led_skip_end)
# Runtime state (populated on start) # Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None self._led_client: Optional[LEDClient] = None
self._color_strip_stream = None # List of (resolved_seg_dict, stream) tuples — read by the loop
self._segment_streams: List[Tuple[dict, object]] = []
self._device_state_before: Optional[dict] = None self._device_state_before: Optional[dict] = None
self._overlay_active = False self._overlay_active = False
self._needs_keepalive = True # resolved at start from device capabilities self._needs_keepalive = True
# Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None self._resolved_display_index: Optional[int] = None
# ----- Properties ----- # ----- Properties -----
@@ -105,44 +126,57 @@ class WledTargetProcessor(TargetProcessor):
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}") logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}") raise RuntimeError(f"Failed to connect to LED device: {e}")
# Acquire color strip stream # Acquire color strip streams for each segment
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager is None: if css_manager is None:
await self._led_client.close() await self._led_client.close()
self._led_client = None self._led_client = None
raise RuntimeError("Color strip stream manager not available in context") raise RuntimeError("Color strip stream manager not available in context")
if not self._color_strip_source_id: if not self._segments:
await self._led_client.close() await self._led_client.close()
self._led_client = None self._led_client = None
raise RuntimeError(f"Target {self._target_id} has no color strip source assigned") raise RuntimeError(f"Target {self._target_id} has no segments configured")
resolved = _resolve_segments(self._segments, device_info.led_count)
segment_streams: List[Tuple[dict, object]] = []
try: try:
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id, self._target_id) for seg in resolved:
self._color_strip_stream = stream if not seg["css_id"]:
self._resolved_display_index = stream.display_index continue
stream = await asyncio.to_thread(css_manager.acquire, seg["css_id"], self._target_id)
seg_len = seg["end"] - seg["start"]
if hasattr(stream, "configure") and seg_len > 0:
stream.configure(seg_len)
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
segment_streams.append((seg, stream))
# For auto-sized non-picture streams (led_count == 0), size to device LED count # Resolve display index from first stream that has one
if hasattr(stream, "configure") and device_info.led_count > 0: self._resolved_display_index = None
effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end for _, s in segment_streams:
stream.configure(max(1, effective_leds)) di = getattr(s, "display_index", None)
if di is not None:
self._resolved_display_index = di
break
# Notify stream manager of our target FPS so it can adjust capture rate self._segment_streams = segment_streams
css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps
)
seg_desc = ", ".join(f"{s['css_id']}[{s['start']}:{s['end']}]" for s in resolved if s["css_id"])
logger.info( logger.info(
f"Acquired color strip stream for target {self._target_id} " f"Acquired {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}"
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
f"fps={self._target_fps})"
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}") # Release any streams we already acquired
for seg, stream in segment_streams:
try:
css_manager.release(seg["css_id"], self._target_id)
except Exception:
pass
if self._led_client: if self._led_client:
await self._led_client.close() await self._led_client.close()
self._led_client = None self._led_client = None
raise RuntimeError(f"Failed to acquire color strip stream: {e}") raise RuntimeError(f"Failed to acquire segment streams: {e}")
# Reset metrics and start loop # Reset metrics and start loop
self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) self._metrics = ProcessingMetrics(start_time=datetime.utcnow())
@@ -167,8 +201,6 @@ class WledTargetProcessor(TargetProcessor):
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
self._task = None self._task = None
# Allow any in-flight thread pool serial write to complete before
# close() sends the black frame (to_thread keeps running after cancel)
await asyncio.sleep(0.05) await asyncio.sleep(0.05)
# Restore device state # Restore device state
@@ -181,16 +213,16 @@ class WledTargetProcessor(TargetProcessor):
await self._led_client.close() await self._led_client.close()
self._led_client = None self._led_client = None
# Release color strip stream # Release all segment streams
if self._color_strip_stream is not None: css_manager = self._ctx.color_strip_stream_manager
css_manager = self._ctx.color_strip_stream_manager if css_manager:
if css_manager and self._color_strip_source_id: for seg, stream in self._segment_streams:
try: try:
css_manager.remove_target_fps(self._color_strip_source_id, self._target_id) css_manager.remove_target_fps(seg["css_id"], self._target_id)
await asyncio.to_thread(css_manager.release, self._color_strip_source_id, self._target_id) await asyncio.to_thread(css_manager.release, seg["css_id"], self._target_id)
except Exception as e: except Exception as e:
logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}") logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}")
self._color_strip_stream = None self._segment_streams = []
logger.info(f"Stopped processing for target {self._target_id}") logger.info(f"Stopped processing for target {self._target_id}")
self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False})
@@ -202,56 +234,70 @@ class WledTargetProcessor(TargetProcessor):
if isinstance(settings, dict): if isinstance(settings, dict):
if "fps" in settings: if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30 self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
# Notify stream manager so capture rate adjusts to max of all consumers
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id and self._is_running: if css_manager and self._is_running:
css_manager.notify_target_fps( for seg, _ in self._segment_streams:
self._color_strip_source_id, self._target_id, self._target_fps css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
)
if "keepalive_interval" in settings: if "keepalive_interval" in settings:
self._keepalive_interval = settings["keepalive_interval"] self._keepalive_interval = settings["keepalive_interval"]
if "state_check_interval" in settings: if "state_check_interval" in settings:
self._state_check_interval = settings["state_check_interval"] self._state_check_interval = settings["state_check_interval"]
if "led_skip_start" in settings:
self._led_skip_start = max(0, settings["led_skip_start"])
if "led_skip_end" in settings:
self._led_skip_end = max(0, settings["led_skip_end"])
logger.info(f"Updated settings for target {self._target_id}") logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None: def update_device(self, device_id: str) -> None:
"""Update the device this target streams to.""" """Update the device this target streams to."""
self._device_id = device_id self._device_id = device_id
def update_color_strip_source(self, color_strip_source_id: str) -> None: def update_segments(self, new_segments: List[dict]) -> None:
"""Hot-swap the color strip source for a running target.""" """Hot-swap all segments for a running target."""
if not self._is_running or self._color_strip_source_id == color_strip_source_id: self._segments = list(new_segments)
self._color_strip_source_id = color_strip_source_id
if not self._is_running:
return return
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager is None: if css_manager is None:
self._color_strip_source_id = color_strip_source_id
return return
old_id = self._color_strip_source_id device_info = self._ctx.get_device_info(self._device_id)
try: device_leds = device_info.led_count if device_info else 0
new_stream = css_manager.acquire(color_strip_source_id, self._target_id)
css_manager.remove_target_fps(old_id, self._target_id) # Release old streams
css_manager.release(old_id, self._target_id) for seg, stream in self._segment_streams:
self._color_strip_stream = new_stream try:
self._resolved_display_index = new_stream.display_index css_manager.remove_target_fps(seg["css_id"], self._target_id)
self._color_strip_source_id = color_strip_source_id css_manager.release(seg["css_id"], self._target_id)
css_manager.notify_target_fps(color_strip_source_id, self._target_id, self._target_fps) except Exception as e:
logger.info(f"Swapped color strip source for {self._target_id}: {old_id} {color_strip_source_id}") logger.warning(f"Error releasing segment {seg['css_id']}: {e}")
except Exception as e:
logger.error(f"Failed to swap color strip source for {self._target_id}: {e}") # Acquire new streams
resolved = _resolve_segments(new_segments, device_leds)
new_stream_list: List[Tuple[dict, object]] = []
for seg in resolved:
if not seg["css_id"]:
continue
try:
stream = css_manager.acquire(seg["css_id"], self._target_id)
seg_len = seg["end"] - seg["start"]
if hasattr(stream, "configure") and seg_len > 0:
stream.configure(seg_len)
css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps)
new_stream_list.append((seg, stream))
except Exception as e:
logger.error(f"Failed to acquire segment {seg['css_id']}: {e}")
# Atomic swap — the processing loop re-reads this reference each tick
self._segment_streams = new_stream_list
logger.info(f"Hot-swapped segments for {self._target_id}: {len(new_stream_list)} segment(s)")
def get_display_index(self) -> Optional[int]: def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream.""" """Display index being captured, from the first active stream."""
if self._resolved_display_index is not None: if self._resolved_display_index is not None:
return self._resolved_display_index return self._resolved_display_index
if self._color_strip_stream is not None: for _, stream in self._segment_streams:
return self._color_strip_stream.display_index di = getattr(stream, "display_index", None)
if di is not None:
return di
return None return None
# ----- State / Metrics ----- # ----- State / Metrics -----
@@ -260,10 +306,9 @@ class WledTargetProcessor(TargetProcessor):
metrics = self._metrics metrics = self._metrics
fps_target = self._target_fps fps_target = self._target_fps
# Pull per-stage timing from the CSS stream (runs in a background thread)
css_timing: dict = {} css_timing: dict = {}
if self._is_running and self._color_strip_stream is not None: if self._is_running and self._segment_streams:
css_timing = self._color_strip_stream.get_last_timing() css_timing = self._segment_streams[0][1].get_last_timing()
send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None
extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None
@@ -272,15 +317,23 @@ class WledTargetProcessor(TargetProcessor):
if css_timing: if css_timing:
total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1) total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1)
elif self._is_running and send_ms is not None: elif self._is_running and send_ms is not None:
# Non-picture sources have no CSS pipeline timing — total = send only
total_ms = send_ms total_ms = send_ms
else: else:
total_ms = None total_ms = None
# Serialize segments for the dashboard
segments_info = [
{"color_strip_source_id": seg["css_id"], "start": seg["start"],
"end": seg["end"], "reverse": seg.get("reverse", False)}
for seg, _ in self._segment_streams
] if self._segment_streams else [
s for s in self._segments
]
return { return {
"target_id": self._target_id, "target_id": self._target_id,
"device_id": self._device_id, "device_id": self._device_id,
"color_strip_source_id": self._color_strip_source_id, "segments": segments_info,
"processing": self._is_running, "processing": self._is_running,
"fps_actual": metrics.fps_actual if self._is_running else None, "fps_actual": metrics.fps_actual if self._is_running else None,
"fps_potential": metrics.fps_potential if self._is_running else None, "fps_potential": metrics.fps_potential if self._is_running else None,
@@ -296,8 +349,6 @@ class WledTargetProcessor(TargetProcessor):
"display_index": self._resolved_display_index, "display_index": self._resolved_display_index,
"overlay_active": self._overlay_active, "overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive, "needs_keepalive": self._needs_keepalive,
"led_skip_start": self._led_skip_start,
"led_skip_end": self._led_skip_end,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "errors": [metrics.last_error] if metrics.last_error else [],
} }
@@ -332,28 +383,30 @@ class WledTargetProcessor(TargetProcessor):
raise RuntimeError(f"Overlay already active for {self._target_id}") raise RuntimeError(f"Overlay already active for {self._target_id}")
if calibration is None or display_info is None: if calibration is None or display_info is None:
# Calibration comes from the active color strip stream # Find calibration from the first picture stream
if self._color_strip_stream is None: stream_with_cal = None
for _, s in self._segment_streams:
if hasattr(s, "calibration") and s.calibration:
stream_with_cal = s
break
if stream_with_cal is None:
raise ValueError( raise ValueError(
f"Cannot start overlay for {self._target_id}: no color strip stream active " f"Cannot start overlay for {self._target_id}: no stream with calibration"
f"and no calibration provided."
) )
if calibration is None: if calibration is None:
calibration = self._color_strip_stream.calibration calibration = stream_with_cal.calibration
if display_info is None: if display_info is None:
display_index = self._resolved_display_index display_index = self._resolved_display_index
if display_index is None: if display_index is None:
display_index = self._color_strip_stream.display_index display_index = getattr(stream_with_cal, "display_index", None)
if display_index is None or display_index < 0: if display_index is None or display_index < 0:
raise ValueError(f"Invalid display index {display_index} for overlay") raise ValueError(f"Invalid display index {display_index} for overlay")
displays = get_available_displays() displays = get_available_displays()
if display_index >= len(displays): if display_index >= len(displays):
raise ValueError(f"Invalid display index {display_index}") raise ValueError(f"Invalid display index {display_index}")
display_info = displays[display_index] display_info = displays[display_index]
await asyncio.to_thread( await asyncio.to_thread(
@@ -391,16 +444,10 @@ class WledTargetProcessor(TargetProcessor):
@staticmethod @staticmethod
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray: def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
"""Resample colors to match the target device LED count. """Resample colors to match the target LED count."""
Uses linear interpolation so gradients look correct regardless of
source/target LED count mismatch (shared streams may be sized to a
different consumer's LED count).
"""
n = len(colors) n = len(colors)
if n == device_led_count or device_led_count <= 0: if n == device_led_count or device_led_count <= 0:
return colors return colors
# Linear interpolation — preserves gradient appearance at any size
src_x = np.linspace(0, 1, n) src_x = np.linspace(0, 1, n)
dst_x = np.linspace(0, 1, device_led_count) dst_x = np.linspace(0, 1, device_led_count)
result = np.column_stack([ result = np.column_stack([
@@ -409,62 +456,26 @@ class WledTargetProcessor(TargetProcessor):
]) ])
return result return result
@staticmethod
def _apply_led_skip(colors: np.ndarray, buf: Optional[np.ndarray], skip_start: int) -> np.ndarray:
"""Copy effective colors into pre-allocated buffer with black padding.
Args:
colors: Effective LED colors (skip-excluded)
buf: Pre-allocated (device_led_count, 3) buffer with black edges,
or None when no skip is configured.
skip_start: Number of black LEDs at the start (write offset)
"""
if buf is None:
return colors
buf[skip_start:skip_start + len(colors)] = colors
return buf
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main processing loop — poll ColorStripStream → apply brightness → send.""" """Main processing loop — poll segment streamscompose → brightness → send."""
stream = self._color_strip_stream
keepalive_interval = self._keepalive_interval keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10) fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque() send_timestamps: collections.deque = collections.deque()
prev_colors = None
last_send_time = 0.0 last_send_time = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
_init_device_info = self._ctx.get_device_info(self._device_id) _init_device_info = self._ctx.get_device_info(self._device_id)
_total_leds = _init_device_info.led_count if _init_device_info else 0 _total_leds = _init_device_info.led_count if _init_device_info else 0
effective_leds = max(1, _total_leds - self._led_skip_start - self._led_skip_end)
# Pre-allocate skip buffer (reused every frame — edges stay black) # Device-sized output buffer (persistent between frames; gaps stay black)
if (self._led_skip_start > 0 or self._led_skip_end > 0) and _total_leds > 0: device_buf = np.zeros((_total_leds, 3), dtype=np.uint8)
_skip_buf: Optional[np.ndarray] = np.zeros((_total_leds, 3), dtype=np.uint8)
else:
_skip_buf = None
# Pre-allocate resampling cache (linspace + result reused while sizes unchanged) # Segment stream references — re-read each tick to detect hot-swaps
_fit_key = (0, 0) segment_streams = self._segment_streams
_fit_src_x = _fit_dst_x = _fit_result = None # Per-stream identity tracking for "same frame" detection
prev_refs: list = [None] * len(segment_streams)
def _cached_fit(colors_in): has_any_frame = False
"""Resample colors to effective_leds using cached linspace arrays."""
nonlocal _fit_key, _fit_src_x, _fit_dst_x, _fit_result
n_src = len(colors_in)
if n_src == effective_leds or effective_leds <= 0:
return colors_in
if (n_src, effective_leds) != _fit_key:
_fit_key = (n_src, effective_leds)
_fit_src_x = np.linspace(0, 1, n_src)
_fit_dst_x = np.linspace(0, 1, effective_leds)
_fit_result = np.empty((effective_leds, 3), dtype=np.uint8)
for _ch in range(3):
np.copyto(_fit_result[:, _ch],
np.interp(_fit_dst_x, _fit_src_x, colors_in[:, _ch]),
casting='unsafe')
return _fit_result
# Pre-allocate brightness scratch (uint16 intermediate + uint8 output) # Pre-allocate brightness scratch (uint16 intermediate + uint8 output)
_bright_u16: Optional[np.ndarray] = None _bright_u16: Optional[np.ndarray] = None
@@ -472,7 +483,6 @@ class WledTargetProcessor(TargetProcessor):
_bright_n = 0 _bright_n = 0
def _cached_brightness(colors_in, dev_info): def _cached_brightness(colors_in, dev_info):
"""Apply software brightness using pre-allocated uint16 scratch."""
nonlocal _bright_n, _bright_u16, _bright_out nonlocal _bright_n, _bright_u16, _bright_out
if not dev_info or dev_info.software_brightness >= 255: if not dev_info or dev_info.software_brightness >= 255:
return colors_in return colors_in
@@ -487,24 +497,20 @@ class WledTargetProcessor(TargetProcessor):
np.copyto(_bright_out, _bright_u16, casting='unsafe') np.copyto(_bright_out, _bright_u16, casting='unsafe')
return _bright_out return _bright_out
# Short re-poll interval when the animation thread hasn't produced a new
# frame yet. The animation thread and this loop both target the same FPS
# but are unsynchronised; without a short re-poll the loop can miss a
# frame and wait a full frame_time, periodically halving the send rate.
SKIP_REPOLL = 0.005 # 5 ms SKIP_REPOLL = 0.005 # 5 ms
# --- Timing diagnostics --- # --- Timing diagnostics ---
_diag_interval = 5.0 # report every 5 seconds _diag_interval = 5.0
_diag_next_report = time.perf_counter() + _diag_interval _diag_next_report = time.perf_counter() + _diag_interval
_diag_sleep_jitters: list = [] # (requested_ms, actual_ms) _diag_sleep_jitters: list = []
_diag_slow_iters: list = [] # (iter_ms, phase) _diag_slow_iters: list = []
_diag_iter_times: list = [] # total iter durations in ms _diag_iter_times: list = []
_diag_device_info: Optional[DeviceInfo] = None _diag_device_info: Optional[DeviceInfo] = None
_diag_device_info_age = 0 # iterations since last refresh _diag_device_info_age = 0
logger.info( logger.info(
f"Processing loop started for target {self._target_id} " f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={self._target_fps})" f"({len(segment_streams)} segments, {_total_leds} LEDs, fps={self._target_fps})"
) )
next_frame_time = time.perf_counter() next_frame_time = time.perf_counter()
@@ -513,14 +519,18 @@ class WledTargetProcessor(TargetProcessor):
with high_resolution_timer(): with high_resolution_timer():
while self._is_running: while self._is_running:
loop_start = now = time.perf_counter() loop_start = now = time.perf_counter()
# Re-read target_fps each tick so hot-updates take effect immediately
target_fps = self._target_fps if self._target_fps > 0 else 30 target_fps = self._target_fps if self._target_fps > 0 else 30
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
keepalive_interval = self._keepalive_interval
# Detect hot-swapped segments
cur_streams = self._segment_streams
if cur_streams is not segment_streams:
segment_streams = cur_streams
prev_refs = [None] * len(segment_streams)
has_any_frame = False
device_buf[:] = 0
# Re-fetch device info every ~30 iterations instead of every
# iteration (it's just a dict lookup but creates a new
# namedtuple each time, and we poll at ~200 iter/sec with
# SKIP_REPOLL).
_diag_device_info_age += 1 _diag_device_info_age += 1
if _diag_device_info is None or _diag_device_info_age >= 30: if _diag_device_info is None or _diag_device_info_age >= 30:
_diag_device_info = self._ctx.get_device_info(self._device_id) _diag_device_info = self._ctx.get_device_info(self._device_id)
@@ -532,25 +542,34 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue continue
try: if not segment_streams:
colors = stream.get_latest_colors() await asyncio.sleep(frame_time)
continue
if colors is None: try:
# Poll all segment streams
any_new = False
all_none = True
for i, (seg, stream) in enumerate(segment_streams):
frame = stream.get_latest_colors()
if frame is not prev_refs[i]:
any_new = True
prev_refs[i] = frame
if frame is not None:
all_none = False
if all_none:
if self._metrics.frames_processed == 0: if self._metrics.frames_processed == 0:
logger.info(f"Stream returned None for target {self._target_id} (no data yet)") logger.info(f"No data from any segment stream for {self._target_id}")
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue continue
if colors is prev_colors: if not any_new:
# Same frame — send keepalive if interval elapsed (only for devices that need it) # All streams returned same frame — keepalive or skip
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= keepalive_interval: if self._needs_keepalive and has_any_frame and (loop_start - last_send_time) >= keepalive_interval:
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
break break
kc = prev_colors send_colors = _cached_brightness(device_buf, device_info)
if device_info and device_info.led_count > 0:
kc = _cached_fit(kc)
kc = self._apply_led_skip(kc, _skip_buf, self._led_skip_start)
send_colors = _cached_brightness(kc, device_info)
if self._led_client.supports_fast_send: if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
else: else:
@@ -563,19 +582,31 @@ class WledTargetProcessor(TargetProcessor):
while send_timestamps and send_timestamps[0] < now - 1.0: while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft() send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps) self._metrics.fps_current = len(send_timestamps)
repoll = SKIP_REPOLL if stream.is_animated else frame_time is_animated = any(s.is_animated for _, s in segment_streams)
repoll = SKIP_REPOLL if is_animated else frame_time
await asyncio.sleep(repoll) await asyncio.sleep(repoll)
continue continue
prev_colors = colors has_any_frame = True
# Fit to effective LED count (excluding skipped) then pad with blacks # Compose new frame from all segments
if device_info and device_info.led_count > 0: device_buf[:] = 0
colors = _cached_fit(colors) for i, (seg, stream) in enumerate(segment_streams):
colors = self._apply_led_skip(colors, _skip_buf, self._led_skip_start) frame = prev_refs[i]
if frame is None:
continue
seg_start, seg_end = seg["start"], seg["end"]
seg_len = seg_end - seg_start
if seg_len <= 0:
continue
if len(frame) != seg_len:
frame = self._fit_to_device(frame, seg_len)
if seg.get("reverse"):
frame = frame[::-1]
device_buf[seg_start:seg_end] = frame
# Apply device software brightness # Apply device software brightness
send_colors = _cached_brightness(colors, device_info) send_colors = _cached_brightness(device_buf, device_info)
# Send to LED device # Send to LED device
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:
@@ -601,7 +632,7 @@ class WledTargetProcessor(TargetProcessor):
f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms" f"({len(send_colors)} LEDs) — send={send_ms:.1f}ms"
) )
# FPS tracking (skip first sample — interval from loop init is near-zero) # FPS tracking
interval = now - prev_frame_time_stamp interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now prev_frame_time_stamp = now
if self._metrics.frames_processed > 1: if self._metrics.frames_processed > 1:
@@ -620,9 +651,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.last_error = str(e) self._metrics.last_error = str(e)
logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True) logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True)
# Drift-compensating throttle: sleep until the absolute # Drift-compensating throttle
# next_frame_time so overshoots in one frame are recovered
# in the next, keeping average FPS on target.
next_frame_time += frame_time next_frame_time += frame_time
sleep_time = next_frame_time - time.perf_counter() sleep_time = next_frame_time - time.perf_counter()
if sleep_time > 0: if sleep_time > 0:
@@ -633,17 +662,16 @@ class WledTargetProcessor(TargetProcessor):
requested_sleep = sleep_time * 1000 requested_sleep = sleep_time * 1000
jitter = actual_sleep - requested_sleep jitter = actual_sleep - requested_sleep
_diag_sleep_jitters.append((requested_sleep, actual_sleep)) _diag_sleep_jitters.append((requested_sleep, actual_sleep))
if jitter > 10.0: # >10ms overshoot if jitter > 10.0:
_diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter")) _diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter"))
elif sleep_time < -frame_time: elif sleep_time < -frame_time:
# Too far behind — reset to avoid burst catch-up
next_frame_time = time.perf_counter() next_frame_time = time.perf_counter()
# Track total iteration time # Track total iteration time
iter_end = time.perf_counter() iter_end = time.perf_counter()
iter_ms = (iter_end - loop_start) * 1000 iter_ms = (iter_end - loop_start) * 1000
_diag_iter_times.append(iter_ms) _diag_iter_times.append(iter_ms)
if iter_ms > frame_time * 1500: # > 1.5x frame time in ms if iter_ms > frame_time * 1500:
if "sleep_jitter" not in [s[1] for s in _diag_slow_iters[-1:]]: if "sleep_jitter" not in [s[1] for s in _diag_slow_iters[-1:]]:
_diag_slow_iters.append((iter_ms, "slow_iter")) _diag_slow_iters.append((iter_ms, "slow_iter"))

View File

@@ -231,6 +231,92 @@
width: 100%; width: 100%;
} }
/* Segment rows in target editor */
.segment-row {
border: 1px solid var(--border-color, #333);
border-radius: 6px;
padding: 8px 10px;
margin-bottom: 6px;
background: var(--card-bg, #1e1e1e);
}
.segment-row-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.segment-index-label {
font-size: 0.8rem;
font-weight: 600;
color: #888;
}
.btn-icon-inline {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 0 4px;
line-height: 1;
}
.btn-danger-text {
color: var(--danger-color, #f44336);
}
.btn-danger-text:hover {
color: #ff6659;
}
.segment-row-fields {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.segment-row-fields select {
flex: 1 1 100%;
}
.segment-range-fields {
display: flex;
gap: 6px;
align-items: center;
flex: 1;
}
.segment-range-fields label {
font-size: 0.82rem;
color: #aaa;
white-space: nowrap;
}
.segment-range-fields input[type="number"] {
width: 70px;
}
.segment-reverse-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.85rem;
color: #aaa;
cursor: pointer;
white-space: nowrap;
}
.segment-reverse-label input[type="checkbox"] {
margin: 0;
}
.btn-sm {
font-size: 0.85rem;
padding: 4px 10px;
}
.fps-hint { .fps-hint {
display: block; display: block;
margin-top: 4px; margin-top: 4px;

View File

@@ -82,6 +82,7 @@ import {
import { import {
loadTargetsTab, loadTargets, switchTargetSubTab, loadTargetsTab, loadTargets, switchTargetSubTab,
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
addTargetSegment, removeTargetSegment,
startTargetProcessing, stopTargetProcessing, startTargetProcessing, stopTargetProcessing,
startTargetOverlay, stopTargetOverlay, deleteTarget, startTargetOverlay, stopTargetOverlay, deleteTarget,
} from './features/targets.js'; } from './features/targets.js';
@@ -265,6 +266,8 @@ Object.assign(window, {
closeTargetEditorModal, closeTargetEditorModal,
forceCloseTargetEditorModal, forceCloseTargetEditorModal,
saveTargetEditor, saveTargetEditor,
addTargetSegment,
removeTargetSegment,
startTargetProcessing, startTargetProcessing,
stopTargetProcessing, stopTargetProcessing,
startTargetOverlay, startTargetOverlay,

View File

@@ -447,9 +447,15 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
if (device) { if (device) {
subtitleParts.push((device.device_type || '').toUpperCase()); subtitleParts.push((device.device_type || '').toUpperCase());
} }
const cssSource = target.color_strip_source_id ? cssSourceMap[target.color_strip_source_id] : null; const segments = target.segments || [];
if (cssSource) { if (segments.length > 0) {
subtitleParts.push(t(`color_strip.type.${cssSource.source_type}`) || cssSource.source_type); const firstCss = cssSourceMap[segments[0].color_strip_source_id];
if (firstCss) {
subtitleParts.push(t(`color_strip.type.${firstCss.source_type}`) || firstCss.source_type);
}
if (segments.length > 1) {
subtitleParts.push(`${segments.length} seg`);
}
} }
} }

View File

@@ -74,6 +74,23 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
}); });
} }
// --- Segment editor state ---
let _editorCssSources = []; // populated when editor opens
function _serializeSegments() {
const rows = document.querySelectorAll('.segment-row');
const segments = [];
rows.forEach(row => {
segments.push({
css: row.querySelector('.segment-css-select').value,
start: row.querySelector('.segment-start').value,
end: row.querySelector('.segment-end').value,
reverse: row.querySelector('.segment-reverse').checked,
});
});
return JSON.stringify(segments);
}
class TargetEditorModal extends Modal { class TargetEditorModal extends Modal {
constructor() { constructor() {
super('target-editor-modal'); super('target-editor-modal');
@@ -83,11 +100,9 @@ class TargetEditorModal extends Modal {
return { return {
name: document.getElementById('target-editor-name').value, name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value, segments: _serializeSegments(),
fps: document.getElementById('target-editor-fps').value, fps: document.getElementById('target-editor-fps').value,
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
led_skip_start: document.getElementById('target-editor-skip-start').value,
led_skip_end: document.getElementById('target-editor-skip-end').value,
}; };
} }
} }
@@ -100,9 +115,10 @@ function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return; if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return; if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device'); const deviceSelect = document.getElementById('target-editor-device');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || ''; // Use first segment's CSS name
const firstCssSelect = document.querySelector('.segment-css-select');
const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return; if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
} }
@@ -132,6 +148,56 @@ function _updateKeepaliveVisibility() {
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none'; keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
} }
export function addTargetSegment(segment = null) {
const list = document.getElementById('target-editor-segment-list');
const index = list.querySelectorAll('.segment-row').length;
const row = document.createElement('div');
row.className = 'segment-row';
row.innerHTML = _renderSegmentRowInner(index, segment);
list.appendChild(row);
}
export function removeTargetSegment(btn) {
const row = btn.closest('.segment-row');
row.remove();
// Re-index labels
document.querySelectorAll('.segment-row').forEach((r, i) => {
const label = r.querySelector('.segment-index-label');
if (label) label.textContent = `#${i + 1}`;
});
}
function _renderSegmentRowInner(index, segment) {
const cssId = segment?.color_strip_source_id || '';
const start = segment?.start ?? 0;
const end = segment?.end ?? 0;
const reverse = segment?.reverse || false;
const options = _editorCssSources.map(s =>
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === cssId ? 'selected' : ''}>\uD83C\uDFAC ${escapeHtml(s.name)}</option>`
).join('');
return `
<div class="segment-row-header">
<span class="segment-index-label">#${index + 1}</span>
<button type="button" class="btn-icon-inline btn-danger-text" onclick="removeTargetSegment(this)" title="${t('targets.segment.remove')}">&times;</button>
</div>
<div class="segment-row-fields">
<select class="segment-css-select" onchange="window._targetAutoName && window._targetAutoName()">${options}</select>
<div class="segment-range-fields">
<label>${t('targets.segment.start')}</label>
<input type="number" class="segment-start" min="0" value="${start}" placeholder="0">
<label>${t('targets.segment.end')}</label>
<input type="number" class="segment-end" min="0" value="${end}" placeholder="0 = auto">
</div>
<label class="segment-reverse-label">
<input type="checkbox" class="segment-reverse" ${reverse ? 'checked' : ''}>
<span>${t('targets.segment.reverse')}</span>
</label>
</div>
`;
}
export async function showTargetEditor(targetId = null) { export async function showTargetEditor(targetId = null) {
try { try {
// Load devices and CSS sources for dropdowns // Load devices and CSS sources for dropdowns
@@ -143,6 +209,7 @@ export async function showTargetEditor(targetId = null) {
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : []; const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices); set_targetEditorDevices(devices);
_editorCssSources = cssSources;
// Populate device select // Populate device select
const deviceSelect = document.getElementById('target-editor-device'); const deviceSelect = document.getElementById('target-editor-device');
@@ -157,16 +224,9 @@ export async function showTargetEditor(targetId = null) {
deviceSelect.appendChild(opt); deviceSelect.appendChild(opt);
}); });
// Populate color strip source select // Clear segment list
const cssSelect = document.getElementById('target-editor-css'); const segmentList = document.getElementById('target-editor-segment-list');
cssSelect.innerHTML = ''; segmentList.innerHTML = '';
cssSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
if (targetId) { if (targetId) {
// Editing existing target // Editing existing target
@@ -177,33 +237,37 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-id').value = target.id; document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name; document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || ''; deviceSelect.value = target.device_id || '';
cssSelect.value = target.color_strip_source_id || '';
const fps = target.fps ?? 30; const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps; document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval').value = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0; document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
document.getElementById('target-editor-skip-start').value = target.led_skip_start ?? 0;
document.getElementById('target-editor-skip-end').value = target.led_skip_end ?? 0;
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
// Populate segments
const segments = target.segments || [];
if (segments.length === 0) {
addTargetSegment();
} else {
segments.forEach(seg => addTargetSegment(seg));
}
} else { } else {
// Creating new target — first option is selected by default // Creating new target — start with one empty segment
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = ''; document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30; document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-keepalive-interval').value = 1.0; document.getElementById('target-editor-keepalive-interval').value = 1.0;
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0'; document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
document.getElementById('target-editor-skip-start').value = 0;
document.getElementById('target-editor-skip-end').value = 0;
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
addTargetSegment();
} }
// Auto-name generation // Auto-name generation
_targetNameManuallyEdited = !!targetId; _targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
window._targetAutoName = _autoGenerateTargetName;
deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities // Show/hide standby interval based on selected device capabilities
@@ -237,10 +301,7 @@ export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value; const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim(); const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value; const deviceId = document.getElementById('target-editor-device').value;
const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value); const standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
const ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0;
const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0;
if (!name) { if (!name) {
targetEditorModal.showError(t('targets.error.name_required')); targetEditorModal.showError(t('targets.error.name_required'));
@@ -249,14 +310,28 @@ export async function saveTargetEditor() {
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
// Collect segments from DOM
const segmentRows = document.querySelectorAll('.segment-row');
const segments = [];
for (const row of segmentRows) {
const cssId = row.querySelector('.segment-css-select').value;
const start = parseInt(row.querySelector('.segment-start').value) || 0;
const end = parseInt(row.querySelector('.segment-end').value) || 0;
const reverse = row.querySelector('.segment-reverse').checked;
segments.push({
color_strip_source_id: cssId,
start,
end,
reverse,
});
}
const payload = { const payload = {
name, name,
device_id: deviceId, device_id: deviceId,
color_strip_source_id: cssId, segments,
fps, fps,
keepalive_interval: standbyInterval, keepalive_interval: standbyInterval,
led_skip_start: ledSkipStart,
led_skip_end: ledSkipEnd,
}; };
try { try {
@@ -535,6 +610,19 @@ export async function loadTargetsTab() {
} }
} }
function _segmentsSummary(segments, colorStripSourceMap) {
if (!segments || segments.length === 0) return t('targets.no_segments');
return segments.map(seg => {
const css = colorStripSourceMap[seg.color_strip_source_id];
const name = css ? css.name : (seg.color_strip_source_id || '?');
let range = '';
if (seg.start || seg.end) {
range = ` [${seg.start}-${seg.end || '\u221e'}]`;
}
return escapeHtml(name) + range;
}).join(', ');
}
export function createTargetCard(target, deviceMap, colorStripSourceMap) { export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {}; const state = target.state || {};
const metrics = target.metrics || {}; const metrics = target.metrics || {};
@@ -542,9 +630,15 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const isProcessing = state.processing || false; const isProcessing = state.processing || false;
const device = deviceMap[target.device_id]; const device = deviceMap[target.device_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device'); const deviceName = device ? device.name : (target.device_id || 'No device');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
const segments = target.segments || [];
const segSummary = _segmentsSummary(segments, colorStripSourceMap);
// Determine if overlay is available (first segment has a picture-based CSS)
const firstCssId = segments.length > 0 ? segments[0].color_strip_source_id : '';
const firstCss = firstCssId ? colorStripSourceMap[firstCssId] : null;
const overlayAvailable = !firstCss || firstCss.source_type === 'picture';
// Health info from target state (forwarded from device) // Health info from target state (forwarded from device)
const devOnline = state.device_online || false; const devOnline = state.device_online || false;
@@ -568,7 +662,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span> <span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</span> <span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span> <span class="stream-card-prop stream-card-prop-full" title="${t('targets.segments')}">🎞️ ${segSummary}</span>
</div> </div>
<div class="card-content"> <div class="card-content">
${isProcessing ? ` ${isProcessing ? `
@@ -631,7 +725,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}"> <button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️ ✏️
</button> </button>
${(!css || css.source_type === 'picture') ? (state.overlay_active ? ` ${overlayAvailable ? (state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}"> <button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
👁️ 👁️
</button> </button>

View File

@@ -356,8 +356,14 @@
"targets.device": "Device:", "targets.device": "Device:",
"targets.device.hint": "Select the LED device to send data to", "targets.device.hint": "Select the LED device to send data to",
"targets.device.none": "-- Select a device --", "targets.device.none": "-- Select a device --",
"targets.color_strip_source": "Color Strip Source:", "targets.segments": "Segments:",
"targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors", "targets.segments.hint": "Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.",
"targets.segments.add": "+ Add Segment",
"targets.segment.start": "Start:",
"targets.segment.end": "End:",
"targets.segment.reverse": "Reverse",
"targets.segment.remove": "Remove segment",
"targets.no_segments": "No segments",
"targets.source": "Source:", "targets.source": "Source:",
"targets.source.hint": "Which picture source to capture and process", "targets.source.hint": "Which picture source to capture and process",
"targets.source.none": "-- No source assigned --", "targets.source.none": "-- No source assigned --",
@@ -373,10 +379,6 @@
"targets.interpolation.dominant": "Dominant", "targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:", "targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
"targets.led_skip": "LED Skip:",
"targets.led_skip.hint": "Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.",
"targets.led_skip_start": "Start:",
"targets.led_skip_end": "End:",
"targets.keepalive_interval": "Keep Alive Interval:", "targets.keepalive_interval": "Keep Alive Interval:",
"targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)", "targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)",
"targets.created": "Target created successfully", "targets.created": "Target created successfully",

View File

@@ -356,8 +356,14 @@
"targets.device": "Устройство:", "targets.device": "Устройство:",
"targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.hint": "Выберите LED устройство для передачи данных",
"targets.device.none": "-- Выберите устройство --", "targets.device.none": "-- Выберите устройство --",
"targets.color_strip_source": "Источник цветовой полосы:", "targets.segments": "Сегменты:",
"targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов", "targets.segments.hint": "Каждый сегмент отображает источник цветовой полосы на диапазон пикселей LED ленты. Промежутки между сегментами остаются чёрными. Один сегмент с Начало=0, Конец=0 авто-подгоняется под всю ленту.",
"targets.segments.add": "+ Добавить сегмент",
"targets.segment.start": "Начало:",
"targets.segment.end": "Конец:",
"targets.segment.reverse": "Реверс",
"targets.segment.remove": "Удалить сегмент",
"targets.no_segments": "Нет сегментов",
"targets.source": "Источник:", "targets.source": "Источник:",
"targets.source.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.hint": "Какой источник изображения захватывать и обрабатывать",
"targets.source.none": "-- Источник не назначен --", "targets.source.none": "-- Источник не назначен --",
@@ -373,10 +379,6 @@
"targets.interpolation.dominant": "Доминантный", "targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:", "targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.led_skip": "Пропуск LED:",
"targets.led_skip.hint": "Количество светодиодов в начале и конце ленты, которые остаются чёрными. Источники цвета будут рендериться только на активных (непропущенных) LED.",
"targets.led_skip_start": "Начало:",
"targets.led_skip_end": "Конец:",
"targets.keepalive_interval": "Интервал поддержания связи:", "targets.keepalive_interval": "Интервал поддержания связи:",
"targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", "targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана", "targets.created": "Цель успешно создана",

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from wled_controller.storage.picture_target import PictureTarget from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.wled_picture_target import TargetSegment, WledPictureTarget
from wled_controller.storage.key_colors_picture_target import ( from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings, KeyColorsSettings,
KeyColorsPictureTarget, KeyColorsPictureTarget,
@@ -101,38 +101,21 @@ class PictureTargetStore:
name: str, name: str,
target_type: str, target_type: str,
device_id: str = "", device_id: str = "",
color_strip_source_id: str = "", segments: Optional[List[dict]] = None,
fps: int = 30, fps: int = 30,
keepalive_interval: float = 1.0, keepalive_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
led_skip_start: int = 0,
led_skip_end: int = 0,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
# Legacy params — accepted but ignored for backward compat
picture_source_id: str = "", picture_source_id: str = "",
settings=None,
) -> PictureTarget: ) -> PictureTarget:
"""Create a new picture target. """Create a new picture target.
Args:
name: Target name
target_type: Target type ("led", "wled", "key_colors")
device_id: WLED device ID (for led targets)
color_strip_source_id: Color strip source ID (for led targets)
keepalive_interval: Keepalive interval in seconds (for led targets)
state_check_interval: State check interval in seconds (for led targets)
key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description
Raises: Raises:
ValueError: If validation fails ValueError: If validation fails
""" """
if target_type not in ("led", "wled", "key_colors"): if target_type not in ("led", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}") raise ValueError(f"Invalid target type: {target_type}")
# Normalize legacy "wled" to "led"
if target_type == "wled":
target_type = "led"
# Check for duplicate name # Check for duplicate name
for target in self._targets.values(): for target in self._targets.values():
@@ -143,17 +126,17 @@ class PictureTargetStore:
now = datetime.utcnow() now = datetime.utcnow()
if target_type == "led": if target_type == "led":
seg_list = [TargetSegment.from_dict(s) for s in segments] if segments else []
target: PictureTarget = WledPictureTarget( target: PictureTarget = WledPictureTarget(
id=target_id, id=target_id,
name=name, name=name,
target_type="led", target_type="led",
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, segments=seg_list,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
description=description, description=description,
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -183,17 +166,12 @@ class PictureTargetStore:
target_id: str, target_id: str,
name: Optional[str] = None, name: Optional[str] = None,
device_id: Optional[str] = None, device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None, segments: Optional[List[dict]] = None,
fps: Optional[int] = None, fps: Optional[int] = None,
keepalive_interval: Optional[float] = None, keepalive_interval: Optional[float] = None,
state_check_interval: Optional[int] = None, state_check_interval: Optional[int] = None,
led_skip_start: Optional[int] = None,
led_skip_end: Optional[int] = None,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None, description: Optional[str] = None,
# Legacy params — accepted but ignored
picture_source_id: Optional[str] = None,
settings=None,
) -> PictureTarget: ) -> PictureTarget:
"""Update a picture target. """Update a picture target.
@@ -214,12 +192,10 @@ class PictureTargetStore:
target.update_fields( target.update_fields(
name=name, name=name,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, segments=segments,
fps=fps, fps=fps,
keepalive_interval=keepalive_interval, keepalive_interval=keepalive_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
led_skip_start=led_skip_start,
led_skip_end=led_skip_end,
key_colors_settings=key_colors_settings, key_colors_settings=key_colors_settings,
description=description, description=description,
) )
@@ -262,7 +238,8 @@ class PictureTargetStore:
"""Return names of LED targets that reference a color strip source.""" """Return names of LED targets that reference a color strip source."""
return [ return [
target.name for target in self._targets.values() target.name for target in self._targets.values()
if isinstance(target, WledPictureTarget) and target.color_strip_source_id == css_id if isinstance(target, WledPictureTarget)
and any(seg.color_strip_source_id == css_id for seg in target.segments)
] ]
def count(self) -> int: def count(self) -> int:

View File

@@ -1,7 +1,8 @@
"""LED picture target — sends a color strip source to an LED device.""" """LED picture target — sends color strip sources to an LED device."""
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import List, Optional
from wled_controller.storage.picture_target import PictureTarget from wled_controller.storage.picture_target import PictureTarget
@@ -9,20 +10,50 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
@dataclass @dataclass
class WledPictureTarget(PictureTarget): class TargetSegment:
"""LED picture target — pairs an LED device with a ColorStripSource. """Maps a color strip source to a pixel range on the LED device.
The ColorStripSource produces LED colors (calibration, color correction, ``start`` is inclusive, ``end`` is exclusive. When a target has a single
smoothing). The target controls device-specific settings including send FPS. segment with ``end == 0`` the range auto-fits to the full device LED count.
"""
color_strip_source_id: str = ""
start: int = 0
end: int = 0
reverse: bool = False
def to_dict(self) -> dict:
return {
"color_strip_source_id": self.color_strip_source_id,
"start": self.start,
"end": self.end,
"reverse": self.reverse,
}
@staticmethod
def from_dict(d: dict) -> "TargetSegment":
return TargetSegment(
color_strip_source_id=d.get("color_strip_source_id", ""),
start=d.get("start", 0),
end=d.get("end", 0),
reverse=d.get("reverse", False),
)
@dataclass
class WledPictureTarget(PictureTarget):
"""LED picture target — pairs an LED device with one or more ColorStripSources.
Each segment maps a ColorStripSource to a pixel range on the device.
Gaps between segments stay black. A single segment with ``end == 0``
auto-fits to the full device LED count.
""" """
device_id: str = "" device_id: str = ""
color_strip_source_id: str = "" segments: List[TargetSegment] = field(default_factory=list)
fps: int = 30 # target send FPS (1-90) fps: int = 30 # target send FPS (1-90)
keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
led_skip_start: int = 0 # first N LEDs forced to black
led_skip_end: int = 0 # last M LEDs forced to black
def register_with_manager(self, manager) -> None: def register_with_manager(self, manager) -> None:
"""Register this WLED target with the processor manager.""" """Register this WLED target with the processor manager."""
@@ -30,80 +61,90 @@ class WledPictureTarget(PictureTarget):
manager.add_target( manager.add_target(
target_id=self.id, target_id=self.id,
device_id=self.device_id, device_id=self.device_id,
color_strip_source_id=self.color_strip_source_id, segments=[s.to_dict() for s in self.segments],
fps=self.fps, fps=self.fps,
keepalive_interval=self.keepalive_interval, keepalive_interval=self.keepalive_interval,
state_check_interval=self.state_check_interval, state_check_interval=self.state_check_interval,
led_skip_start=self.led_skip_start,
led_skip_end=self.led_skip_end,
) )
def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None: def sync_with_manager(self, manager, *, settings_changed: bool,
segments_changed: bool = False,
device_changed: bool = False) -> None:
"""Push changed fields to the processor manager.""" """Push changed fields to the processor manager."""
if settings_changed: if settings_changed:
manager.update_target_settings(self.id, { manager.update_target_settings(self.id, {
"fps": self.fps, "fps": self.fps,
"keepalive_interval": self.keepalive_interval, "keepalive_interval": self.keepalive_interval,
"state_check_interval": self.state_check_interval, "state_check_interval": self.state_check_interval,
"led_skip_start": self.led_skip_start,
"led_skip_end": self.led_skip_end,
}) })
if source_changed: if segments_changed:
manager.update_target_color_strip_source(self.id, self.color_strip_source_id) manager.update_target_segments(self.id, [s.to_dict() for s in self.segments])
if device_changed: if device_changed:
manager.update_target_device(self.id, self.device_id) manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, def update_fields(self, *, name=None, device_id=None, segments=None,
fps=None, keepalive_interval=None, state_check_interval=None, fps=None, keepalive_interval=None, state_check_interval=None,
led_skip_start=None, led_skip_end=None,
description=None, **_kwargs) -> None: description=None, **_kwargs) -> None:
"""Apply mutable field updates for WLED targets.""" """Apply mutable field updates for WLED targets."""
super().update_fields(name=name, description=description) super().update_fields(name=name, description=description)
if device_id is not None: if device_id is not None:
self.device_id = device_id self.device_id = device_id
if color_strip_source_id is not None: if segments is not None:
self.color_strip_source_id = color_strip_source_id self.segments = [
TargetSegment.from_dict(s) if isinstance(s, dict) else s
for s in segments
]
if fps is not None: if fps is not None:
self.fps = fps self.fps = fps
if keepalive_interval is not None: if keepalive_interval is not None:
self.keepalive_interval = keepalive_interval self.keepalive_interval = keepalive_interval
if state_check_interval is not None: if state_check_interval is not None:
self.state_check_interval = state_check_interval self.state_check_interval = state_check_interval
if led_skip_start is not None:
self.led_skip_start = led_skip_start
if led_skip_end is not None:
self.led_skip_end = led_skip_end
@property @property
def has_picture_source(self) -> bool: def has_picture_source(self) -> bool:
return bool(self.color_strip_source_id) return any(s.color_strip_source_id for s in self.segments)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Convert to dictionary.""" """Convert to dictionary."""
d = super().to_dict() d = super().to_dict()
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id d["segments"] = [s.to_dict() for s in self.segments]
d["fps"] = self.fps d["fps"] = self.fps
d["keepalive_interval"] = self.keepalive_interval d["keepalive_interval"] = self.keepalive_interval
d["state_check_interval"] = self.state_check_interval d["state_check_interval"] = self.state_check_interval
d["led_skip_start"] = self.led_skip_start
d["led_skip_end"] = self.led_skip_end
return d return d
@classmethod @classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget": def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary.""" """Create from dictionary with backward compatibility."""
# Migrate old single-source format to segments
if "segments" in data:
segments = [TargetSegment.from_dict(s) for s in data["segments"]]
elif "color_strip_source_id" in data:
css_id = data.get("color_strip_source_id", "")
skip_start = data.get("led_skip_start", 0)
skip_end = data.get("led_skip_end", 0)
if css_id:
segments = [TargetSegment(
color_strip_source_id=css_id,
start=skip_start,
end=0, # auto-fit; skip_end handled by processor
)]
else:
segments = []
else:
segments = []
return cls( return cls(
id=data["id"], id=data["id"],
name=data["name"], name=data["name"],
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), segments=segments,
fps=data.get("fps", 30), fps=data.get("fps", 30),
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
led_skip_start=data.get("led_skip_start", 0),
led_skip_end=data.get("led_skip_end", 0),
description=data.get("description"), description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),

View File

@@ -1,4 +1,4 @@
<!-- Target Editor Modal (name, device, color strip source, standby) --> <!-- Target Editor Modal (name, device, segments, settings) -->
<div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title"> <div id="target-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="target-editor-title">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -23,13 +23,14 @@
<select id="target-editor-device"></select> <select id="target-editor-device"></select>
</div> </div>
<div class="form-group"> <div class="form-group" id="target-editor-segments-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-css" data-i18n="targets.color_strip_source">Color Strip Source:</label> <label data-i18n="targets.segments">Segments:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="targets.color_strip_source.hint">Color strip source that captures and processes screen pixels into LED colors</small> <small class="input-hint" style="display:none" data-i18n="targets.segments.hint">Each segment maps a color strip source to a pixel range on the LED strip. Gaps between segments stay black. A single segment with Start=0, End=0 auto-fits to the full strip.</small>
<select id="target-editor-css"></select> <div id="target-editor-segment-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addTargetSegment()" data-i18n="targets.segments.add">+ Add Segment</button>
</div> </div>
<div class="form-group" id="target-editor-fps-group"> <div class="form-group" id="target-editor-fps-group">
@@ -48,24 +49,6 @@
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small> <small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
</div> </div>
<div class="form-group" id="target-editor-skip-group">
<div class="label-row">
<label data-i18n="targets.led_skip">LED Skip:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.led_skip.hint">Number of LEDs at the start and end of the strip to keep black. Color sources will render only across the active (non-skipped) LEDs.</small>
<div class="inline-fields">
<div class="inline-field">
<label for="target-editor-skip-start" data-i18n="targets.led_skip_start">Start:</label>
<input type="number" id="target-editor-skip-start" min="0" value="0">
</div>
<div class="inline-field">
<label for="target-editor-skip-end" data-i18n="targets.led_skip_end">End:</label>
<input type="number" id="target-editor-skip-end" min="0" value="0">
</div>
</div>
</div>
<div class="form-group" id="target-editor-keepalive-group"> <div class="form-group" id="target-editor-keepalive-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-keepalive-interval"> <label for="target-editor-keepalive-interval">