Add adaptive FPS and honest device reachability during streaming

DDP uses fire-and-forget UDP, so when a WiFi device becomes overwhelmed
by sustained traffic, sends appear successful while the device is
actually unreachable. This adds:

- HTTP liveness probe (GET /json/info, 2s timeout) every 10s during
  streaming, exposed as device_streaming_reachable in target state
- Adaptive FPS (opt-in): exponential backoff when device is unreachable,
  gradual recovery when it stabilizes — finds sustainable send rate
- Honest health checks: removed the lie that forced device_online=true
  during streaming; now runs actual health checks regardless
- Target editor toggle, FPS display shows effective rate when throttled,
  health dot reflects streaming reachability, red highlight when
  unreachable
- Auto-backup scheduling support in settings modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 20:22:58 +03:00
parent f8656b72a6
commit cadef971e7
23 changed files with 873 additions and 21 deletions

View File

@@ -322,6 +322,7 @@ class ProcessorManager:
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
brightness_value_source_id: str = "",
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
):
"""Register a WLED target processor."""
if target_id in self._processors:
@@ -338,6 +339,7 @@ class ProcessorManager:
state_check_interval=state_check_interval,
brightness_value_source_id=brightness_value_source_id,
min_brightness_threshold=min_brightness_threshold,
adaptive_fps=adaptive_fps,
ctx=self._build_context(),
)
self._processors[target_id] = proc
@@ -834,11 +836,7 @@ class ProcessorManager:
try:
while self._health_monitoring_active:
if not self._device_is_processing(device_id):
await self._check_device_health(device_id)
else:
if state.health:
state.health.online = True
await self._check_device_health(device_id)
await asyncio.sleep(check_interval)
except asyncio.CancelledError:
pass

View File

@@ -56,6 +56,9 @@ class ProcessingMetrics:
# KC targets
timing_calc_colors_ms: float = 0.0
timing_broadcast_ms: float = 0.0
# Streaming liveness (HTTP probe during DDP)
device_streaming_reachable: Optional[bool] = None
fps_effective: int = 0
@dataclass

View File

