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

@@ -331,16 +331,29 @@ async def get_device_brightness(
store: DeviceStore = Depends(get_device_store), store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager), 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) device = store.get_device(device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
if "brightness_control" not in get_device_capabilities(device.device_type): 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") 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: try:
provider = get_provider(device.device_type) provider = get_provider(device.device_type)
bri = await provider.get_brightness(device.url) 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} return {"brightness": bri}
except NotImplementedError: except NotImplementedError:
# Provider has no hardware brightness; use software brightness # Provider has no hardware brightness; use software brightness
@@ -381,8 +394,12 @@ async def set_device_brightness(
if device_id in manager._devices: if device_id in manager._devices:
manager._devices[device_id].software_brightness = bri 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) 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): if ds and ds.static_color is not None and not manager.is_device_processing(device_id):
try: try:
await manager.send_static_color(device_id, ds.static_color) await manager.send_static_color(device_id, ds.static_color)

View File

@@ -43,6 +43,8 @@ class DeviceState:
health_task: Optional[asyncio.Task] = None health_task: Optional[asyncio.Task] = None
# Software brightness for devices without hardware brightness (e.g. Adalight) # Software brightness for devices without hardware brightness (e.g. Adalight)
software_brightness: int = 255 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-restore: restore device to idle state when targets stop
auto_shutdown: bool = False auto_shutdown: bool = False
# Static idle color for devices without a rich editor (e.g. Adalight) # 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. # frame and wait a full frame_time, periodically halving the send rate.
SKIP_REPOLL = 0.005 # 5 ms 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( logger.info(
f"Processing loop started for target {self._target_id} " f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})" 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 target_fps = stream.target_fps if stream.target_fps > 0 else 30
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
# Re-fetch device info for runtime changes (test mode, brightness) # Re-fetch device info every ~30 iterations instead of every
device_info = self._ctx.get_device_info(self._device_id) # 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 # Skip send while in calibration test mode
if device_info and device_info.test_mode_active: if device_info and device_info.test_mode_active:
@@ -478,7 +494,54 @@ class WledTargetProcessor(TargetProcessor):
elapsed = now - loop_start elapsed = now - loop_start
remaining = frame_time - elapsed remaining = frame_time - elapsed
if remaining > 0: if remaining > 0:
t_sleep_start = time.perf_counter()
await asyncio.sleep(remaining) 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: except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {self._target_id}") logger.info(f"Processing loop cancelled for target {self._target_id}")

View File

@@ -288,7 +288,10 @@ export async function saveCardBrightness(deviceId, value) {
} }
} }
const _brightnessFetchInFlight = new Set();
export async function fetchDeviceBrightness(deviceId) { export async function fetchDeviceBrightness(deviceId) {
if (_brightnessFetchInFlight.has(deviceId)) return;
_brightnessFetchInFlight.add(deviceId);
try { try {
const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, { const resp = await fetch(`${API_BASE}/devices/${deviceId}/brightness`, {
headers: getHeaders() headers: getHeaders()
@@ -306,6 +309,8 @@ export async function fetchDeviceBrightness(deviceId) {
if (wrap) wrap.classList.remove('brightness-loading'); if (wrap) wrap.classList.remove('brightness-loading');
} catch (err) { } catch (err) {
// Silently fail — device may be offline // Silently fail — device may be offline
} finally {
_brightnessFetchInFlight.delete(deviceId);
} }
} }