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