Add multi-segment LED targets, replace single color strip source + skip fields
Each target now has a segments list where each segment maps a color strip source to a pixel range (start/end) on the device with optional reverse. This enables composing multiple visualizations on a single LED strip. Old targets auto-migrate from the single source format on load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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) -----
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user