Backend performance and code quality improvements

Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:06:29 +03:00
parent 1f047d6561
commit cdba98813b
37 changed files with 296 additions and 137 deletions
@@ -398,8 +398,8 @@ class AutomationEngine:
"automation_id": automation_id,
"action": action,
})
except Exception:
pass
except Exception as e:
logger.error("Automation action failed: %s", e, exc_info=True)
# ===== Public query methods (used by API) =====
@@ -168,9 +168,7 @@ class AdalightClient(LEDClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
np.clip(arr, 0, 255, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
return self._header + rgb_bytes
@@ -40,9 +40,7 @@ class AmbiLEDClient(AdalightClient):
else:
arr = np.array(pixels, dtype=np.uint16)
if brightness < 255:
arr = arr * brightness // 255
# Note: brightness already applied by processor loop (_cached_brightness)
# Clamp to 0250: values >250 are command bytes in AmbiLED protocol
np.clip(arr, 0, 250, out=arr)
rgb_bytes = arr.astype(np.uint8).tobytes()
@@ -145,7 +145,7 @@ class ChromaClient(LEDClient):
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
device_info = CHROMA_DEVICES.get(self._chroma_device_type)
if not device_info:
return False
@@ -156,10 +156,7 @@ class ChromaClient(LEDClient):
# Chroma uses BGR packed as 0x00BBGGRR integers
colors = []
for i in range(n):
r, g, b = pixel_arr[i]
r = int(r * bri_scale)
g = int(g * bri_scale)
b = int(b * bri_scale)
r, g, b = int(pixel_arr[i][0]), int(pixel_arr[i][1]), int(pixel_arr[i][2])
colors.append(r | (g << 8) | (b << 16))
# Pad to max_leds if needed
@@ -115,7 +115,8 @@ class ESPNowClient(LEDClient):
else:
pixel_bytes = bytes(c for rgb in pixels for c in rgb)
frame = _build_frame(self._peer_mac, pixel_bytes, brightness)
# Note: brightness already applied by processor loop; pass 255 to firmware
frame = _build_frame(self._peer_mac, pixel_bytes, 255)
try:
self._serial.write(frame)
except Exception as e:
@@ -187,7 +187,7 @@ class GameSenseClient(LEDClient):
else:
pixel_arr = np.array(pixels, dtype=np.uint8)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
# Use average color for single-zone devices, or first N for multi-zone
if len(pixel_arr) == 0:
@@ -195,9 +195,9 @@ class GameSenseClient(LEDClient):
# Compute average color for the zone
avg = pixel_arr.mean(axis=0)
r = int(avg[0] * bri_scale)
g = int(avg[1] * bri_scale)
b = int(avg[2] * bri_scale)
r = int(avg[0])
g = int(avg[1])
b = int(avg[2])
event_data = {
"game": GAME_NAME,
@@ -46,13 +46,13 @@ def _build_entertainment_frame(
header[15] = 0x00 # reserved
# Light data
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
data = bytearray()
for idx, (r, g, b) in enumerate(lights):
light_id = idx # 0-based light index in entertainment group
r16 = int(r * bri_scale * 257) # scale 0-255 to 0-65535
g16 = int(g * bri_scale * 257)
b16 = int(b * bri_scale * 257)
r16 = int(r * 257) # scale 0-255 to 0-65535
g16 = int(g * 257)
b16 = int(b * 257)
data += struct.pack(">BHHH", light_id, r16, g16, b16)
return bytes(header) + bytes(data)
@@ -302,9 +302,7 @@ class OpenRGBLEDClient(LEDClient):
return
self._last_sent_pixels = pixel_array.copy()
# Apply brightness scaling after dedup
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
# Separate mode: resample full pixel array independently per zone
if self._zone_mode == "separate" and len(self._target_zones) > 1:
@@ -162,7 +162,7 @@ class SPIClient(LEDClient):
if not self._connected:
return
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
if isinstance(pixels, np.ndarray):
pixel_arr = pixels
@@ -176,7 +176,7 @@ class SPIClient(LEDClient):
except ImportError:
return
self._strip.setBrightness(brightness)
self._strip.setBrightness(255)
for i in range(min(len(pixel_arr), self._led_count)):
r, g, b = pixel_arr[i]
self._strip.setPixelColor(i, Color(int(r), int(g), int(b)))
@@ -185,7 +185,7 @@ class SPIClient(LEDClient):
elif self._spi:
# SPI bitbang path: convert RGB to WS2812 wire format
# Each bit is encoded as 3 SPI bits: 1=110, 0=100
scaled = (pixel_arr[:self._led_count].astype(np.float32) * bri_scale).astype(np.uint8)
scaled = pixel_arr[:self._led_count]
# GRB order for WS2812
grb = scaled[:, [1, 0, 2]]
raw_bytes = grb.tobytes()
@@ -100,7 +100,7 @@ class USBHIDClient(LEDClient):
else:
pixel_list = list(pixels)
bri_scale = brightness / 255.0
# Note: brightness already applied by processor loop (_cached_brightness)
# Build HID reports — split across multiple reports if needed
# Each report: [REPORT_ID][CMD][OFFSET_LO][OFFSET_HI][COUNT][R G B R G B ...]
@@ -119,9 +119,9 @@ class USBHIDClient(LEDClient):
for i, (r, g, b) in enumerate(chunk):
base = 5 + i * 3
report[base] = int(r * bri_scale)
report[base + 1] = int(g * bri_scale)
report[base + 2] = int(b * bri_scale)
report[base] = int(r)
report[base + 1] = int(g)
report[base + 2] = int(b)
reports.append(bytes(report))
offset += len(chunk)
@@ -378,9 +378,7 @@ class WLEDClient(LEDClient):
True if successful
"""
try:
if brightness < 255:
pixels = (pixels.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
self._ddp_client.send_pixels_numpy(pixels)
logger.debug(f"Successfully sent pixel colors via DDP")
@@ -419,7 +417,7 @@ class WLEDClient(LEDClient):
# Build WLED JSON state
payload = {
"on": True,
"bri": int(brightness),
"bri": 255, # brightness already applied by processor loop
"seg": [
{
"id": segment_id,
@@ -461,9 +459,7 @@ class WLEDClient(LEDClient):
else:
pixel_array = np.array(pixels, dtype=np.uint8)
if brightness < 255:
pixel_array = (pixel_array.astype(np.uint16) * brightness >> 8).astype(np.uint8)
# Note: brightness already applied by processor loop (_cached_brightness)
self._ddp_client.send_pixels_numpy(pixel_array)
# ===== LEDClient abstraction methods =====
@@ -92,7 +92,11 @@ class ApiInputColorStripStream(ColorStripStream):
if n > self._led_count:
self._ensure_capacity(n)
if n == self._led_count:
self._colors = colors.astype(np.uint8)
if self._colors.shape == colors.shape:
np.copyto(self._colors, colors, casting='unsafe')
else:
self._colors = np.empty((n, 3), dtype=np.uint8)
np.copyto(self._colors, colors, casting='unsafe')
elif n < self._led_count:
# Zero-pad to led_count
padded = np.zeros((self._led_count, 3), dtype=np.uint8)
@@ -48,12 +48,22 @@ class CompositeColorStripStream(ColorStripStream):
self._latest_colors: Optional[np.ndarray] = None
self._latest_layer_colors: Optional[List[np.ndarray]] = None
self._colors_lock = threading.Lock()
self._need_layer_snapshots: bool = False # set True when get_layer_colors() is called
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
# layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
self._sub_streams_version: int = 0 # bumped when _sub_streams changes
self._sub_snapshot_version: int = -1 # version of cached snapshot
self._sub_snapshot_cache: Dict[int, tuple] = {} # cached dict(self._sub_streams)
# Pre-resolved blend methods: blend_mode_str -> bound method
self._blend_methods = {
k: getattr(self, v) for k, v in self._BLEND_DISPATCH.items()
}
self._default_blend_method = self._blend_normal
# Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0
@@ -111,6 +121,7 @@ class CompositeColorStripStream(ColorStripStream):
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
self._need_layer_snapshots = True
with self._colors_lock:
return self._latest_layer_colors
@@ -165,6 +176,7 @@ class CompositeColorStripStream(ColorStripStream):
# ── Sub-stream lifecycle ────────────────────────────────────
def _acquire_sub_streams(self) -> None:
self._sub_streams_version += 1
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
continue
@@ -193,6 +205,7 @@ class CompositeColorStripStream(ColorStripStream):
)
def _release_sub_streams(self) -> None:
self._sub_streams_version += 1
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
try:
self._css_manager.release(src_id, consumer_id)
@@ -356,7 +369,10 @@ class CompositeColorStripStream(ColorStripStream):
layer_snapshots: List[np.ndarray] = []
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
if self._sub_streams_version != self._sub_snapshot_version:
self._sub_snapshot_cache = dict(self._sub_streams)
self._sub_snapshot_version = self._sub_streams_version
sub_snapshot = self._sub_snapshot_cache
for i, layer in enumerate(self._layers):
if not layer.get("enabled", True):
@@ -412,7 +428,8 @@ class CompositeColorStripStream(ColorStripStream):
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
# Snapshot layer colors before blending (copy — may alias shared buf)
layer_snapshots.append(colors.copy())
if self._need_layer_snapshots:
layer_snapshots.append(colors.copy())
opacity = layer.get("opacity", 1.0)
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
@@ -425,11 +442,11 @@ class CompositeColorStripStream(ColorStripStream):
result_buf[:] = colors
else:
result_buf[:] = 0
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
has_result = True
else:
blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal"))
blend_fn = self._blend_methods.get(blend_mode, self._default_blend_method)
blend_fn(result_buf, colors, alpha, result_buf)
if has_result:
@@ -91,7 +91,8 @@ class MetricsHistory:
# Per-target metrics from processor states
try:
all_states = self._manager.get_all_target_states()
except Exception:
except Exception as e:
logger.error("Failed to get target states: %s", e)
all_states = {}
now = datetime.now(timezone.utc).isoformat()
@@ -43,6 +43,7 @@ class ProcessingMetrics:
errors_count: int = 0
last_error: Optional[str] = None
last_update: Optional[datetime] = None
last_update_mono: float = 0.0 # monotonic timestamp for hot-path; lazily converted to last_update on read
start_time: Optional[datetime] = None
fps_actual: float = 0.0
fps_potential: float = 0.0
@@ -22,6 +22,7 @@ from __future__ import annotations
import math
import time
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
@@ -43,12 +44,13 @@ logger = get_logger(__name__)
# Base class
# ---------------------------------------------------------------------------
class ValueStream:
class ValueStream(ABC):
"""Abstract base for runtime value streams."""
@abstractmethod
def get_value(self) -> float:
"""Return current scalar value (0.01.0)."""
return 1.0
...
def start(self) -> None:
"""Acquire resources (if any)."""
@@ -382,6 +382,14 @@ class WledTargetProcessor(TargetProcessor):
else:
total_ms = None
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return {
"target_id": self._target_id,
"device_id": self._device_id,
@@ -405,7 +413,7 @@ class WledTargetProcessor(TargetProcessor):
"display_index": self._resolved_display_index,
"overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update,
"last_update": last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"device_streaming_reachable": self._device_reachable if self._is_running else None,
"fps_effective": self._effective_fps if self._is_running else None,
@@ -419,6 +427,14 @@ class WledTargetProcessor(TargetProcessor):
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.now(timezone.utc) - metrics.start_time).total_seconds()
# Lazily convert monotonic timestamp to UTC datetime for API
last_update = metrics.last_update
if metrics.last_update_mono > 0:
elapsed = time.monotonic() - metrics.last_update_mono
last_update = datetime.now(timezone.utc) if elapsed < 1.0 else datetime.fromtimestamp(
time.time() - elapsed, tz=timezone.utc
)
return {
"target_id": self._target_id,
"device_id": self._device_id,
@@ -429,7 +445,7 @@ class WledTargetProcessor(TargetProcessor):
"frames_processed": metrics.frames_processed,
"errors_count": metrics.errors_count,
"last_error": metrics.last_error,
"last_update": metrics.last_update,
"last_update": last_update,
}
# ----- Overlay -----
@@ -578,7 +594,8 @@ class WledTargetProcessor(TargetProcessor):
keepalive_interval = self._keepalive_interval
fps_samples: collections.deque = collections.deque(maxlen=10)
send_timestamps: collections.deque = collections.deque()
_fps_sum = 0.0
send_timestamps: collections.deque = collections.deque(maxlen=target_fps + 10)
last_send_time = 0.0
_last_preview_broadcast = 0.0
prev_frame_time_stamp = time.perf_counter()
@@ -728,7 +745,7 @@ class WledTargetProcessor(TargetProcessor):
has_any_frame = False
_diag_device_info_age += 1
if _diag_device_info is None or _diag_device_info_age >= 30:
if _diag_device_info is None or _diag_device_info_age >= 300:
_diag_device_info = self._ctx.get_device_info(self._device_id)
_diag_device_info_age = 0
device_info = _diag_device_info
@@ -822,8 +839,6 @@ class WledTargetProcessor(TargetProcessor):
send_timestamps.append(now)
self._metrics.frames_keepalive += 1
self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < loop_start - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(SKIP_REPOLL)
continue
@@ -849,8 +864,6 @@ class WledTargetProcessor(TargetProcessor):
await self._broadcast_led_preview(send_colors, cur_brightness)
_last_preview_broadcast = now
self._metrics.frames_skipped += 1
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
is_animated = stream.is_animated
repoll = SKIP_REPOLL if is_animated else frame_time
@@ -888,7 +901,7 @@ class WledTargetProcessor(TargetProcessor):
self._metrics.timing_send_ms = send_ms
self._metrics.frames_processed += 1
self._metrics.last_update = datetime.now(timezone.utc)
self._metrics.last_update_mono = time.monotonic()
if self._metrics.frames_processed <= 3 or self._metrics.frames_processed % 100 == 0:
logger.info(
@@ -900,14 +913,16 @@ class WledTargetProcessor(TargetProcessor):
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
if self._metrics.frames_processed > 1:
fps_samples.append(1.0 / interval if interval > 0 else 0)
self._metrics.fps_actual = sum(fps_samples) / len(fps_samples)
new_fps = 1.0 / interval if interval > 0 else 0
if len(fps_samples) == fps_samples.maxlen:
_fps_sum -= fps_samples[0]
fps_samples.append(new_fps)
_fps_sum += new_fps
self._metrics.fps_actual = _fps_sum / len(fps_samples)
processing_time = now - loop_start
self._metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
except Exception as e: