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