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 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 02:43:03 +03:00
parent b14da85f3b
commit 7c0c064453
4 changed files with 91 additions and 4 deletions

View File

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

View File

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