Add WLED health monitoring, calibration test mode, and UI improvements
Some checks failed
Validate / validate (push) Failing after 8s

- Add background health checks (GET /json/info) with configurable interval per device
- Auto-detect LED count from WLED device on add (remove led_count from create API)
- Add calibration test mode: toggle edges on/off with colored LEDs via PUT endpoint
- Show WLED firmware version badge and LED count badge on device cards
- Add modal dirty tracking with discard confirmation on close/backdrop click
- Fix layout jump when modals open by compensating for scrollbar width
- Add state_check_interval to settings API and UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 23:44:29 +03:00
parent 579821a69b
commit d4261d76d8
10 changed files with 1047 additions and 315 deletions

View File

@@ -4,7 +4,9 @@ import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, Optional
from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.calibration import (
CalibrationConfig,
@@ -18,6 +20,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@dataclass
class ProcessingSettings:
@@ -31,6 +35,20 @@ class ProcessingSettings:
saturation: float = 1.0
smoothing: float = 0.3
interpolation_mode: str = "average"
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@dataclass
class DeviceHealth:
"""Health check result for a WLED device (GET /json/info)."""
online: bool = False
latency_ms: Optional[float] = None
last_checked: Optional[datetime] = None
wled_name: Optional[str] = None
wled_version: Optional[str] = None
wled_led_count: Optional[int] = None
error: Optional[str] = None
@dataclass
@@ -60,6 +78,10 @@ class ProcessorState:
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
previous_colors: Optional[list] = None
health: DeviceHealth = field(default_factory=DeviceHealth)
test_mode_active: bool = False
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
health_task: Optional[asyncio.Task] = None
class ProcessorManager:
@@ -68,8 +90,16 @@ class ProcessorManager:
def __init__(self):
"""Initialize processor manager."""
self._processors: Dict[str, ProcessorState] = {}
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
logger.info("Processor manager initialized")
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create a shared HTTP client for health checks."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(timeout=5)
return self._http_client
def add_device(
self,
device_id: str,
@@ -105,6 +135,11 @@ class ProcessorManager:
)
self._processors[device_id] = state
# Start health monitoring for this device
if self._health_monitoring_active:
self._start_device_health_check(device_id)
logger.info(f"Added device {device_id} with {led_count} LEDs")
def remove_device(self, device_id: str):
@@ -123,6 +158,9 @@ class ProcessorManager:
if self._processors[device_id].is_running:
raise RuntimeError(f"Cannot remove device {device_id} while processing")
# Stop health check task
self._stop_device_health_check(device_id)
del self._processors[device_id]
logger.info(f"Removed device {device_id}")
@@ -291,6 +329,11 @@ class ProcessorManager:
while state.is_running:
loop_start = time.time()
# Skip capture/send while in calibration test mode
if state.test_mode_active:
await asyncio.sleep(frame_time)
continue
try:
# Run blocking operations in thread pool to avoid blocking event loop
# Capture screen (blocking I/O)
@@ -375,6 +418,7 @@ class ProcessorManager:
state = self._processors[device_id]
metrics = state.metrics
h = state.health
return {
"device_id": device_id,
@@ -384,8 +428,77 @@ class ProcessorManager:
"display_index": state.settings.display_index,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"wled_online": h.online,
"wled_latency_ms": h.latency_ms,
"wled_name": h.wled_name,
"wled_version": h.wled_version,
"wled_led_count": h.wled_led_count,
"wled_last_checked": h.last_checked,
"wled_error": h.error,
"test_mode": state.test_mode_active,
"test_mode_edges": list(state.test_mode_edges.keys()),
}
async def set_test_mode(self, device_id: str, edges: Dict[str, List[int]]) -> None:
"""Set or clear calibration test mode for a device.
When edges dict is non-empty, enters test mode and sends test pixel pattern.
When empty, exits test mode and clears LEDs.
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
state = self._processors[device_id]
if edges:
state.test_mode_active = True
state.test_mode_edges = {
edge: tuple(color) for edge, color in edges.items()
}
await self._send_test_pixels(device_id)
else:
state.test_mode_active = False
state.test_mode_edges = {}
await self._send_clear_pixels(device_id)
async def _send_test_pixels(self, device_id: str) -> None:
"""Build and send test pixel array for active test edges."""
state = self._processors[device_id]
pixels = [(0, 0, 0)] * state.led_count
for edge_name, color in state.test_mode_edges.items():
for seg in state.calibration.segments:
if seg.edge == edge_name:
for i in range(seg.led_start, seg.led_start + seg.led_count):
if i < state.led_count:
pixels[i] = color
break
try:
if state.wled_client and state.is_running:
await state.wled_client.send_pixels(pixels)
else:
use_ddp = state.led_count > WLEDClient.HTTP_MAX_LEDS
async with WLEDClient(state.device_url, use_ddp=use_ddp) as wled:
await wled.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to send test pixels for {device_id}: {e}")
async def _send_clear_pixels(self, device_id: str) -> None:
"""Send all-black pixels to clear WLED output."""
state = self._processors[device_id]
pixels = [(0, 0, 0)] * state.led_count
try:
if state.wled_client and state.is_running:
await state.wled_client.send_pixels(pixels)
else:
use_ddp = state.led_count > WLEDClient.HTTP_MAX_LEDS
async with WLEDClient(state.device_url, use_ddp=use_ddp) as wled:
await wled.send_pixels(pixels)
except Exception as e:
logger.error(f"Failed to clear pixels for {device_id}: {e}")
def get_metrics(self, device_id: str) -> dict:
"""Get detailed metrics for a device.
@@ -447,9 +560,12 @@ class ProcessorManager:
return list(self._processors.keys())
async def stop_all(self):
"""Stop processing for all devices."""
device_ids = list(self._processors.keys())
"""Stop processing and health monitoring for all devices."""
# Stop health monitoring
await self.stop_health_monitoring()
# Stop processing
device_ids = list(self._processors.keys())
for device_id in device_ids:
if self._processors[device_id].is_running:
try:
@@ -457,4 +573,116 @@ class ProcessorManager:
except Exception as e:
logger.error(f"Error stopping device {device_id}: {e}")
# Close shared HTTP client
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
self._http_client = None
logger.info("Stopped all processors")
# ===== HEALTH MONITORING =====
async def start_health_monitoring(self):
"""Start background health checks for all registered devices."""
self._health_monitoring_active = True
for device_id in self._processors:
self._start_device_health_check(device_id)
logger.info("Started health monitoring for all devices")
async def stop_health_monitoring(self):
"""Stop all background health checks."""
self._health_monitoring_active = False
for device_id in list(self._processors.keys()):
self._stop_device_health_check(device_id)
logger.info("Stopped health monitoring for all devices")
def _start_device_health_check(self, device_id: str):
"""Start health check task for a single device."""
state = self._processors.get(device_id)
if not state:
return
if state.health_task and not state.health_task.done():
return
state.health_task = asyncio.create_task(self._health_check_loop(device_id))
def _stop_device_health_check(self, device_id: str):
"""Stop health check task for a single device."""
state = self._processors.get(device_id)
if not state or not state.health_task:
return
state.health_task.cancel()
state.health_task = None
async def _health_check_loop(self, device_id: str):
"""Background loop that periodically checks a WLED device via GET /json/info."""
state = self._processors.get(device_id)
if not state:
return
try:
while self._health_monitoring_active:
await self._check_device_health(device_id)
await asyncio.sleep(state.settings.state_check_interval)
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Fatal error in health check loop for {device_id}: {e}")
async def _check_device_health(self, device_id: str):
"""Check device health via GET /json/info.
Determines online status, latency, device name and firmware version.
"""
state = self._processors.get(device_id)
if not state:
return
url = state.device_url.rstrip("/")
start = time.time()
try:
client = await self._get_http_client()
response = await client.get(f"{url}/json/info")
response.raise_for_status()
data = response.json()
latency = (time.time() - start) * 1000
wled_led_count = data.get("leds", {}).get("count")
state.health = DeviceHealth(
online=True,
latency_ms=round(latency, 1),
last_checked=datetime.utcnow(),
wled_name=data.get("name"),
wled_version=data.get("ver"),
wled_led_count=wled_led_count,
error=None,
)
except Exception as e:
state.health = DeviceHealth(
online=False,
latency_ms=None,
last_checked=datetime.utcnow(),
wled_name=state.health.wled_name,
wled_version=state.health.wled_version,
wled_led_count=state.health.wled_led_count,
error=str(e),
)
def get_device_health(self, device_id: str) -> dict:
"""Get health status for a device.
Args:
device_id: Device identifier
Returns:
Health status dictionary
"""
if device_id not in self._processors:
raise ValueError(f"Device {device_id} not found")
h = self._processors[device_id].health
return {
"online": h.online,
"latency_ms": h.latency_ms,
"last_checked": h.last_checked,
"wled_name": h.wled_name,
"wled_version": h.wled_version,
"wled_led_count": h.wled_led_count,
"error": h.error,
}