Optimize numpy pipeline, add per-stage timing, and auto-sync LED count
- Eliminate 5 numpy↔tuple conversions per frame in processing hot path: map_border_to_leds returns ndarray, inline numpy smoothing with integer math, send_pixels_fast accepts ndarray directly - Fix numpy boolean bug in keepalive check (use `is not None`) - Add per-stage pipeline timing (extract/map/smooth/send) to metrics API and UI with color-coded breakdown bar - Expose device_fps from WLED health check in API schemas - Auto-sync LED count from WLED device: health check detects changes and updates storage, calibration, and active targets automatically - Use integer math for brightness scaling (uint16 * brightness >> 8) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,7 @@ class DeviceStateResponse(BaseModel):
|
|||||||
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||||
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||||
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
||||||
|
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
||||||
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
test_mode: bool = Field(default=False, description="Whether calibration test mode is active")
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ class TargetProcessingState(BaseModel):
|
|||||||
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)")
|
||||||
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby")
|
||||||
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
fps_current: Optional[int] = Field(None, description="Frames sent in the last second")
|
||||||
|
timing_extract_ms: Optional[float] = Field(None, description="Border extraction time (ms)")
|
||||||
|
timing_map_leds_ms: Optional[float] = Field(None, description="LED mapping time (ms)")
|
||||||
|
timing_smooth_ms: Optional[float] = Field(None, description="Smoothing time (ms)")
|
||||||
|
timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)")
|
||||||
|
timing_total_ms: Optional[float] = Field(None, description="Total processing time (ms)")
|
||||||
display_index: int = Field(default=0, description="Current display index")
|
display_index: int = Field(default=0, description="Current display index")
|
||||||
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
last_update: Optional[datetime] = Field(None, description="Last successful update")
|
||||||
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
errors: List[str] = Field(default_factory=list, description="Recent errors")
|
||||||
@@ -138,6 +143,7 @@ class TargetProcessingState(BaseModel):
|
|||||||
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
device_led_count: Optional[int] = Field(None, description="LED count reported by device")
|
||||||
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
device_rgbw: Optional[bool] = Field(None, description="Whether device uses RGBW LEDs")
|
||||||
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
device_led_type: Optional[str] = Field(None, description="LED chip type (e.g. WS2812B, SK6812 RGBW)")
|
||||||
|
device_fps: Optional[int] = Field(None, description="Device-reported FPS (WLED internal refresh rate)")
|
||||||
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
device_last_checked: Optional[datetime] = Field(None, description="Last health check time")
|
||||||
device_error: Optional[str] = Field(None, description="Last health check error")
|
device_error: Optional[str] = Field(None, description="Last health check error")
|
||||||
|
|
||||||
|
|||||||
@@ -116,10 +116,15 @@ class AdalightClient(LEDClient):
|
|||||||
|
|
||||||
async def send_pixels(
|
async def send_pixels(
|
||||||
self,
|
self,
|
||||||
pixels: List[Tuple[int, int, int]],
|
pixels,
|
||||||
brightness: int = 255,
|
brightness: int = 255,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send pixel data over serial using Adalight protocol (non-blocking)."""
|
"""Send pixel data over serial using Adalight protocol (non-blocking).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||||
|
brightness: Global brightness (0-255)
|
||||||
|
"""
|
||||||
if not self.is_connected:
|
if not self.is_connected:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -136,8 +141,11 @@ class AdalightClient(LEDClient):
|
|||||||
# Serial write is blocking — use async send_pixels path instead
|
# Serial write is blocking — use async send_pixels path instead
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _build_frame(self, pixels: List[Tuple[int, int, int]], brightness: int) -> bytes:
|
def _build_frame(self, pixels, brightness: int) -> bytes:
|
||||||
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
|
"""Build a complete Adalight frame: header + brightness-scaled RGB data."""
|
||||||
|
if isinstance(pixels, np.ndarray):
|
||||||
|
arr = pixels.astype(np.uint16)
|
||||||
|
else:
|
||||||
arr = np.array(pixels, dtype=np.uint16)
|
arr = np.array(pixels, dtype=np.uint16)
|
||||||
|
|
||||||
if brightness < 255:
|
if brightness < 255:
|
||||||
|
|||||||
@@ -255,14 +255,14 @@ class PixelMapper:
|
|||||||
def map_border_to_leds(
|
def map_border_to_leds(
|
||||||
self,
|
self,
|
||||||
border_pixels: BorderPixels
|
border_pixels: BorderPixels
|
||||||
) -> List[Tuple[int, int, int]]:
|
) -> np.ndarray:
|
||||||
"""Map screen border pixels to LED colors.
|
"""Map screen border pixels to LED colors.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
border_pixels: Extracted border pixels from screen
|
border_pixels: Extracted border pixels from screen
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of (R, G, B) tuples for each LED
|
numpy array of shape (total_leds, 3), dtype uint8
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If border pixels don't match calibration
|
ValueError: If border pixels don't match calibration
|
||||||
@@ -338,7 +338,7 @@ class PixelMapper:
|
|||||||
elif active_count <= 0:
|
elif active_count <= 0:
|
||||||
led_array[:] = 0
|
led_array[:] = 0
|
||||||
|
|
||||||
return [tuple(c) for c in led_array]
|
return led_array
|
||||||
else:
|
else:
|
||||||
if offset > 0:
|
if offset > 0:
|
||||||
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
|
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
|
||||||
@@ -358,7 +358,7 @@ class PixelMapper:
|
|||||||
elif active_count <= 0:
|
elif active_count <= 0:
|
||||||
led_colors = [(0, 0, 0)] * total_leds
|
led_colors = [(0, 0, 0)] * total_leds
|
||||||
|
|
||||||
return led_colors
|
return np.array(led_colors, dtype=np.uint8)
|
||||||
|
|
||||||
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
||||||
"""Generate test pattern to light up specific edge.
|
"""Generate test pattern to light up specific edge.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class DeviceHealth:
|
|||||||
device_led_count: Optional[int] = None
|
device_led_count: Optional[int] = None
|
||||||
device_rgbw: Optional[bool] = None
|
device_rgbw: Optional[bool] = None
|
||||||
device_led_type: Optional[str] = None
|
device_led_type: Optional[str] = None
|
||||||
|
device_fps: Optional[int] = None
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from wled_controller.core.calibration import (
|
|||||||
from wled_controller.core.capture_engines.base import ScreenCapture
|
from wled_controller.core.capture_engines.base import ScreenCapture
|
||||||
from wled_controller.core.live_stream import LiveStream
|
from wled_controller.core.live_stream import LiveStream
|
||||||
from wled_controller.core.live_stream_manager import LiveStreamManager
|
from wled_controller.core.live_stream_manager import LiveStreamManager
|
||||||
from wled_controller.core.pixel_processor import smooth_colors
|
|
||||||
from wled_controller.core.screen_capture import (
|
from wled_controller.core.screen_capture import (
|
||||||
calculate_average_color,
|
calculate_average_color,
|
||||||
calculate_dominant_color,
|
calculate_dominant_color,
|
||||||
@@ -42,18 +41,32 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
|
|||||||
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
|
def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing):
|
||||||
"""All CPU-bound work for one WLED frame (runs in thread pool).
|
"""All CPU-bound work for one WLED frame (runs in thread pool).
|
||||||
|
|
||||||
Args:
|
Returns (led_colors, timing_ms) where led_colors is numpy array (N, 3) uint8
|
||||||
capture: ScreenCapture from live_stream.get_latest_frame()
|
and timing_ms is a dict with per-stage timing in milliseconds.
|
||||||
border_width: Border pixel width for extraction
|
|
||||||
pixel_mapper: PixelMapper for LED mapping
|
|
||||||
previous_colors: Previous frame colors for smoothing
|
|
||||||
smoothing: Smoothing factor (0-1)
|
|
||||||
"""
|
"""
|
||||||
|
t0 = time.perf_counter()
|
||||||
border_pixels = extract_border_pixels(capture, border_width)
|
border_pixels = extract_border_pixels(capture, border_width)
|
||||||
|
t1 = time.perf_counter()
|
||||||
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
|
led_colors = pixel_mapper.map_border_to_leds(border_pixels)
|
||||||
if previous_colors and smoothing > 0:
|
t2 = time.perf_counter()
|
||||||
led_colors = smooth_colors(led_colors, previous_colors, smoothing)
|
|
||||||
return led_colors
|
# Inline numpy smoothing — avoids list↔numpy round-trip
|
||||||
|
if previous_colors is not None and smoothing > 0 and len(previous_colors) == len(led_colors):
|
||||||
|
alpha = int(smoothing * 256)
|
||||||
|
led_colors = (
|
||||||
|
(256 - alpha) * led_colors.astype(np.uint16)
|
||||||
|
+ alpha * previous_colors.astype(np.uint16)
|
||||||
|
) >> 8
|
||||||
|
led_colors = led_colors.astype(np.uint8)
|
||||||
|
t3 = time.perf_counter()
|
||||||
|
|
||||||
|
timing_ms = {
|
||||||
|
"extract": (t1 - t0) * 1000,
|
||||||
|
"map_leds": (t2 - t1) * 1000,
|
||||||
|
"smooth": (t3 - t2) * 1000,
|
||||||
|
"total": (t3 - t0) * 1000,
|
||||||
|
}
|
||||||
|
return led_colors, timing_ms
|
||||||
|
|
||||||
|
|
||||||
def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing):
|
||||||
@@ -121,6 +134,12 @@ class ProcessingMetrics:
|
|||||||
fps_actual: float = 0.0
|
fps_actual: float = 0.0
|
||||||
fps_potential: float = 0.0
|
fps_potential: float = 0.0
|
||||||
fps_current: int = 0
|
fps_current: int = 0
|
||||||
|
# Per-stage timing (ms), averaged over last 10 frames
|
||||||
|
timing_extract_ms: float = 0.0
|
||||||
|
timing_map_leds_ms: float = 0.0
|
||||||
|
timing_smooth_ms: float = 0.0
|
||||||
|
timing_send_ms: float = 0.0
|
||||||
|
timing_total_ms: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -192,7 +211,7 @@ class ProcessorManager:
|
|||||||
Targets are registered for processing (streaming sources to devices).
|
Targets are registered for processing (streaming sources to devices).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None):
|
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None):
|
||||||
"""Initialize processor manager."""
|
"""Initialize processor manager."""
|
||||||
self._devices: Dict[str, DeviceState] = {}
|
self._devices: Dict[str, DeviceState] = {}
|
||||||
self._targets: Dict[str, TargetState] = {}
|
self._targets: Dict[str, TargetState] = {}
|
||||||
@@ -203,6 +222,7 @@ class ProcessorManager:
|
|||||||
self._capture_template_store = capture_template_store
|
self._capture_template_store = capture_template_store
|
||||||
self._pp_template_store = pp_template_store
|
self._pp_template_store = pp_template_store
|
||||||
self._pattern_template_store = pattern_template_store
|
self._pattern_template_store = pattern_template_store
|
||||||
|
self._device_store = device_store
|
||||||
self._live_stream_manager = LiveStreamManager(
|
self._live_stream_manager = LiveStreamManager(
|
||||||
picture_source_store, capture_template_store, pp_template_store
|
picture_source_store, capture_template_store, pp_template_store
|
||||||
)
|
)
|
||||||
@@ -346,6 +366,7 @@ class ProcessorManager:
|
|||||||
"device_led_count": h.device_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"device_rgbw": h.device_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"device_led_type": h.device_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
|
"device_fps": h.device_fps,
|
||||||
"error": h.error,
|
"error": h.error,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +386,7 @@ class ProcessorManager:
|
|||||||
"device_led_count": h.device_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"device_rgbw": h.device_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"device_led_type": h.device_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
|
"device_fps": h.device_fps,
|
||||||
"device_last_checked": h.last_checked,
|
"device_last_checked": h.last_checked,
|
||||||
"device_error": h.error,
|
"device_error": h.error,
|
||||||
"test_mode": ds.test_mode_active,
|
"test_mode": ds.test_mode_active,
|
||||||
@@ -648,6 +670,7 @@ class ProcessorManager:
|
|||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
standby_interval = settings.standby_interval
|
standby_interval = settings.standby_interval
|
||||||
fps_samples = []
|
fps_samples = []
|
||||||
|
timing_samples: collections.deque = collections.deque(maxlen=10) # per-stage timing
|
||||||
prev_frame_time_stamp = time.time()
|
prev_frame_time_stamp = time.time()
|
||||||
prev_capture = None # Track previous ScreenCapture for change detection
|
prev_capture = None # Track previous ScreenCapture for change detection
|
||||||
last_send_time = 0.0 # Timestamp of last DDP send (for keepalive)
|
last_send_time = 0.0 # Timestamp of last DDP send (for keepalive)
|
||||||
@@ -679,7 +702,7 @@ class ProcessorManager:
|
|||||||
# Skip processing + send if the frame hasn't changed
|
# Skip processing + send if the frame hasn't changed
|
||||||
if capture is prev_capture:
|
if capture is prev_capture:
|
||||||
# Keepalive: resend last colors to prevent device exiting live mode
|
# Keepalive: resend last colors to prevent device exiting live mode
|
||||||
if state.previous_colors and (loop_start - last_send_time) >= standby_interval:
|
if state.previous_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
||||||
if not state.is_running or state.led_client is None:
|
if not state.is_running or state.led_client is None:
|
||||||
break
|
break
|
||||||
brightness_value = int(led_brightness * 255)
|
brightness_value = int(led_brightness * 255)
|
||||||
@@ -701,7 +724,7 @@ class ProcessorManager:
|
|||||||
prev_capture = capture
|
prev_capture = capture
|
||||||
|
|
||||||
# CPU-bound work in thread pool
|
# CPU-bound work in thread pool
|
||||||
led_colors = await asyncio.to_thread(
|
led_colors, frame_timing = await asyncio.to_thread(
|
||||||
_process_frame,
|
_process_frame,
|
||||||
capture, border_width,
|
capture, border_width,
|
||||||
state.pixel_mapper, state.previous_colors, smoothing,
|
state.pixel_mapper, state.previous_colors, smoothing,
|
||||||
@@ -711,17 +734,36 @@ class ProcessorManager:
|
|||||||
if not state.is_running or state.led_client is None:
|
if not state.is_running or state.led_client is None:
|
||||||
break
|
break
|
||||||
brightness_value = int(led_brightness * 255)
|
brightness_value = int(led_brightness * 255)
|
||||||
|
t_send_start = time.perf_counter()
|
||||||
if state.led_client.supports_fast_send:
|
if state.led_client.supports_fast_send:
|
||||||
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
state.led_client.send_pixels_fast(led_colors, brightness=brightness_value)
|
||||||
else:
|
else:
|
||||||
await state.led_client.send_pixels(led_colors, brightness=brightness_value)
|
await state.led_client.send_pixels(led_colors, brightness=brightness_value)
|
||||||
|
send_ms = (time.perf_counter() - t_send_start) * 1000
|
||||||
last_send_time = time.time()
|
last_send_time = time.time()
|
||||||
send_timestamps.append(last_send_time)
|
send_timestamps.append(last_send_time)
|
||||||
|
|
||||||
|
# Per-stage timing (rolling average over last 10 frames)
|
||||||
|
frame_timing["send"] = send_ms
|
||||||
|
timing_samples.append(frame_timing)
|
||||||
|
n = len(timing_samples)
|
||||||
|
state.metrics.timing_extract_ms = sum(s["extract"] for s in timing_samples) / n
|
||||||
|
state.metrics.timing_map_leds_ms = sum(s["map_leds"] for s in timing_samples) / n
|
||||||
|
state.metrics.timing_smooth_ms = sum(s["smooth"] for s in timing_samples) / n
|
||||||
|
state.metrics.timing_send_ms = sum(s["send"] for s in timing_samples) / n
|
||||||
|
state.metrics.timing_total_ms = sum(s["total"] for s in timing_samples) / n + send_ms
|
||||||
|
|
||||||
# Update metrics
|
# Update metrics
|
||||||
state.metrics.frames_processed += 1
|
state.metrics.frames_processed += 1
|
||||||
if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0:
|
if state.metrics.frames_processed <= 3 or state.metrics.frames_processed % 100 == 0:
|
||||||
logger.info(f"Frame {state.metrics.frames_processed} sent for target {target_id} ({len(led_colors)} LEDs, bri={brightness_value})")
|
logger.info(
|
||||||
|
f"Frame {state.metrics.frames_processed} for {target_id} "
|
||||||
|
f"({len(led_colors)} LEDs, bri={brightness_value}) — "
|
||||||
|
f"extract={frame_timing['extract']:.1f}ms "
|
||||||
|
f"map={frame_timing['map_leds']:.1f}ms "
|
||||||
|
f"smooth={frame_timing['smooth']:.1f}ms "
|
||||||
|
f"send={send_ms:.1f}ms"
|
||||||
|
)
|
||||||
state.metrics.last_update = datetime.utcnow()
|
state.metrics.last_update = datetime.utcnow()
|
||||||
state.previous_colors = led_colors
|
state.previous_colors = led_colors
|
||||||
|
|
||||||
@@ -784,6 +826,7 @@ class ProcessorManager:
|
|||||||
"device_led_count": h.device_led_count,
|
"device_led_count": h.device_led_count,
|
||||||
"device_rgbw": h.device_rgbw,
|
"device_rgbw": h.device_rgbw,
|
||||||
"device_led_type": h.device_led_type,
|
"device_led_type": h.device_led_type,
|
||||||
|
"device_fps": h.device_fps,
|
||||||
"device_last_checked": h.last_checked,
|
"device_last_checked": h.last_checked,
|
||||||
"device_error": h.error,
|
"device_error": h.error,
|
||||||
}
|
}
|
||||||
@@ -798,6 +841,11 @@ class ProcessorManager:
|
|||||||
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
"frames_skipped": metrics.frames_skipped if state.is_running else None,
|
||||||
"frames_keepalive": metrics.frames_keepalive if state.is_running else None,
|
"frames_keepalive": metrics.frames_keepalive if state.is_running else None,
|
||||||
"fps_current": metrics.fps_current if state.is_running else None,
|
"fps_current": metrics.fps_current if state.is_running else None,
|
||||||
|
"timing_extract_ms": round(metrics.timing_extract_ms, 1) if state.is_running else None,
|
||||||
|
"timing_map_leds_ms": round(metrics.timing_map_leds_ms, 1) if state.is_running else None,
|
||||||
|
"timing_smooth_ms": round(metrics.timing_smooth_ms, 1) if state.is_running else None,
|
||||||
|
"timing_send_ms": round(metrics.timing_send_ms, 1) if state.is_running else None,
|
||||||
|
"timing_total_ms": round(metrics.timing_total_ms, 1) if state.is_running else None,
|
||||||
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
|
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
|
||||||
"last_update": metrics.last_update,
|
"last_update": metrics.last_update,
|
||||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||||
@@ -1034,7 +1082,10 @@ class ProcessorManager:
|
|||||||
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
|
||||||
|
|
||||||
async def _check_device_health(self, device_id: str):
|
async def _check_device_health(self, device_id: str):
|
||||||
"""Check device health via the LED client abstraction."""
|
"""Check device health via the LED client abstraction.
|
||||||
|
|
||||||
|
Also auto-syncs LED count if the device reports a different value.
|
||||||
|
"""
|
||||||
state = self._devices.get(device_id)
|
state = self._devices.get(device_id)
|
||||||
if not state:
|
if not state:
|
||||||
return
|
return
|
||||||
@@ -1043,6 +1094,33 @@ class ProcessorManager:
|
|||||||
state.device_type, state.device_url, client, state.health,
|
state.device_type, state.device_url, client, state.health,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-sync LED count when device reports a different value
|
||||||
|
reported = state.health.device_led_count
|
||||||
|
if reported and reported != state.led_count and self._device_store:
|
||||||
|
old_count = state.led_count
|
||||||
|
logger.info(
|
||||||
|
f"Device {device_id} LED count changed: {old_count} → {reported}, "
|
||||||
|
f"updating calibration"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Update persistent storage (creates new default calibration)
|
||||||
|
device = self._device_store.update_device(device_id, led_count=reported)
|
||||||
|
# Sync in-memory state
|
||||||
|
state.led_count = reported
|
||||||
|
state.calibration = device.calibration
|
||||||
|
# Update any active targets using this device
|
||||||
|
for ts in self._targets.values():
|
||||||
|
if ts.device_id == device_id:
|
||||||
|
ts.led_count = reported
|
||||||
|
ts.calibration = device.calibration
|
||||||
|
if ts.pixel_mapper:
|
||||||
|
ts.pixel_mapper = PixelMapper(
|
||||||
|
device.calibration,
|
||||||
|
interpolation_mode=ts.settings.interpolation_mode,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sync LED count for {device_id}: {e}")
|
||||||
|
|
||||||
# ===== KEY COLORS TARGET MANAGEMENT =====
|
# ===== KEY COLORS TARGET MANAGEMENT =====
|
||||||
|
|
||||||
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
|
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
|
||||||
|
|||||||
@@ -435,25 +435,28 @@ class WLEDClient(LEDClient):
|
|||||||
|
|
||||||
def send_pixels_fast(
|
def send_pixels_fast(
|
||||||
self,
|
self,
|
||||||
pixels: List[Tuple[int, int, int]],
|
pixels,
|
||||||
brightness: int = 255,
|
brightness: int = 255,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Optimized send for the hot loop — numpy packing + brightness, fire-and-forget DDP.
|
"""Optimized send for the hot loop — fire-and-forget DDP.
|
||||||
|
|
||||||
|
Accepts numpy array (N,3) uint8 directly to avoid conversion overhead.
|
||||||
Synchronous (no await). Only works for DDP path.
|
Synchronous (no await). Only works for DDP path.
|
||||||
Falls back to raising if DDP is not available.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pixels: List of (R, G, B) tuples
|
pixels: numpy array (N, 3) uint8 or list of (R, G, B) tuples
|
||||||
brightness: Global brightness (0-255)
|
brightness: Global brightness (0-255)
|
||||||
"""
|
"""
|
||||||
if not self.use_ddp or not self._ddp_client:
|
if not self.use_ddp or not self._ddp_client:
|
||||||
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
|
raise RuntimeError("send_pixels_fast requires DDP; use send_pixels for HTTP")
|
||||||
|
|
||||||
|
if isinstance(pixels, np.ndarray):
|
||||||
|
pixel_array = pixels
|
||||||
|
else:
|
||||||
pixel_array = np.array(pixels, dtype=np.uint8)
|
pixel_array = np.array(pixels, dtype=np.uint8)
|
||||||
|
|
||||||
if brightness < 255:
|
if brightness < 255:
|
||||||
pixel_array = (pixel_array.astype(np.float32) * (brightness / 255.0)).astype(np.uint8)
|
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
|
||||||
|
|
||||||
self._ddp_client.send_pixels_numpy(pixel_array)
|
self._ddp_client.send_pixels_numpy(pixel_array)
|
||||||
|
|
||||||
@@ -530,6 +533,7 @@ class WLEDClient(LEDClient):
|
|||||||
device_led_count=leds_info.get("count"),
|
device_led_count=leds_info.get("count"),
|
||||||
device_rgbw=leds_info.get("rgbw", False),
|
device_rgbw=leds_info.get("rgbw", False),
|
||||||
device_led_type=device_led_type,
|
device_led_type=device_led_type,
|
||||||
|
device_fps=leds_info.get("fps"),
|
||||||
error=None,
|
error=None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -542,6 +546,7 @@ class WLEDClient(LEDClient):
|
|||||||
device_led_count=prev_health.device_led_count if prev_health else None,
|
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||||
device_rgbw=prev_health.device_rgbw if prev_health else None,
|
device_rgbw=prev_health.device_rgbw if prev_health else None,
|
||||||
device_led_type=prev_health.device_led_type if prev_health else None,
|
device_led_type=prev_health.device_led_type if prev_health else None,
|
||||||
|
device_fps=prev_health.device_fps if prev_health else None,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ processor_manager = ProcessorManager(
|
|||||||
capture_template_store=template_store,
|
capture_template_store=template_store,
|
||||||
pp_template_store=pp_template_store,
|
pp_template_store=pp_template_store,
|
||||||
pattern_template_store=pattern_template_store,
|
pattern_template_store=pattern_template_store,
|
||||||
|
device_store=device_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4669,6 +4669,26 @@ function createTargetCard(target, deviceMap, sourceMap) {
|
|||||||
<div class="metric-value">${metrics.errors_count || 0}</div>
|
<div class="metric-value">${metrics.errors_count || 0}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${state.timing_total_ms != null ? `
|
||||||
|
<div class="timing-breakdown">
|
||||||
|
<div class="timing-header">
|
||||||
|
<div class="metric-label">${t('device.metrics.timing')}</div>
|
||||||
|
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="timing-bar">
|
||||||
|
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
|
||||||
|
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
|
||||||
|
<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>
|
||||||
|
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
|
||||||
|
</div>
|
||||||
|
<div class="timing-legend">
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>
|
||||||
|
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
|||||||
@@ -150,6 +150,8 @@
|
|||||||
"device.metrics.frames_skipped": "Skipped",
|
"device.metrics.frames_skipped": "Skipped",
|
||||||
"device.metrics.keepalive": "Keepalive",
|
"device.metrics.keepalive": "Keepalive",
|
||||||
"device.metrics.errors": "Errors",
|
"device.metrics.errors": "Errors",
|
||||||
|
"device.metrics.timing": "Pipeline timing:",
|
||||||
|
"device.metrics.device_fps": "Device refresh rate",
|
||||||
"device.health.online": "Online",
|
"device.health.online": "Online",
|
||||||
"device.health.offline": "Offline",
|
"device.health.offline": "Offline",
|
||||||
"device.health.checking": "Checking...",
|
"device.health.checking": "Checking...",
|
||||||
|
|||||||
@@ -150,6 +150,8 @@
|
|||||||
"device.metrics.frames_skipped": "Пропущено",
|
"device.metrics.frames_skipped": "Пропущено",
|
||||||
"device.metrics.keepalive": "Keepalive",
|
"device.metrics.keepalive": "Keepalive",
|
||||||
"device.metrics.errors": "Ошибки",
|
"device.metrics.errors": "Ошибки",
|
||||||
|
"device.metrics.timing": "Тайминг пайплайна:",
|
||||||
|
"device.metrics.device_fps": "Частота обновления устройства",
|
||||||
"device.health.online": "Онлайн",
|
"device.health.online": "Онлайн",
|
||||||
"device.health.offline": "Недоступен",
|
"device.health.offline": "Недоступен",
|
||||||
"device.health.checking": "Проверка...",
|
"device.health.checking": "Проверка...",
|
||||||
|
|||||||
@@ -962,6 +962,71 @@ input:-webkit-autofill:focus {
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Timing breakdown bar */
|
||||||
|
.timing-breakdown {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-total {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-bar {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 4px 0;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-seg {
|
||||||
|
min-width: 2px;
|
||||||
|
transition: flex 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-extract { background: #4CAF50; }
|
||||||
|
.timing-map { background: #FF9800; }
|
||||||
|
.timing-smooth { background: #2196F3; }
|
||||||
|
.timing-send { background: #E91E63; }
|
||||||
|
|
||||||
|
.timing-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timing-dot.timing-extract { background: #4CAF50; }
|
||||||
|
.timing-dot.timing-map { background: #FF9800; }
|
||||||
|
.timing-dot.timing-smooth { background: #2196F3; }
|
||||||
|
.timing-dot.timing-send { background: #E91E63; }
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal {
|
.modal {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user