Remove target segments, use single color strip source per target

Segments are redundant now that the "mapped" CSS type handles spatial
multiplexing internally. Each target now references one color_strip_source_id
instead of an array of segments with start/end/reverse ranges.

Backward compat: existing targets with old segments format are migrated
on load by extracting the first segment's CSS source ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 00:00:26 +03:00
parent 9efb08acb6
commit 808037775f
14 changed files with 171 additions and 513 deletions

View File

@@ -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."""

View File

@@ -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) -----

View File

@@ -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: