Add WLED health monitoring, calibration test mode, and UI improvements
Some checks failed
Validate / validate (push) Failing after 8s
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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user