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,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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user