diff --git a/CLAUDE.md b/CLAUDE.md index dc064c9..d3dfe7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,10 @@ powershell -ExecutionPolicy Bypass -File "c:\Users\Alexei\Documents\wled-screen- **Do NOT use** `Stop-Process -Name python` (kills unrelated Python processes like VS Code extensions) or bash background `&` jobs (get killed when the shell session ends). +## Default Config & API Key + +The server configuration is in `/server/config/default_config.yaml`. The default API key for development is `development-key-change-in-production` (label: `dev`). The server runs on port **8080** by default. + ## Project Structure This is a monorepo containing: diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 23669df..c0a81c1 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -32,7 +32,6 @@ 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 @@ -94,15 +93,7 @@ def _target_to_response(target) -> PictureTargetResponse: name=target.name, target_type=target.target_type, device_id=target.device_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 - ], + color_strip_source_id=target.color_strip_source_id, fps=target.fps, keepalive_interval=target.keepalive_interval, state_check_interval=target.state_check_interval, @@ -157,7 +148,7 @@ async def create_target( name=data.name, target_type=data.target_type, device_id=data.device_id, - segments=[s.model_dump() for s in data.segments] if data.segments else None, + color_strip_source_id=data.color_strip_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, @@ -267,12 +258,11 @@ 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, - segments=segments_dicts, + color_strip_source_id=data.color_strip_source_id, fps=data.fps, keepalive_interval=data.keepalive_interval, state_check_interval=data.state_check_interval, @@ -288,7 +278,7 @@ async def update_target( data.keepalive_interval is not None or data.state_check_interval is not None or data.key_colors_settings is not None), - segments_changed=data.segments is not None, + css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, ) except ValueError: @@ -756,9 +746,8 @@ 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.segments: - # Use the first segment's CSS for calibration/overlay - first_css_id = target.segments[0].color_strip_source_id + if isinstance(target, WledPictureTarget) and target.color_strip_source_id: + first_css_id = target.color_strip_source_id if first_css_id: try: css = color_strip_store.get_source(first_css_id) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 3c60ff4..bd08d3a 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -1,7 +1,7 @@ """Picture target schemas (CRUD, processing state, metrics).""" from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, Optional, List from pydantic import BaseModel, Field @@ -45,15 +45,6 @@ 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.""" @@ -61,7 +52,7 @@ 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") - segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") + color_strip_source_id: str = Field(default="", description="Color strip source ID") 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) @@ -77,7 +68,7 @@ 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") - segments: Optional[List[TargetSegmentSchema]] = Field(None, description="LED segments") + color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") 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) @@ -95,7 +86,7 @@ class PictureTargetResponse(BaseModel): target_type: str = Field(description="Target type") # LED target fields device_id: str = Field(default="", description="LED device ID") - segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") + color_strip_source_id: str = Field(default="", description="Color strip source ID") 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)") @@ -119,7 +110,7 @@ class TargetProcessingState(BaseModel): target_id: str = Field(description="Target ID") device_id: Optional[str] = Field(None, description="Device ID") - segments: List[TargetSegmentSchema] = Field(default_factory=list, description="LED segments") + color_strip_source_id: str = Field(default="", description="Color strip source ID") 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 2709c8e..4888f4b 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -272,7 +272,7 @@ class ProcessorManager: self, target_id: str, device_id: str, - segments: Optional[list] = None, + color_strip_source_id: str = "", fps: int = 30, keepalive_interval: float = 1.0, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, @@ -286,7 +286,7 @@ class ProcessorManager: proc = WledTargetProcessor( target_id=target_id, device_id=device_id, - segments=segments, + color_strip_source_id=color_strip_source_id, fps=fps, keepalive_interval=keepalive_interval, state_check_interval=state_check_interval, @@ -335,10 +335,10 @@ class ProcessorManager: proc = self._get_processor(target_id) proc.update_source(picture_source_id) - def update_target_segments(self, target_id: str, segments: list): - """Update the segments for a WLED target.""" + def update_target_css(self, target_id: str, color_strip_source_id: str): + """Update the color strip source for a WLED target.""" proc = self._get_processor(target_id) - proc.update_segments(segments) + proc.update_css_source(color_strip_source_id) 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 8c186e6..5f31ae1 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_segments(self, segments: list) -> None: - """Update segments. No-op for targets that don't use segments.""" + def update_css_source(self, color_strip_source_id: str) -> None: + """Update the color strip source. No-op for targets that don't use CSS.""" 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 752dd96..7895972 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 ColorStripStreams, sends via DDP.""" +"""WLED/LED target processor — gets colors from a ColorStripStream, sends via DDP.""" from __future__ import annotations @@ -6,7 +6,7 @@ import asyncio import collections import time from datetime import datetime -from typing import List, Optional, Tuple +from typing import Optional import numpy as np @@ -24,47 +24,14 @@ 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 one or more ColorStripStreams to a WLED/LED device. - - Each segment maps a CSS source to a pixel range on the device. - Gaps between segments stay black. - """ + """Streams LED colors from a single ColorStripStream to a WLED/LED device.""" def __init__( self, target_id: str, device_id: str, - segments: Optional[List[dict]] = None, + color_strip_source_id: str = "", fps: int = 30, keepalive_interval: float = 1.0, state_check_interval: int = 30, @@ -75,12 +42,11 @@ class WledTargetProcessor(TargetProcessor): self._target_fps = fps if fps > 0 else 30 self._keepalive_interval = keepalive_interval self._state_check_interval = state_check_interval - self._segments = list(segments) if segments else [] + self._css_id = color_strip_source_id # Runtime state (populated on start) self._led_client: Optional[LEDClient] = None - # List of (resolved_seg_dict, stream) tuples — read by the loop - self._segment_streams: List[Tuple[dict, object]] = [] + self._css_stream: Optional[object] = None # active stream reference self._device_state_before: Optional[dict] = None self._overlay_active = False self._needs_keepalive = True @@ -126,57 +92,35 @@ 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 streams for each segment + # Acquire color strip stream 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._segments: + if not self._css_id: await self._led_client.close() self._led_client = None - 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]] = [] + raise RuntimeError(f"Target {self._target_id} has no color strip source configured") try: - 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)) + stream = await asyncio.to_thread(css_manager.acquire, self._css_id, self._target_id) + if hasattr(stream, "configure") and device_info.led_count > 0: + stream.configure(device_info.led_count) + css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps) - # 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 + self._resolved_display_index = getattr(stream, "display_index", None) + self._css_stream = stream - 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 {len(segment_streams)} segment stream(s) for target {self._target_id}: {seg_desc}" + f"Acquired CSS stream '{self._css_id}' for target {self._target_id}" ) except Exception as 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 segment streams: {e}") + raise RuntimeError(f"Failed to acquire CSS stream: {e}") # Reset metrics and start loop self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) @@ -213,16 +157,15 @@ class WledTargetProcessor(TargetProcessor): await self._led_client.close() self._led_client = None - # Release all segment streams + # Release CSS stream css_manager = self._ctx.color_strip_stream_manager - if css_manager: - for seg, stream in self._segment_streams: - try: - 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 segment stream {seg['css_id']} for {self._target_id}: {e}") - self._segment_streams = [] + if css_manager and self._css_stream is not None: + try: + css_manager.remove_target_fps(self._css_id, self._target_id) + await asyncio.to_thread(css_manager.release, self._css_id, self._target_id) + except Exception as e: + logger.warning(f"Error releasing CSS stream {self._css_id} for {self._target_id}: {e}") + self._css_stream = None logger.info(f"Stopped processing for target {self._target_id}") self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) @@ -235,9 +178,8 @@ class WledTargetProcessor(TargetProcessor): if "fps" in settings: self._target_fps = settings["fps"] if settings["fps"] > 0 else 30 css_manager = self._ctx.color_strip_stream_manager - 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 css_manager and self._is_running and self._css_id: + css_manager.notify_target_fps(self._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: @@ -248,9 +190,10 @@ class WledTargetProcessor(TargetProcessor): """Update the device this target streams to.""" self._device_id = device_id - def update_segments(self, new_segments: List[dict]) -> None: - """Hot-swap all segments for a running target.""" - self._segments = list(new_segments) + def update_css_source(self, new_css_id: str) -> None: + """Hot-swap the color strip source for a running target.""" + old_css_id = self._css_id + self._css_id = new_css_id if not self._is_running: return @@ -262,42 +205,35 @@ class WledTargetProcessor(TargetProcessor): 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: + # Release old stream + if self._css_stream is not None and old_css_id: try: - css_manager.remove_target_fps(seg["css_id"], self._target_id) - css_manager.release(seg["css_id"], self._target_id) + css_manager.remove_target_fps(old_css_id, self._target_id) + css_manager.release(old_css_id, self._target_id) except Exception as e: - logger.warning(f"Error releasing segment {seg['css_id']}: {e}") + logger.warning(f"Error releasing old CSS {old_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 + # Acquire new stream + new_stream = None + if new_css_id: 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)) + new_stream = css_manager.acquire(new_css_id, self._target_id) + if hasattr(new_stream, "configure") and device_leds > 0: + new_stream.configure(device_leds) + css_manager.notify_target_fps(new_css_id, self._target_id, self._target_fps) except Exception as e: - logger.error(f"Failed to acquire segment {seg['css_id']}: {e}") + logger.error(f"Failed to acquire new CSS {new_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)") + # Atomic swap — the processing loop detects via identity check + self._css_stream = new_stream + logger.info(f"Hot-swapped CSS for {self._target_id}: {old_css_id} -> {new_css_id}") def get_display_index(self) -> Optional[int]: - """Display index being captured, from the first active stream.""" + """Display index being captured, from the active stream.""" if self._resolved_display_index is not None: return self._resolved_display_index - for _, stream in self._segment_streams: - di = getattr(stream, "display_index", None) - if di is not None: - return di + if self._css_stream is not None: + return getattr(self._css_stream, "display_index", None) return None # ----- State / Metrics ----- @@ -307,8 +243,8 @@ class WledTargetProcessor(TargetProcessor): fps_target = self._target_fps css_timing: dict = {} - if self._is_running and self._segment_streams: - css_timing = self._segment_streams[0][1].get_last_timing() + if self._is_running and self._css_stream is not None: + css_timing = self._css_stream.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 @@ -321,19 +257,10 @@ class WledTargetProcessor(TargetProcessor): 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, - "segments": segments_info, + "color_strip_source_id": self._css_id, "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, @@ -383,25 +310,19 @@ class WledTargetProcessor(TargetProcessor): raise RuntimeError(f"Overlay already active for {self._target_id}") if calibration is None or display_info 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: + stream = self._css_stream + if stream is None or not (hasattr(stream, "calibration") and stream.calibration): raise ValueError( f"Cannot start overlay for {self._target_id}: no stream with calibration" ) if calibration is None: - calibration = stream_with_cal.calibration + calibration = stream.calibration if display_info is None: display_index = self._resolved_display_index if display_index is None: - display_index = getattr(stream_with_cal, "display_index", None) + display_index = getattr(stream, "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() @@ -435,13 +356,6 @@ class WledTargetProcessor(TargetProcessor): # ----- Private: processing loop ----- - @staticmethod - def _apply_brightness(colors: np.ndarray, device_info: Optional[DeviceInfo]) -> np.ndarray: - """Apply device software_brightness if < 255.""" - if device_info and device_info.software_brightness < 255: - return (colors.astype(np.uint16) * device_info.software_brightness >> 8).astype(np.uint8) - return colors - @staticmethod def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray: """Resample colors to match the target LED count.""" @@ -457,7 +371,7 @@ class WledTargetProcessor(TargetProcessor): return result async def _processing_loop(self) -> None: - """Main processing loop — poll segment streams → compose → brightness → send.""" + """Main processing loop — poll CSS stream -> brightness -> send.""" keepalive_interval = self._keepalive_interval fps_samples: collections.deque = collections.deque(maxlen=10) @@ -468,13 +382,9 @@ class WledTargetProcessor(TargetProcessor): _init_device_info = self._ctx.get_device_info(self._device_id) _total_leds = _init_device_info.led_count if _init_device_info else 0 - # Device-sized output buffer (persistent between frames; gaps stay black) - device_buf = np.zeros((_total_leds, 3), dtype=np.uint8) - - # 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) + # Stream reference — re-read each tick to detect hot-swaps + stream = self._css_stream + prev_frame_ref = None has_any_frame = False # Pre-allocate brightness scratch (uint16 intermediate + uint8 output) @@ -510,7 +420,7 @@ class WledTargetProcessor(TargetProcessor): logger.info( f"Processing loop started for target {self._target_id} " - f"({len(segment_streams)} segments, {_total_leds} LEDs, fps={self._target_fps})" + f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps})" ) next_frame_time = time.perf_counter() @@ -523,13 +433,12 @@ class WledTargetProcessor(TargetProcessor): 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) + # Detect hot-swapped CSS stream + cur_stream = self._css_stream + if cur_stream is not stream: + stream = cur_stream + prev_frame_ref = None has_any_frame = False - device_buf[:] = 0 _diag_device_info_age += 1 if _diag_device_info is None or _diag_device_info_age >= 30: @@ -542,34 +451,28 @@ class WledTargetProcessor(TargetProcessor): await asyncio.sleep(frame_time) continue - if not segment_streams: + if stream is None: await asyncio.sleep(frame_time) continue 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 + # Poll the CSS stream + frame = stream.get_latest_colors() - if all_none: + if frame is None: if self._metrics.frames_processed == 0: - logger.info(f"No data from any segment stream for {self._target_id}") + logger.info(f"No data from CSS stream for {self._target_id}") await asyncio.sleep(frame_time) continue - if not any_new: - # All streams returned same frame — keepalive or skip + if frame is prev_frame_ref: + # 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 - send_colors = _cached_brightness(device_buf, device_info) + send_colors = _cached_brightness( + self._fit_to_device(prev_frame_ref, _total_leds), device_info + ) if self._led_client.supports_fast_send: self._led_client.send_pixels_fast(send_colors) else: @@ -582,31 +485,17 @@ class WledTargetProcessor(TargetProcessor): while send_timestamps and send_timestamps[0] < now - 1.0: send_timestamps.popleft() self._metrics.fps_current = len(send_timestamps) - is_animated = any(s.is_animated for _, s in segment_streams) + is_animated = stream.is_animated repoll = SKIP_REPOLL if is_animated else frame_time await asyncio.sleep(repoll) continue + prev_frame_ref = frame has_any_frame = True - # 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(device_buf, device_info) + # Fit to device LED count and apply brightness + device_colors = self._fit_to_device(frame, _total_leds) + send_colors = _cached_brightness(device_colors, device_info) # Send to LED device if not self._is_running or self._led_client is None: diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8007ba0..22fe7ad 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -85,7 +85,6 @@ import { import { loadTargetsTab, loadTargets, switchTargetSubTab, showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, - addTargetSegment, removeTargetSegment, syncSegmentsMappedMode, startTargetProcessing, stopTargetProcessing, startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, @@ -283,9 +282,6 @@ Object.assign(window, { closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor, - addTargetSegment, - removeTargetSegment, - syncSegmentsMappedMode, 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 14a2018..5ce1409 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -447,14 +447,11 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap if (device) { subtitleParts.push((device.device_type || '').toUpperCase()); } - 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`); + const cssId = target.color_strip_source_id || ''; + if (cssId) { + const css = cssSourceMap[cssId]; + if (css) { + subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type); } } } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index b95b195..734b0d9 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -74,73 +74,9 @@ function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) { }); } -// --- Segment editor state --- +// --- Editor state --- let _editorCssSources = []; // populated when editor opens -/** - * When the selected CSS source is a mapped type, collapse the segment UI - * to a single source dropdown — range fields, reverse, header, and "Add Segment" - * are hidden because the mapped CSS already defines spatial zones internally. - */ -export function syncSegmentsMappedMode() { - const list = document.getElementById('target-editor-segment-list'); - if (!list) return; - const rows = list.querySelectorAll('.segment-row'); - if (rows.length === 0) return; - - const firstSelect = rows[0].querySelector('.segment-css-select'); - const selectedId = firstSelect ? firstSelect.value : ''; - const selectedSource = _editorCssSources.find(s => s.id === selectedId); - const isMapped = selectedSource && selectedSource.source_type === 'mapped'; - - // Remove extra segments when switching to mapped - if (isMapped && rows.length > 1) { - for (let i = rows.length - 1; i >= 1; i--) rows[i].remove(); - } - - // Toggle visibility of range/reverse/header within the first row - const firstRow = list.querySelector('.segment-row'); - if (firstRow) { - const header = firstRow.querySelector('.segment-row-header'); - const rangeFields = firstRow.querySelector('.segment-range-fields'); - const reverseLabel = firstRow.querySelector('.segment-reverse-label'); - if (header) header.style.display = isMapped ? 'none' : ''; - if (rangeFields) rangeFields.style.display = isMapped ? 'none' : ''; - if (reverseLabel) reverseLabel.style.display = isMapped ? 'none' : ''; - } - - // Hide/show "Add Segment" button - const addBtn = document.querySelector('#target-editor-segments-group > .btn-sm'); - if (addBtn) addBtn.style.display = isMapped ? 'none' : ''; - - // Swap label: "Segments:" ↔ "Color Strip Source:" - const group = document.getElementById('target-editor-segments-group'); - if (group) { - const label = group.querySelector('.label-row label'); - const hintToggle = group.querySelector('.hint-toggle'); - const hint = group.querySelector('.input-hint'); - if (label) label.textContent = isMapped - ? t('targets.color_strip_source') - : t('targets.segments'); - if (hintToggle) hintToggle.style.display = isMapped ? 'none' : ''; - if (hint) hint.style.display = 'none'; // collapse hint on switch - } -} - -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'); @@ -150,7 +86,7 @@ class TargetEditorModal extends Modal { return { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, - segments: _serializeSegments(), + css_source: document.getElementById('target-editor-css-source').value, fps: document.getElementById('target-editor-fps').value, keepalive_interval: document.getElementById('target-editor-keepalive-interval').value, }; @@ -166,9 +102,8 @@ function _autoGenerateTargetName() { if (document.getElementById('target-editor-id').value) return; const deviceSelect = document.getElementById('target-editor-device'); const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || ''; - // Use first segment's CSS name - const firstCssSelect = document.querySelector('.segment-css-select'); - const cssName = firstCssSelect?.selectedOptions[0]?.dataset?.name || ''; + const cssSelect = document.getElementById('target-editor-css-source'); + const cssName = cssSelect?.selectedOptions[0]?.dataset?.name || ''; if (!deviceName || !cssName) return; document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } @@ -210,54 +145,11 @@ 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 => - `` +function _populateCssDropdown(selectedId = '') { + const select = document.getElementById('target-editor-css-source'); + select.innerHTML = _editorCssSources.map(s => + `` ).join(''); - - return ` -