diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 312b9d2..23669df 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -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} diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index cf6aa4d..3c60ff4 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -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)") diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index f5f2687..0ea87a2 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -270,12 +270,10 @@ class ProcessorManager: self, target_id: str, device_id: str, - color_strip_source_id: str = "", + segments: Optional[list] = None, fps: int = 30, keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, - led_skip_start: int = 0, - led_skip_end: int = 0, ): """Register a WLED target processor.""" if target_id in self._processors: @@ -286,12 +284,10 @@ class ProcessorManager: proc = WledTargetProcessor( target_id=target_id, device_id=device_id, - color_strip_source_id=color_strip_source_id, + segments=segments, fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, - led_skip_start=led_skip_start, - led_skip_end=led_skip_end, ctx=self._build_context(), ) self._processors[target_id] = proc @@ -337,10 +333,10 @@ class ProcessorManager: proc = self._get_processor(target_id) proc.update_source(picture_source_id) - def update_target_color_strip_source(self, target_id: str, color_strip_source_id: str): - """Update the color strip source for a WLED target.""" + def update_target_segments(self, target_id: str, segments: list): + """Update the segments for a WLED target.""" 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): """Update the device for a target.""" diff --git a/server/src/wled_controller/core/processing/target_processor.py b/server/src/wled_controller/core/processing/target_processor.py index 3816fb0..8c186e6 100644 --- a/server/src/wled_controller/core/processing/target_processor.py +++ b/server/src/wled_controller/core/processing/target_processor.py @@ -160,8 +160,8 @@ class TargetProcessor(ABC): """Update device association. Raises for targets without devices.""" raise ValueError(f"Target {self._target_id} does not support device assignment") - def update_color_strip_source(self, color_strip_source_id: str) -> None: - """Update color strip source. No-op for targets that don't use CSS.""" + def update_segments(self, segments: list) -> None: + """Update segments. No-op for targets that don't use segments.""" pass # ----- Device / display info (overridden by device-aware subclasses) ----- diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 905fa6a..752dd96 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 @@ -6,7 +6,7 @@ import asyncio import collections import time from datetime import datetime -from typing import Optional +from typing import List, Optional, Tuple import numpy as np @@ -24,46 +24,67 @@ from wled_controller.utils.timer import high_resolution_timer 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 # --------------------------------------------------------------------------- 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. - This processor only applies device software_brightness and sends pixels. + Each segment maps a CSS source to a pixel range on the device. + Gaps between segments stay black. """ def __init__( self, target_id: str, device_id: str, - color_strip_source_id: str, - fps: int, - keepalive_interval: float, - state_check_interval: int, - led_skip_start: int = 0, - led_skip_end: int = 0, + segments: Optional[List[dict]] = None, + fps: int = 30, + keepalive_interval: float = 1.0, + state_check_interval: int = 30, ctx: TargetContext = None, ): super().__init__(target_id, ctx) self._device_id = device_id - self._color_strip_source_id = color_strip_source_id self._target_fps = fps if fps > 0 else 30 self._keepalive_interval = keepalive_interval self._state_check_interval = state_check_interval - self._led_skip_start = max(0, led_skip_start) - self._led_skip_end = max(0, led_skip_end) + self._segments = list(segments) if segments else [] # Runtime state (populated on start) 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._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 # ----- Properties ----- @@ -105,44 +126,57 @@ class WledTargetProcessor(TargetProcessor): 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}") - # Acquire color strip stream + # Acquire color strip streams for each segment css_manager = self._ctx.color_strip_stream_manager if css_manager is None: await self._led_client.close() self._led_client = None 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() 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: - stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id, self._target_id) - self._color_strip_stream = stream - self._resolved_display_index = stream.display_index + for seg in resolved: + if not seg["css_id"]: + 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 - if hasattr(stream, "configure") and device_info.led_count > 0: - effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end - stream.configure(max(1, effective_leds)) + # Resolve display index from first stream that has one + self._resolved_display_index = None + for _, s in segment_streams: + 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 - css_manager.notify_target_fps( - self._color_strip_source_id, self._target_id, self._target_fps - ) + self._segment_streams = segment_streams + seg_desc = ", ".join(f"{s['css_id']}[{s['start']}:{s['end']}]" for s in resolved if s["css_id"]) logger.info( - f"Acquired color strip stream for target {self._target_id} " - f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " - f"fps={self._target_fps})" + f"Acquired {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}" ) 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: await self._led_client.close() 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 self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) @@ -167,8 +201,6 @@ class WledTargetProcessor(TargetProcessor): except asyncio.CancelledError: pass 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) # Restore device state @@ -181,16 +213,16 @@ class WledTargetProcessor(TargetProcessor): await self._led_client.close() self._led_client = None - # Release color strip stream - if self._color_strip_stream is not None: - css_manager = self._ctx.color_strip_stream_manager - if css_manager and self._color_strip_source_id: + # Release all segment streams + css_manager = self._ctx.color_strip_stream_manager + if css_manager: + for seg, stream in self._segment_streams: try: - css_manager.remove_target_fps(self._color_strip_source_id, self._target_id) - await asyncio.to_thread(css_manager.release, 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, seg["css_id"], self._target_id) except Exception as e: - logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}") - self._color_strip_stream = None + logger.warning(f"Error releasing segment stream {seg['css_id']} for {self._target_id}: {e}") + self._segment_streams = [] logger.info(f"Stopped processing for target {self._target_id}") 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 "fps" in settings: 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 - if css_manager and self._color_strip_source_id and self._is_running: - css_manager.notify_target_fps( - self._color_strip_source_id, self._target_id, self._target_fps - ) + if css_manager and self._is_running: + for seg, _ in self._segment_streams: + css_manager.notify_target_fps(seg["css_id"], self._target_id, self._target_fps) if "keepalive_interval" in settings: self._keepalive_interval = settings["keepalive_interval"] if "state_check_interval" in settings: 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}") def update_device(self, device_id: str) -> None: """Update the device this target streams to.""" self._device_id = device_id - def update_color_strip_source(self, color_strip_source_id: str) -> None: - """Hot-swap the color strip source for a running target.""" - if not self._is_running or self._color_strip_source_id == color_strip_source_id: - self._color_strip_source_id = color_strip_source_id + def update_segments(self, new_segments: List[dict]) -> None: + """Hot-swap all segments for a running target.""" + self._segments = list(new_segments) + + if not self._is_running: return css_manager = self._ctx.color_strip_stream_manager if css_manager is None: - self._color_strip_source_id = color_strip_source_id return - old_id = self._color_strip_source_id - try: - new_stream = css_manager.acquire(color_strip_source_id, self._target_id) - css_manager.remove_target_fps(old_id, self._target_id) - css_manager.release(old_id, self._target_id) - self._color_strip_stream = new_stream - self._resolved_display_index = new_stream.display_index - self._color_strip_source_id = color_strip_source_id - css_manager.notify_target_fps(color_strip_source_id, self._target_id, self._target_fps) - logger.info(f"Swapped color strip source for {self._target_id}: {old_id} → {color_strip_source_id}") - except Exception as e: - logger.error(f"Failed to swap color strip source for {self._target_id}: {e}") + device_info = self._ctx.get_device_info(self._device_id) + device_leds = device_info.led_count if device_info else 0 + + # Release old streams + for seg, stream in self._segment_streams: + try: + css_manager.remove_target_fps(seg["css_id"], self._target_id) + css_manager.release(seg["css_id"], self._target_id) + except Exception as e: + logger.warning(f"Error releasing segment {seg['css_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]: - """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: return self._resolved_display_index - if self._color_strip_stream is not None: - return self._color_strip_stream.display_index + for _, stream in self._segment_streams: + di = getattr(stream, "display_index", None) + if di is not None: + return di return None # ----- State / Metrics ----- @@ -260,10 +306,9 @@ class WledTargetProcessor(TargetProcessor): metrics = self._metrics fps_target = self._target_fps - # Pull per-stage timing from the CSS stream (runs in a background thread) css_timing: dict = {} - if self._is_running and self._color_strip_stream is not None: - css_timing = self._color_strip_stream.get_last_timing() + if self._is_running and self._segment_streams: + css_timing = self._segment_streams[0][1].get_last_timing() 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 @@ -272,15 +317,23 @@ class WledTargetProcessor(TargetProcessor): if css_timing: total_ms = round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1) 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 else: 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 { "target_id": self._target_id, "device_id": self._device_id, - "color_strip_source_id": self._color_strip_source_id, + "segments": segments_info, "processing": self._is_running, "fps_actual": metrics.fps_actual 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, "overlay_active": self._overlay_active, "needs_keepalive": self._needs_keepalive, - "led_skip_start": self._led_skip_start, - "led_skip_end": self._led_skip_end, "last_update": metrics.last_update, "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}") if calibration is None or display_info is None: - # Calibration comes from the active color strip stream - if self._color_strip_stream is None: + # Find calibration from the first picture stream + 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( - f"Cannot start overlay for {self._target_id}: no color strip stream active " - f"and no calibration provided." + f"Cannot start overlay for {self._target_id}: no stream with calibration" ) if calibration is None: - calibration = self._color_strip_stream.calibration + calibration = stream_with_cal.calibration if display_info is None: display_index = self._resolved_display_index 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: raise ValueError(f"Invalid display index {display_index} for overlay") - displays = get_available_displays() if display_index >= len(displays): raise ValueError(f"Invalid display index {display_index}") - display_info = displays[display_index] await asyncio.to_thread( @@ -391,16 +444,10 @@ class WledTargetProcessor(TargetProcessor): @staticmethod def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray: - """Resample colors to match the target device 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). - """ + """Resample colors to match the target LED count.""" n = len(colors) if n == device_led_count or device_led_count <= 0: return colors - # Linear interpolation — preserves gradient appearance at any size src_x = np.linspace(0, 1, n) dst_x = np.linspace(0, 1, device_led_count) result = np.column_stack([ @@ -409,62 +456,26 @@ class WledTargetProcessor(TargetProcessor): ]) 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: - """Main processing loop — poll ColorStripStream → apply brightness → send.""" - stream = self._color_strip_stream + """Main processing loop — poll segment streams → compose → brightness → send.""" keepalive_interval = self._keepalive_interval fps_samples: collections.deque = collections.deque(maxlen=10) send_timestamps: collections.deque = collections.deque() - prev_colors = None last_send_time = 0.0 prev_frame_time_stamp = time.perf_counter() loop = asyncio.get_running_loop() _init_device_info = self._ctx.get_device_info(self._device_id) _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) - if (self._led_skip_start > 0 or self._led_skip_end > 0) and _total_leds > 0: - _skip_buf: Optional[np.ndarray] = np.zeros((_total_leds, 3), dtype=np.uint8) - else: - _skip_buf = None + # Device-sized output buffer (persistent between frames; gaps stay black) + device_buf = np.zeros((_total_leds, 3), dtype=np.uint8) - # Pre-allocate resampling cache (linspace + result reused while sizes unchanged) - _fit_key = (0, 0) - _fit_src_x = _fit_dst_x = _fit_result = None - - def _cached_fit(colors_in): - """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 + # Segment stream references — re-read each tick to detect hot-swaps + segment_streams = self._segment_streams + # Per-stream identity tracking for "same frame" detection + prev_refs: list = [None] * len(segment_streams) + has_any_frame = False # Pre-allocate brightness scratch (uint16 intermediate + uint8 output) _bright_u16: Optional[np.ndarray] = None @@ -472,7 +483,6 @@ class WledTargetProcessor(TargetProcessor): _bright_n = 0 def _cached_brightness(colors_in, dev_info): - """Apply software brightness using pre-allocated uint16 scratch.""" nonlocal _bright_n, _bright_u16, _bright_out if not dev_info or dev_info.software_brightness >= 255: return colors_in @@ -487,24 +497,20 @@ class WledTargetProcessor(TargetProcessor): np.copyto(_bright_out, _bright_u16, casting='unsafe') 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 # --- Timing diagnostics --- - _diag_interval = 5.0 # report every 5 seconds + _diag_interval = 5.0 _diag_next_report = time.perf_counter() + _diag_interval - _diag_sleep_jitters: list = [] # (requested_ms, actual_ms) - _diag_slow_iters: list = [] # (iter_ms, phase) - _diag_iter_times: list = [] # total iter durations in ms + _diag_sleep_jitters: list = [] + _diag_slow_iters: list = [] + _diag_iter_times: list = [] _diag_device_info: Optional[DeviceInfo] = None - _diag_device_info_age = 0 # iterations since last refresh + _diag_device_info_age = 0 logger.info( 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() @@ -513,14 +519,18 @@ class WledTargetProcessor(TargetProcessor): with high_resolution_timer(): while self._is_running: 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 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 if _diag_device_info is None or _diag_device_info_age >= 30: _diag_device_info = self._ctx.get_device_info(self._device_id) @@ -532,25 +542,34 @@ class WledTargetProcessor(TargetProcessor): await asyncio.sleep(frame_time) continue - try: - colors = stream.get_latest_colors() + if not segment_streams: + 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: - 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) continue - if colors is prev_colors: - # Same frame — send keepalive if interval elapsed (only for devices that need it) - if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= keepalive_interval: + if not any_new: + # All streams returned same frame — keepalive or skip + 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: break - kc = prev_colors - 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) + send_colors = _cached_brightness(device_buf, device_info) if self._led_client.supports_fast_send: self._led_client.send_pixels_fast(send_colors) else: @@ -563,19 +582,31 @@ class WledTargetProcessor(TargetProcessor): while send_timestamps and send_timestamps[0] < now - 1.0: send_timestamps.popleft() 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) continue - prev_colors = colors + has_any_frame = True - # Fit to effective LED count (excluding skipped) then pad with blacks - if device_info and device_info.led_count > 0: - colors = _cached_fit(colors) - colors = self._apply_led_skip(colors, _skip_buf, self._led_skip_start) + # Compose new frame from all segments + device_buf[:] = 0 + for i, (seg, stream) in enumerate(segment_streams): + 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 - send_colors = _cached_brightness(colors, device_info) + send_colors = _cached_brightness(device_buf, device_info) # Send to LED device 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" ) - # FPS tracking (skip first sample — interval from loop init is near-zero) + # FPS tracking interval = now - prev_frame_time_stamp prev_frame_time_stamp = now if self._metrics.frames_processed > 1: @@ -620,9 +651,7 @@ class WledTargetProcessor(TargetProcessor): self._metrics.last_error = str(e) logger.error(f"Processing error for target {self._target_id}: {e}", exc_info=True) - # Drift-compensating throttle: sleep until the absolute - # next_frame_time so overshoots in one frame are recovered - # in the next, keeping average FPS on target. + # Drift-compensating throttle next_frame_time += frame_time sleep_time = next_frame_time - time.perf_counter() if sleep_time > 0: @@ -633,17 +662,16 @@ class WledTargetProcessor(TargetProcessor): requested_sleep = sleep_time * 1000 jitter = actual_sleep - requested_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")) elif sleep_time < -frame_time: - # Too far behind — reset to avoid burst catch-up next_frame_time = time.perf_counter() # Track total iteration time iter_end = time.perf_counter() iter_ms = (iter_end - loop_start) * 1000 _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:]]: _diag_slow_iters.append((iter_ms, "slow_iter")) diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 8a96ead..2c69da9 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -231,6 +231,92 @@ 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 { display: block; margin-top: 4px; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 62aceba..394cb99 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -82,6 +82,7 @@ import { import { loadTargetsTab, loadTargets, switchTargetSubTab, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, + addTargetSegment, removeTargetSegment, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, } from './features/targets.js'; @@ -265,6 +266,8 @@ Object.assign(window, { closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, + addTargetSegment, + removeTargetSegment, startTargetProcessing, stopTargetProcessing, startTargetOverlay, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 9f57fd6..14a2018 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -447,9 +447,15 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap if (device) { subtitleParts.push((device.device_type || '').toUpperCase()); } - const cssSource = target.color_strip_source_id ? cssSourceMap[target.color_strip_source_id] : null; - if (cssSource) { - subtitleParts.push(t(`color_strip.type.${cssSource.source_type}`) || cssSource.source_type); + const segments = target.segments || []; + if (segments.length > 0) { + 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`); + } } } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index e0ecad2..84edda5 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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 { constructor() { super('target-editor-modal'); @@ -83,11 +100,9 @@ class TargetEditorModal extends Modal { return { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, - css: document.getElementById('target-editor-css').value, + segments: _serializeSegments(), fps: document.getElementById('target-editor-fps').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 (document.getElementById('target-editor-id').value) return; const deviceSelect = document.getElementById('target-editor-device'); - const cssSelect = document.getElementById('target-editor-css'); 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; document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } @@ -132,6 +148,56 @@ function _updateKeepaliveVisibility() { 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 => + `` + ).join(''); + + return ` +