From 7c0c064453fe53bfd3531b378ac3c45b21f4687b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 21 Feb 2026 02:43:03 +0300 Subject: [PATCH] Fix FPS drops caused by brightness endpoint polling WLED device The GET /devices/{id}/brightness endpoint was making an HTTP request to the ESP32 over WiFi on every frontend poll (~3s), causing 150ms async event loop jitter that froze the LED processing loop. Cache brightness server-side after first fetch/set, add frontend dedup guard, reduce get_device_info() frequency, and add processing loop timing diagnostics. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/api/routes/devices.py | 21 +++++- .../core/processing/processor_manager.py | 2 + .../core/processing/wled_target_processor.py | 67 ++++++++++++++++++- .../static/js/features/devices.js | 5 ++ 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 7d9afbc..88cc2ff 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -331,16 +331,29 @@ async def get_device_brightness( store: DeviceStore = Depends(get_device_store), manager: ProcessorManager = Depends(get_processor_manager), ): - """Get current brightness from the device.""" + """Get current brightness from the device. + + Uses a server-side cache to avoid polling the physical device on every + frontend request — hitting the ESP32 over WiFi in the async event loop + causes ~150 ms jitter in the processing loop. + """ device = store.get_device(device_id) if not device: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") if "brightness_control" not in get_device_capabilities(device.device_type): raise HTTPException(status_code=400, detail=f"Brightness control is not supported for {device.device_type} devices") + # Return cached hardware brightness if available (updated by SET endpoint) + ds = manager._devices.get(device_id) + if ds and ds.hardware_brightness is not None: + return {"brightness": ds.hardware_brightness} + try: provider = get_provider(device.device_type) bri = await provider.get_brightness(device.url) + # Cache the result so subsequent polls don't hit the device + if ds: + ds.hardware_brightness = bri return {"brightness": bri} except NotImplementedError: # Provider has no hardware brightness; use software brightness @@ -381,8 +394,12 @@ async def set_device_brightness( if device_id in manager._devices: manager._devices[device_id].software_brightness = bri - # If device is idle with a static color, re-send it at the new brightness + # Update cached hardware brightness ds = manager._devices.get(device_id) + if ds: + ds.hardware_brightness = bri + + # If device is idle with a static color, re-send it at the new brightness if ds and ds.static_color is not None and not manager.is_device_processing(device_id): try: await manager.send_static_color(device_id, ds.static_color) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 0cd5e25..79969ed 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -43,6 +43,8 @@ class DeviceState: health_task: Optional[asyncio.Task] = None # Software brightness for devices without hardware brightness (e.g. Adalight) software_brightness: int = 255 + # Cached hardware brightness (fetched once, updated via SET; avoids polling device) + hardware_brightness: Optional[int] = None # Auto-restore: restore device to idle state when targets stop auto_shutdown: bool = False # Static idle color for devices without a rich editor (e.g. Adalight) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 162468e..7b12d86 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -376,6 +376,15 @@ class WledTargetProcessor(TargetProcessor): # 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_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_device_info: Optional[DeviceInfo] = None + _diag_device_info_age = 0 # iterations since last refresh + logger.info( f"Processing loop started for target {self._target_id} " f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})" @@ -389,8 +398,15 @@ class WledTargetProcessor(TargetProcessor): target_fps = stream.target_fps if stream.target_fps > 0 else 30 frame_time = 1.0 / target_fps - # Re-fetch device info for runtime changes (test mode, brightness) - device_info = self._ctx.get_device_info(self._device_id) + # 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) + _diag_device_info_age = 0 + device_info = _diag_device_info # Skip send while in calibration test mode if device_info and device_info.test_mode_active: @@ -478,7 +494,54 @@ class WledTargetProcessor(TargetProcessor): elapsed = now - loop_start remaining = frame_time - elapsed if remaining > 0: + t_sleep_start = time.perf_counter() await asyncio.sleep(remaining) + t_sleep_end = time.perf_counter() + actual_sleep = (t_sleep_end - t_sleep_start) * 1000 + requested_sleep = remaining * 1000 + jitter = actual_sleep - requested_sleep + _diag_sleep_jitters.append((requested_sleep, actual_sleep)) + if jitter > 10.0: # >10ms overshoot + _diag_slow_iters.append(((t_sleep_end - loop_start) * 1000, "sleep_jitter")) + + # 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 "sleep_jitter" not in [s[1] for s in _diag_slow_iters[-1:]]: + _diag_slow_iters.append((iter_ms, "slow_iter")) + + # Periodic diagnostics report + if iter_end >= _diag_next_report: + _diag_next_report = iter_end + _diag_interval + if _diag_sleep_jitters: + jitters = [a - r for r, a in _diag_sleep_jitters] + avg_j = sum(jitters) / len(jitters) + max_j = max(jitters) + p95_j = sorted(jitters)[int(len(jitters) * 0.95)] if len(jitters) >= 20 else max_j + logger.info( + f"[DIAG] {self._target_id} sleep jitter: " + f"avg={avg_j:.1f}ms max={max_j:.1f}ms p95={p95_j:.1f}ms " + f"(n={len(_diag_sleep_jitters)})" + ) + if _diag_iter_times: + avg_iter = sum(_diag_iter_times) / len(_diag_iter_times) + max_iter = max(_diag_iter_times) + logger.info( + f"[DIAG] {self._target_id} iter: " + f"avg={avg_iter:.1f}ms max={max_iter:.1f}ms " + f"target={frame_time*1000:.1f}ms iters={len(_diag_iter_times)}" + ) + if _diag_slow_iters: + logger.warning( + f"[DIAG] {self._target_id} slow iterations: " + f"{len(_diag_slow_iters)} in last {_diag_interval}s — " + f"{_diag_slow_iters[:5]}" + ) + _diag_sleep_jitters.clear() + _diag_slow_iters.clear() + _diag_iter_times.clear() except asyncio.CancelledError: logger.info(f"Processing loop cancelled for target {self._target_id}") diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 5b44aa6..a97845a 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -288,7 +288,10 @@ export async function saveCardBrightness(deviceId, value) { } } +const _brightnessFetchInFlight = new Set(); export async function fetchDeviceBrightness(deviceId) { + if (_brightnessFetchInFlight.has(deviceId)) return; + _brightnessFetchInFlight.add(deviceId); try { const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { headers: getHeaders() @@ -306,6 +309,8 @@ export async function fetchDeviceBrightness(deviceId) { if (wrap) wrap.classList.remove('brightness-loading'); } catch (err) { // Silently fail — device may be offline + } finally { + _brightnessFetchInFlight.delete(deviceId); } }