@@ -8,6 +8,7 @@ import time
from datetime import datetime
from typing import Optional
import httpx
import numpy as np
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
@@ -37,6 +38,7 @@ class WledTargetProcessor(TargetProcessor):
state_check_interval: int = 30,
brightness_value_source_id: str = "",
min_brightness_threshold: int = 0,
adaptive_fps: bool = False,
ctx: TargetContext = None,
):
super().__init__(target_id, ctx)
@@ -47,6 +49,11 @@ class WledTargetProcessor(TargetProcessor):
self._css_id = color_strip_source_id
self._brightness_vs_id = brightness_value_source_id
self._min_brightness_threshold = min_brightness_threshold
self._adaptive_fps = adaptive_fps
# Adaptive FPS / liveness probe runtime state
self._effective_fps: int = self._target_fps
self._device_reachable: Optional[bool] = None # None = not yet probed
# Runtime state (populated on start)
self._led_client: Optional[LEDClient] = None
@@ -60,6 +67,8 @@ class WledTargetProcessor(TargetProcessor):
# LED preview WebSocket clients
self._preview_clients: list = []
self._last_preview_colors: np.ndarray | None = None
self._last_preview_brightness: int = 255
# ----- Properties -----
@@ -205,6 +214,7 @@ class WledTargetProcessor(TargetProcessor):
if isinstance(settings, dict):
if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
self._effective_fps = self._target_fps # reset adaptive
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._is_running and self._css_id:
css_manager.notify_target_fps(self._css_id, self._target_id, self._target_fps)
@@ -214,6 +224,10 @@ class WledTargetProcessor(TargetProcessor):
self._state_check_interval = settings["state_check_interval"]
if "min_brightness_threshold" in settings:
self._min_brightness_threshold = settings["min_brightness_threshold"]
if "adaptive_fps" in settings:
self._adaptive_fps = settings["adaptive_fps"]
if not self._adaptive_fps:
self._effective_fps = self._target_fps
logger.info(f"Updated settings for target {self._target_id}")
def update_device(self, device_id: str) -> None:
@@ -285,6 +299,14 @@ class WledTargetProcessor(TargetProcessor):
logger.info(f"Hot-swapped brightness VS for {self._target_id}: {old_vs_id} -> {vs_id}")
async def _probe_device(self, device_url: str, client: httpx.AsyncClient) -> bool:
"""HTTP liveness probe — lightweight GET to check if device is reachable."""
try:
resp = await client.get(f"{device_url}/json/info")
return resp.status_code == 200
except Exception:
return False
def get_display_index(self) -> Optional[int]:
"""Display index being captured, from the active stream."""
if self._resolved_display_index is not None:
@@ -349,6 +371,8 @@ class WledTargetProcessor(TargetProcessor):
"needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"device_streaming_reachable": self._device_reachable if self._is_running else None,
"fps_effective": self._effective_fps if self._is_running else None,
}
def get_metrics(self) -> dict:
@@ -432,6 +456,17 @@ class WledTargetProcessor(TargetProcessor):
def add_led_preview_client(self, ws) -> None:
self._preview_clients.append(ws)
# Send last known frame immediately so late joiners see current state
if self._last_preview_colors is not None:
data = bytes([self._last_preview_brightness]) + self._last_preview_colors.astype(np.uint8).tobytes()
asyncio.ensure_future(self._send_preview_to(ws, data))
@staticmethod
async def _send_preview_to(ws, data: bytes) -> None:
try:
await ws.send_bytes(data)
except Exception:
pass
def remove_led_preview_client(self, ws) -> None:
if ws in self._preview_clients:
@@ -536,9 +571,22 @@ class WledTargetProcessor(TargetProcessor):
_diag_device_info: Optional[DeviceInfo] = None
_diag_device_info_age = 0
# --- Liveness probe + adaptive FPS ---
_device_url = _init_device_info.device_url if _init_device_info else ""
_probe_enabled = _device_url.startswith("http")
_probe_interval = 10.0 # seconds between probes
_last_probe_time = 0.0 # force first probe soon (after 10s)
_probe_task: Optional[asyncio.Task] = None
_probe_client: Optional[httpx.AsyncClient] = None
if _probe_enabled:
_probe_client = httpx.AsyncClient(timeout=httpx.Timeout(2.0))
self._effective_fps = self._target_fps
self._device_reachable = None
logger.info(
f"Processing loop started for target {self._target_id} "
f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps})"
f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}"
f"{', adaptive' if self._adaptive_fps else ''})"
)
next_frame_time = time.perf_counter()
@@ -548,7 +596,61 @@ class WledTargetProcessor(TargetProcessor):
while self._is_running:
loop_start = now = time.perf_counter()
target_fps = self._target_fps if self._target_fps > 0 else 30
frame_time = 1.0 / target_fps
# --- Liveness probe ---
# Collect result as soon as it's done (every iteration)
if _probe_task is not None and _probe_task.done():
try:
reachable = _probe_task.result()
except Exception:
reachable = False
prev_reachable = self._device_reachable
self._device_reachable = reachable
self._metrics.device_streaming_reachable = reachable
_probe_task = None
if self._adaptive_fps:
if not reachable:
# Backoff: halve effective FPS
old_eff = self._effective_fps
self._effective_fps = max(1, self._effective_fps // 2)
if old_eff != self._effective_fps:
logger.warning(
f"[ADAPTIVE] {self._target_id} device unreachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
else:
# Recovery: gradually increase
if self._effective_fps < target_fps:
step = max(1, target_fps // 8)
old_eff = self._effective_fps
self._effective_fps = min(target_fps, self._effective_fps + step)
if old_eff != self._effective_fps:
logger.info(
f"[ADAPTIVE] {self._target_id} device reachable, "
f"FPS {old_eff}{self._effective_fps}"
)
next_frame_time = time.perf_counter()
if prev_reachable != reachable:
logger.info(
f"[PROBE] {self._target_id} device "
f"{'reachable' if reachable else 'UNREACHABLE'}"
)
# Fire new probe every _probe_interval seconds
if _probe_enabled and _probe_task is None and (now - _last_probe_time) >= _probe_interval:
if _probe_client is not None:
_last_probe_time = now
_probe_task = asyncio.create_task(
self._probe_device(_device_url, _probe_client)
)
# Use effective FPS for frame timing
effective_fps = self._effective_fps if self._adaptive_fps else target_fps
self._metrics.fps_effective = effective_fps
frame_time = 1.0 / effective_fps
keepalive_interval = self._keepalive_interval
# Detect hot-swapped CSS stream
@@ -640,6 +742,8 @@ class WledTargetProcessor(TargetProcessor):
# Fit to device LED count and apply brightness
device_colors = self._fit_to_device(frame, _total_leds)
send_colors = _cached_brightness(device_colors, cur_brightness)
self._last_preview_colors = send_colors
self._last_preview_brightness = cur_brightness
# Send to LED device
if not self._is_running or self._led_client is None:
@@ -752,4 +856,11 @@ class WledTargetProcessor(TargetProcessor):
self._is_running = False
raise
finally:
# Clean up probe client
if _probe_client is not None:
await _probe_client.aclose()
if _probe_task is not None and not _probe_task.done():
_probe_task.cancel()
self._device_reachable = None
self._metrics.device_streaming_reachable = None
logger.info(f"Processing loop ended for target {self._target_id}")