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 ` +
+ #${index + 1} + +
+
+ +
+ + + + +
+ +
+ `; +} + export async function showTargetEditor(targetId = null) { try { // 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 cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : []; set_targetEditorDevices(devices); + _editorCssSources = cssSources; // Populate device select const deviceSelect = document.getElementById('target-editor-device'); @@ -157,16 +224,9 @@ export async function showTargetEditor(targetId = null) { deviceSelect.appendChild(opt); }); - // Populate color strip source select - const cssSelect = document.getElementById('target-editor-css'); - cssSelect.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); - }); + // Clear segment list + const segmentList = document.getElementById('target-editor-segment-list'); + segmentList.innerHTML = ''; if (targetId) { // 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-name').value = target.name; deviceSelect.value = target.device_id || ''; - cssSelect.value = target.color_strip_source_id || ''; const fps = target.fps ?? 30; document.getElementById('target-editor-fps').value = 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').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'); + + // Populate segments + const segments = target.segments || []; + if (segments.length === 0) { + addTargetSegment(); + } else { + segments.forEach(seg => addTargetSegment(seg)); + } } 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-name').value = ''; document.getElementById('target-editor-fps').value = 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').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'); + addTargetSegment(); } // Auto-name generation _targetNameManuallyEdited = !!targetId; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; + window._targetAutoName = _autoGenerateTargetName; deviceSelect.onchange = () => { _updateKeepaliveVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; - cssSelect.onchange = () => _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName(); // 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 name = document.getElementById('target-editor-name').value.trim(); 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 ledSkipStart = parseInt(document.getElementById('target-editor-skip-start').value) || 0; - const ledSkipEnd = parseInt(document.getElementById('target-editor-skip-end').value) || 0; if (!name) { 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; + // 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 = { name, device_id: deviceId, - color_strip_source_id: cssId, + segments, fps, keepalive_interval: standbyInterval, - led_skip_start: ledSkipStart, - led_skip_end: ledSkipEnd, }; 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) { const state = target.state || {}; const metrics = target.metrics || {}; @@ -542,9 +630,15 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) { const isProcessing = state.processing || false; 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 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) const devOnline = state.device_online || false; @@ -568,7 +662,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
💡 ${escapeHtml(deviceName)} ⚡ ${target.fps || 30} fps - 🎞️ ${escapeHtml(cssName)} + 🎞️ ${segSummary}
${isProcessing ? ` @@ -631,7 +725,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) { - ${(!css || css.source_type === 'picture') ? (state.overlay_active ? ` + ${overlayAvailable ? (state.overlay_active ? ` diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 69091cd..12db59e 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -356,8 +356,14 @@ "targets.device": "Device:", "targets.device.hint": "Select the LED device to send data to", "targets.device.none": "-- Select a device --", - "targets.color_strip_source": "Color Strip Source:", - "targets.color_strip_source.hint": "Color strip source that captures and processes screen pixels into LED colors", + "targets.segments": "Segments:", + "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.hint": "Which picture source to capture and process", "targets.source.none": "-- No source assigned --", @@ -373,10 +379,6 @@ "targets.interpolation.dominant": "Dominant", "targets.smoothing": "Smoothing:", "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.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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index b2c0ddd..1a77c62 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -356,8 +356,14 @@ "targets.device": "Устройство:", "targets.device.hint": "Выберите LED устройство для передачи данных", "targets.device.none": "-- Выберите устройство --", - "targets.color_strip_source": "Источник цветовой полосы:", - "targets.color_strip_source.hint": "Источник цветовой полосы, который захватывает и обрабатывает пиксели экрана в цвета светодиодов", + "targets.segments": "Сегменты:", + "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.hint": "Какой источник изображения захватывать и обрабатывать", "targets.source.none": "-- Источник не назначен --", @@ -373,10 +379,6 @@ "targets.interpolation.dominant": "Доминантный", "targets.smoothing": "Сглаживание:", "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.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", "targets.created": "Цель успешно создана", diff --git a/server/src/wled_controller/storage/picture_target_store.py b/server/src/wled_controller/storage/picture_target_store.py index 731c57b..30cfdd1 100644 --- a/server/src/wled_controller/storage/picture_target_store.py +++ b/server/src/wled_controller/storage/picture_target_store.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Dict, List, Optional 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 ( KeyColorsSettings, KeyColorsPictureTarget, @@ -101,38 +101,21 @@ class PictureTargetStore: name: str, target_type: str, device_id: str = "", - color_strip_source_id: str = "", + segments: Optional[List[dict]] = 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, key_colors_settings: Optional[KeyColorsSettings] = None, description: Optional[str] = None, - # Legacy params — accepted but ignored for backward compat picture_source_id: str = "", - settings=None, ) -> PictureTarget: """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: 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}") - # Normalize legacy "wled" to "led" - if target_type == "wled": - target_type = "led" # Check for duplicate name for target in self._targets.values(): @@ -143,17 +126,17 @@ class PictureTargetStore: now = datetime.utcnow() if target_type == "led": + seg_list = [TargetSegment.from_dict(s) for s in segments] if segments else [] + target: PictureTarget = WledPictureTarget( id=target_id, name=name, target_type="led", device_id=device_id, - color_strip_source_id=color_strip_source_id, + segments=seg_list, fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, - led_skip_start=led_skip_start, - led_skip_end=led_skip_end, description=description, created_at=now, updated_at=now, @@ -183,17 +166,12 @@ class PictureTargetStore: target_id: str, name: Optional[str] = None, device_id: Optional[str] = None, - color_strip_source_id: Optional[str] = None, + segments: Optional[List[dict]] = None, fps: Optional[int] = None, keepalive_interval: Optional[float] = 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, description: Optional[str] = None, - # Legacy params — accepted but ignored - picture_source_id: Optional[str] = None, - settings=None, ) -> PictureTarget: """Update a picture target. @@ -214,12 +192,10 @@ class PictureTargetStore: target.update_fields( name=name, 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, key_colors_settings=key_colors_settings, description=description, ) @@ -262,7 +238,8 @@ class PictureTargetStore: """Return names of LED targets that reference a color strip source.""" return [ 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: diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index de86988..3bd2c96 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -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 typing import List, Optional from wled_controller.storage.picture_target import PictureTarget @@ -9,20 +10,50 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds @dataclass -class WledPictureTarget(PictureTarget): - """LED picture target — pairs an LED device with a ColorStripSource. +class TargetSegment: + """Maps a color strip source to a pixel range on the LED device. - The ColorStripSource produces LED colors (calibration, color correction, - smoothing). The target controls device-specific settings including send FPS. + ``start`` is inclusive, ``end`` is exclusive. When a target has a single + 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 = "" - color_strip_source_id: str = "" + segments: List[TargetSegment] = field(default_factory=list) fps: int = 30 # target send FPS (1-90) keepalive_interval: float = 1.0 # seconds between keepalive sends when screen is static 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: """Register this WLED target with the processor manager.""" @@ -30,80 +61,90 @@ class WledPictureTarget(PictureTarget): manager.add_target( target_id=self.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, keepalive_interval=self.keepalive_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.""" if settings_changed: manager.update_target_settings(self.id, { "fps": self.fps, "keepalive_interval": self.keepalive_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: - manager.update_target_color_strip_source(self.id, self.color_strip_source_id) + if segments_changed: + manager.update_target_segments(self.id, [s.to_dict() for s in self.segments]) if device_changed: 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, - led_skip_start=None, led_skip_end=None, description=None, **_kwargs) -> None: """Apply mutable field updates for WLED targets.""" super().update_fields(name=name, description=description) if device_id is not None: self.device_id = device_id - if color_strip_source_id is not None: - self.color_strip_source_id = color_strip_source_id + if segments is not None: + self.segments = [ + TargetSegment.from_dict(s) if isinstance(s, dict) else s + for s in segments + ] if fps is not None: self.fps = fps if keepalive_interval is not None: self.keepalive_interval = keepalive_interval if state_check_interval is not None: 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 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: """Convert to dictionary.""" d = super().to_dict() 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["keepalive_interval"] = self.keepalive_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 @classmethod 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( id=data["id"], name=data["name"], target_type="led", device_id=data.get("device_id", ""), - color_strip_source_id=data.get("color_strip_source_id", ""), + segments=segments, fps=data.get("fps", 30), keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)), 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"), created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())), diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 528df13..35673b4 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -1,4 +1,4 @@ - +