From d4261d76d8437f98effba3e855ee91f0f62aca56 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 7 Feb 2026 23:44:29 +0300 Subject: [PATCH] Add WLED health monitoring, calibration test mode, and UI improvements - 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 --- server/src/wled_controller/api/routes.py | 135 +++--- server/src/wled_controller/api/schemas.py | 34 +- .../wled_controller/core/processor_manager.py | 234 ++++++++++- server/src/wled_controller/main.py | 3 + server/src/wled_controller/static/app.js | 397 ++++++++++++++---- server/src/wled_controller/static/index.html | 196 +++------ .../wled_controller/static/locales/en.json | 25 +- .../wled_controller/static/locales/ru.json | 25 +- server/src/wled_controller/static/style.css | 306 +++++++++++++- .../wled_controller/storage/device_store.py | 7 +- 10 files changed, 1047 insertions(+), 315 deletions(-) diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index c602183..d95e985 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -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)) diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 7643d6f..f032f42 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -5,6 +5,8 @@ from typing import Dict, List, Literal, Optional from pydantic import BaseModel, Field, HttpUrl +from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL + # Health and Version Schemas @@ -53,7 +55,6 @@ class DeviceCreate(BaseModel): name: str = Field(description="Device name", min_length=1, max_length=100) url: str = Field(description="WLED device URL (e.g., http://192.168.1.100)") - led_count: int = Field(description="Total number of LEDs", gt=0, le=10000) class DeviceUpdate(BaseModel): @@ -61,7 +62,6 @@ class DeviceUpdate(BaseModel): name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) url: Optional[str] = Field(None, description="WLED device URL") - led_count: Optional[int] = Field(None, description="Total number of LEDs", gt=0, le=10000) enabled: Optional[bool] = Field(None, description="Whether device is enabled") @@ -80,6 +80,10 @@ class ProcessingSettings(BaseModel): fps: int = Field(default=30, description="Target frames per second", ge=1, le=60) border_width: int = Field(default=10, description="Border width in pixels", ge=1, le=100) brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) + state_check_interval: int = Field( + default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, + description="Seconds between WLED health checks" + ) color_correction: Optional[ColorCorrection] = Field( default_factory=ColorCorrection, description="Color correction settings" @@ -118,6 +122,25 @@ class Calibration(BaseModel): ) +class CalibrationTestModeRequest(BaseModel): + """Request to set calibration test mode with multiple edges.""" + + edges: Dict[str, List[int]] = Field( + default_factory=dict, + description="Map of active edge names to RGB colors. " + "E.g. {'top': [255, 0, 0], 'left': [255, 255, 0]}. " + "Empty dict = exit test mode." + ) + + +class CalibrationTestModeResponse(BaseModel): + """Response for calibration test mode.""" + + test_mode: bool = Field(description="Whether test mode is active") + active_edges: List[str] = Field(default_factory=list, description="Currently lit edges") + device_id: str = Field(description="Device ID") + + class DeviceResponse(BaseModel): """Device information response.""" @@ -154,6 +177,13 @@ class ProcessingState(BaseModel): display_index: int = Field(description="Current display index") last_update: Optional[datetime] = Field(None, description="Last successful update") errors: List[str] = Field(default_factory=list, description="Recent errors") + wled_online: bool = Field(default=False, description="Whether WLED device is reachable") + wled_latency_ms: Optional[float] = Field(None, description="WLED health check latency in ms") + wled_name: Optional[str] = Field(None, description="WLED device name") + wled_version: Optional[str] = Field(None, description="WLED firmware version") + wled_led_count: Optional[int] = Field(None, description="LED count reported by WLED device") + wled_last_checked: Optional[datetime] = Field(None, description="Last health check time") + wled_error: Optional[str] = Field(None, description="Last health check error") class MetricsResponse(BaseModel): diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index a979472..198b311 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -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, + } diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 69b4d72..56ebb8b 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -78,6 +78,9 @@ async def lifespan(app: FastAPI): logger.info(f"Loaded {len(devices)} devices from storage") + # Start background health monitoring for all devices + await processor_manager.start_health_monitoring() + yield # Shutdown diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 049a56b..bbd4ac2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -5,6 +5,31 @@ let apiKey = null; // Track logged errors to avoid console spam const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } +// Calibration test mode state +const calibrationTestState = {}; // deviceId -> Set of active edge names + +// Modal dirty tracking - stores initial values when modals open +let settingsInitialValues = {}; +let calibrationInitialValues = {}; +const EDGE_TEST_COLORS = { + top: [255, 0, 0], + right: [0, 255, 0], + bottom: [0, 100, 255], + left: [255, 255, 0] +}; + +// Modal body lock helpers - prevent layout jump when scrollbar disappears +function lockBody() { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + document.body.style.paddingRight = scrollbarWidth + 'px'; + document.body.classList.add('modal-open'); +} + +function unlockBody() { + document.body.classList.remove('modal-open'); + document.body.style.paddingRight = ''; +} + // Locale management let currentLocale = 'en'; let translations = {}; @@ -238,7 +263,7 @@ async function loadServerInfo() { const response = await fetch('/health'); const data = await response.json(); - document.getElementById('version-number').textContent = data.version; + document.getElementById('version-number').textContent = `v${data.version}`; document.getElementById('server-status').textContent = '●'; document.getElementById('server-status').className = 'status-badge online'; } catch (error) { @@ -270,32 +295,6 @@ async function loadDisplays() { return; } - // Render display cards with enhanced information - container.innerHTML = data.displays.map(display => ` -
-
-
${display.name}
- ${display.is_primary ? `${t('displays.badge.primary')}` : `${t('displays.badge.secondary')}`} -
-
- ${t('displays.resolution')} - ${display.width} × ${display.height} -
-
- ${t('displays.refresh_rate')} - ${display.refresh_rate}Hz -
-
- ${t('displays.position')} - (${display.x}, ${display.y}) -
-
- ${t('displays.index')} - ${display.index} -
-
- `).join(''); - // Render visual layout renderDisplayLayout(data.displays); } catch (error) { @@ -349,9 +348,12 @@ function renderDisplayLayout(displays) {
+
(${display.x}, ${display.y})
+
#${display.index}
${display.name} ${display.width}×${display.height} + ${display.refresh_rate}Hz
${display.is_primary ? '
' : ''}
@@ -466,28 +468,56 @@ function createDeviceCard(device) { const settings = device.settings || {}; const isProcessing = state.processing || false; + const brightnessPercent = Math.round((settings.brightness !== undefined ? settings.brightness : 1.0) * 100); const statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle'; const status = isProcessing ? 'processing' : 'idle'; + // WLED device health indicator + const wledOnline = state.wled_online || false; + const wledLatency = state.wled_latency_ms; + const wledName = state.wled_name; + const wledVersion = state.wled_version; + const wledLastChecked = state.wled_last_checked; + + let healthClass, healthTitle, healthLabel; + if (wledLastChecked === null || wledLastChecked === undefined) { + healthClass = 'health-unknown'; + healthTitle = t('device.health.checking'); + healthLabel = ''; + } else if (wledOnline) { + healthClass = 'health-online'; + healthTitle = `${t('device.health.online')}`; + if (wledName) healthTitle += ` - ${wledName}`; + if (wledVersion) healthTitle += ` v${wledVersion}`; + healthLabel = wledLatency !== null && wledLatency !== undefined + ? `${Math.round(wledLatency)}ms` : ''; + } else { + healthClass = 'health-offline'; + healthTitle = t('device.health.offline'); + if (state.wled_error) healthTitle += `: ${state.wled_error}`; + healthLabel = `${t('device.health.offline')}`; + } + return `
-
${device.name || device.id}
- ${t(statusKey)} +
+ + ${device.name || device.id} + ${wledVersion ? `v${wledVersion}` : ''} + ${healthLabel} +
+
+ 🖥️${settings.display_index !== undefined ? settings.display_index : 0} + ${state.wled_led_count ? `💡${state.wled_led_count}` : ''} + ${isProcessing ? `${t('device.status.processing')}` : ''} +
${t('device.url')} ${device.url || 'N/A'}
-
- ${t('device.led_count')} - ${device.led_count || 0} -
-
- ${t('device.display')} - ${settings.display_index !== undefined ? settings.display_index : 0} -
${isProcessing ? `
@@ -509,6 +539,13 @@ function createDeviceCard(device) {
` : ''}
+
+ +
${isProcessing ? ` @@ -21,9 +23,6 @@ - @@ -34,25 +33,15 @@
-

Available Displays

+

Display Layout

-
-

Display Layout

Loading layout...
-
- Primary Display - Secondary Display -
- -

Display Information

-
-
Loading displays...
-
+
@@ -78,11 +67,6 @@
-
- - - Number of LEDs configured in your WLED device -
@@ -108,115 +92,81 @@