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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user