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,6 +4,7 @@ import sys
from datetime import datetime
from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Depends
from wled_controller import __version__
@@ -19,6 +20,8 @@ from wled_controller.api.schemas import (
DeviceListResponse,
ProcessingSettings as ProcessingSettingsSchema,
Calibration as CalibrationSchema,
CalibrationTestModeRequest,
CalibrationTestModeResponse,
ProcessingState,
MetricsResponse,
)
@@ -147,11 +150,44 @@ async def create_device(
try:
logger.info(f"Creating device: {device_data.name}")
# Create device in storage
# Validate WLED device is reachable before adding
device_url = device_data.url.rstrip("/")
try:
async with httpx.AsyncClient(timeout=5) as client:
response = await client.get(f"{device_url}/json/info")
response.raise_for_status()
wled_info = response.json()
wled_led_count = wled_info.get("leds", {}).get("count")
if not wled_led_count or wled_led_count < 1:
raise HTTPException(
status_code=422,
detail=f"WLED device at {device_url} reported invalid LED count: {wled_led_count}"
)
logger.info(
f"WLED device reachable: {wled_info.get('name', 'Unknown')} "
f"v{wled_info.get('ver', '?')} ({wled_led_count} LEDs)"
)
except httpx.ConnectError:
raise HTTPException(
status_code=422,
detail=f"Cannot reach WLED device at {device_url}. Check the URL and ensure the device is powered on."
)
except httpx.TimeoutException:
raise HTTPException(
status_code=422,
detail=f"Connection to {device_url} timed out. Check network connectivity."
)
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Failed to connect to WLED device at {device_url}: {e}"
)
# Create device in storage (LED count auto-detected from WLED)
device = store.create_device(
name=device_data.name,
url=device_data.url,
led_count=device_data.led_count,
led_count=wled_led_count,
)
# Add to processor manager
@@ -175,6 +211,7 @@ async def create_device(
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
@@ -207,6 +244,8 @@ async def list_devices(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
@@ -248,6 +287,8 @@ async def get_device(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
@@ -284,6 +325,7 @@ async def update_device(
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at,
@@ -409,6 +451,7 @@ async def get_settings(
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
)
@@ -430,6 +473,7 @@ async def update_settings(
brightness=settings.color_correction.brightness if settings.color_correction else 1.0,
gamma=settings.color_correction.gamma if settings.color_correction else 2.2,
saturation=settings.color_correction.saturation if settings.color_correction else 1.0,
state_check_interval=settings.state_check_interval,
)
# Update in storage
@@ -446,6 +490,8 @@ async def update_settings(
display_index=device.settings.display_index,
fps=device.settings.fps,
border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
)
except ValueError as e:
@@ -504,71 +550,62 @@ async def update_calibration(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/devices/{device_id}/calibration/test", tags=["Calibration"])
async def test_calibration(
@router.put(
"/api/v1/devices/{device_id}/calibration/test",
response_model=CalibrationTestModeResponse,
tags=["Calibration"],
)
async def set_calibration_test_mode(
device_id: str,
body: CalibrationTestModeRequest,
_auth: AuthRequired,
edge: str = "top",
color: List[int] = [255, 0, 0],
store: DeviceStore = Depends(get_device_store),
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Test calibration by lighting up specific edge.
"""Toggle calibration test mode for specific edges.
Useful for verifying LED positions match screen edges.
Send edges with colors to light them up, or empty edges dict to exit test mode.
While test mode is active, screen capture processing is paused.
"""
try:
# Get device
device = store.get_device(device_id)
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Find the segment for this edge
segment = None
for seg in device.calibration.segments:
if seg.edge == edge:
segment = seg
break
# Validate edge names and colors
valid_edges = {"top", "right", "bottom", "left"}
for edge_name, color in body.edges.items():
if edge_name not in valid_edges:
raise HTTPException(
status_code=400,
detail=f"Invalid edge '{edge_name}'. Must be one of: {', '.join(valid_edges)}"
)
if len(color) != 3 or not all(0 <= c <= 255 for c in color):
raise HTTPException(
status_code=400,
detail=f"Invalid color for edge '{edge_name}'. Must be [R, G, B] with values 0-255."
)
if not segment:
raise HTTPException(status_code=400, detail=f"No LEDs configured for {edge} edge")
await manager.set_test_mode(device_id, body.edges)
# Create pixel array - all black except for the test edge
pixels = [(0, 0, 0)] * device.led_count
active_edges = list(body.edges.keys())
logger.info(
f"Test mode {'activated' if active_edges else 'deactivated'} "
f"for device {device_id}: {active_edges}"
)
# Light up the test edge
r, g, b = color if len(color) == 3 else [255, 0, 0]
for i in range(segment.led_start, segment.led_start + segment.led_count):
if i < device.led_count:
pixels[i] = (r, g, b)
# Send to WLED
from wled_controller.core.wled_client import WLEDClient
import asyncio
async with WLEDClient(device.url) as wled:
# Light up the edge
await wled.send_pixels(pixels)
# Wait 2 seconds
await asyncio.sleep(2)
# Turn off
pixels_off = [(0, 0, 0)] * device.led_count
await wled.send_pixels(pixels_off)
logger.info(f"Calibration test completed for edge '{edge}' on device {device_id}")
return {
"status": "test_completed",
"device_id": device_id,
"edge": edge,
"led_count": segment.led_count,
}
return CalibrationTestModeResponse(
test_mode=len(active_edges) > 0,
active_edges=active_edges,
device_id=device_id,
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to test calibration: {e}")
logger.error(f"Failed to set test mode: {e}")
raise HTTPException(status_code=500, detail=str(e))