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))

View File

@@ -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):

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,
}

View File

@@ -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

View File

@@ -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 => `
<div class="display-card">
<div class="display-header">
<div class="display-index">${display.name}</div>
${display.is_primary ? `<span class="badge badge-primary">${t('displays.badge.primary')}</span>` : `<span class="badge badge-secondary">${t('displays.badge.secondary')}</span>`}
</div>
<div class="info-row">
<span class="info-label">${t('displays.resolution')}</span>
<span class="info-value">${display.width} × ${display.height}</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.refresh_rate')}</span>
<span class="info-value">${display.refresh_rate}Hz</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.position')}</span>
<span class="info-value">(${display.x}, ${display.y})</span>
</div>
<div class="info-row">
<span class="info-label">${t('displays.index')}</span>
<span class="info-value">${display.index}</span>
</div>
</div>
`).join('');
// Render visual layout
renderDisplayLayout(data.displays);
} catch (error) {
@@ -349,9 +348,12 @@ function renderDisplayLayout(displays) {
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;"
title="${display.name}\n${display.width}×${display.height}\nPosition: (${display.x}, ${display.y})">
<div class="layout-position-label">(${display.x}, ${display.y})</div>
<div class="layout-index-label">#${display.index}</div>
<div class="layout-display-label">
<strong>${display.name}</strong>
<small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
@@ -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
? `<span class="health-latency">${Math.round(wledLatency)}ms</span>` : '';
} else {
healthClass = 'health-offline';
healthTitle = t('device.health.offline');
if (state.wled_error) healthTitle += `: ${state.wled_error}`;
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
}
return `
<div class="card" data-device-id="${device.id}">
<div class="card-header">
<div class="card-title">${device.name || device.id}</div>
<span class="badge ${status}">${t(statusKey)}</span>
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${device.name || device.id}
${wledVersion ? `<span class="wled-version">v${wledVersion}</span>` : ''}
${healthLabel}
</div>
<div class="card-header-badges">
<span class="display-badge" title="${t('device.display')} ${settings.display_index !== undefined ? settings.display_index : 0}">🖥️${settings.display_index !== undefined ? settings.display_index : 0}</span>
${state.wled_led_count ? `<span class="led-count-badge" title="${t('device.led_count')} ${state.wled_led_count}">💡${state.wled_led_count}</span>` : ''}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="card-content">
<div class="info-row">
<span class="info-label">${t('device.url')}</span>
<span class="info-value">${device.url || 'N/A'}</span>
</div>
<div class="info-row">
<span class="info-label">${t('device.led_count')}</span>
<span class="info-value">${device.led_count || 0}</span>
</div>
<div class="info-row">
<span class="info-label">${t('device.display')}</span>
<span class="info-value">${settings.display_index !== undefined ? settings.display_index : 0}</span>
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
@@ -509,6 +539,13 @@ function createDeviceCard(device) {
</div>
` : ''}
</div>
<div class="brightness-control">
<input type="range" class="brightness-slider" min="0" max="100"
value="${brightnessPercent}"
oninput="updateBrightnessLabel('${device.id}', this.value)"
onchange="saveCardBrightness('${device.id}', this.value)"
title="${brightnessPercent}%">
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}">
@@ -647,17 +684,20 @@ async function showSettings(deviceId) {
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url;
document.getElementById('settings-device-led-count').value = device.led_count;
// Set health check interval
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
// Set brightness (convert from 0.0-1.0 to 0-100)
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100);
document.getElementById('settings-device-brightness').value = brightnessPercent;
document.getElementById('brightness-value').textContent = brightnessPercent + '%';
// Snapshot initial values for dirty checking
settingsInitialValues = {
name: device.name,
url: device.url,
state_check_interval: String(device.settings.state_check_interval || 30),
};
// Show modal
const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
// Focus first input
setTimeout(() => {
@@ -670,36 +710,51 @@ async function showSettings(deviceId) {
}
}
function closeDeviceSettingsModal() {
function isSettingsDirty() {
return (
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
);
}
function forceCloseDeviceSettingsModal() {
const modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error');
modal.style.display = 'none';
error.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
settingsInitialValues = {};
}
async function closeDeviceSettingsModal() {
if (isSettingsDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseDeviceSettingsModal();
}
async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim();
const led_count = parseInt(document.getElementById('settings-device-led-count').value);
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const error = document.getElementById('settings-error');
// Validation
if (!name || !url || !led_count || led_count < 1) {
if (!name || !url) {
error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block';
return;
}
try {
// Update device info (name, url, led_count)
// Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
body: JSON.stringify({ name, url })
});
if (deviceResponse.status === 401) {
@@ -714,11 +769,11 @@ async function saveDeviceSettings() {
return;
}
// Update settings (brightness)
// Update settings (health check interval)
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
body: JSON.stringify({ state_check_interval })
});
if (settingsResponse.status === 401) {
@@ -728,7 +783,7 @@ async function saveDeviceSettings() {
if (settingsResponse.ok) {
showToast('Device settings updated', 'success');
closeDeviceSettingsModal();
forceCloseDeviceSettingsModal();
loadDevices();
} else {
const errorData = await settingsResponse.json();
@@ -742,21 +797,40 @@ async function saveDeviceSettings() {
}
}
// Card brightness controls
function updateBrightnessLabel(deviceId, value) {
const slider = document.querySelector(`[data-device-id="${deviceId}"] .brightness-slider`);
if (slider) slider.title = value + '%';
}
async function saveCardBrightness(deviceId, value) {
const brightness = parseInt(value) / 100.0;
try {
await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ brightness })
});
} catch (err) {
console.error('Failed to update brightness:', err);
showToast('Failed to update brightness', 'error');
}
}
// Add device form handler
async function handleAddDevice(event) {
event.preventDefault();
const name = document.getElementById('device-name').value;
const url = document.getElementById('device-url').value;
const led_count = parseInt(document.getElementById('device-led-count').value);
console.log(`Adding device: ${name} (${url}, ${led_count} LEDs)`);
console.log(`Adding device: ${name} (${url})`);
try {
const response = await fetch(`${API_BASE}/devices`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name, url, led_count })
body: JSON.stringify({ name, url })
});
if (response.status === 401) {
@@ -825,14 +899,14 @@ function showConfirm(message, title = null) {
noBtn.textContent = t('confirm.no');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
});
}
function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal');
modal.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
if (confirmResolve) {
confirmResolve(result);
@@ -881,13 +955,27 @@ async function showCalibration(deviceId) {
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
document.getElementById('cal-left-leds').value = edgeCounts.left;
// Snapshot initial values for dirty checking
calibrationInitialValues = {
start_position: calibration.start_position,
layout: calibration.layout,
offset: String(calibration.offset || 0),
top: String(edgeCounts.top),
right: String(edgeCounts.right),
bottom: String(edgeCounts.bottom),
left: String(edgeCounts.left),
};
// Initialize test mode state for this device
calibrationTestState[device.id] = new Set();
// Update preview
updateCalibrationPreview();
// Show modal
const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
} catch (error) {
console.error('Failed to load calibration:', error);
@@ -895,55 +983,128 @@ async function showCalibration(deviceId) {
}
}
function closeCalibrationModal() {
function isCalibrationDirty() {
return (
document.getElementById('cal-start-position').value !== calibrationInitialValues.start_position ||
document.getElementById('cal-layout').value !== calibrationInitialValues.layout ||
document.getElementById('cal-offset').value !== calibrationInitialValues.offset ||
document.getElementById('cal-top-leds').value !== calibrationInitialValues.top ||
document.getElementById('cal-right-leds').value !== calibrationInitialValues.right ||
document.getElementById('cal-bottom-leds').value !== calibrationInitialValues.bottom ||
document.getElementById('cal-left-leds').value !== calibrationInitialValues.left
);
}
function forceCloseCalibrationModal() {
const deviceId = document.getElementById('calibration-device-id').value;
if (deviceId) {
clearTestMode(deviceId);
}
const modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error');
modal.style.display = 'none';
error.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
calibrationInitialValues = {};
}
async function closeCalibrationModal() {
if (isCalibrationDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseCalibrationModal();
}
function updateCalibrationPreview() {
// Update edge counts in preview
document.getElementById('preview-top-count').textContent = document.getElementById('cal-top-leds').value;
document.getElementById('preview-right-count').textContent = document.getElementById('cal-right-leds').value;
document.getElementById('preview-bottom-count').textContent = document.getElementById('cal-bottom-leds').value;
document.getElementById('preview-left-count').textContent = document.getElementById('cal-left-leds').value;
// Calculate total
// Calculate total from edge inputs
const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0);
document.getElementById('cal-total-leds').textContent = total;
// Update starting position indicator
// Update corner dot highlights for start position
const startPos = document.getElementById('cal-start-position').value;
const indicator = document.getElementById('start-indicator');
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
if (cornerEl) {
if (corner === startPos) {
cornerEl.classList.add('active');
} else {
cornerEl.classList.remove('active');
}
}
});
const positions = {
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' },
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' },
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' },
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' }
};
// Update direction toggle display
const direction = document.getElementById('cal-layout').value;
const dirIcon = document.getElementById('direction-icon');
const dirLabel = document.getElementById('direction-label');
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const pos = positions[startPos];
indicator.style.top = pos.top;
indicator.style.right = pos.right;
indicator.style.bottom = pos.bottom;
indicator.style.left = pos.left;
// Update edge highlight states
const deviceId = document.getElementById('calibration-device-id').value;
const activeEdges = calibrationTestState[deviceId] || new Set();
['top', 'right', 'bottom', 'left'].forEach(edge => {
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
if (!edgeEl) return;
if (activeEdges.has(edge)) {
const [r, g, b] = EDGE_TEST_COLORS[edge];
edgeEl.classList.add('active');
edgeEl.style.background = `rgba(${r}, ${g}, ${b}, 0.7)`;
edgeEl.style.boxShadow = `0 0 8px rgba(${r}, ${g}, ${b}, 0.5)`;
} else {
edgeEl.classList.remove('active');
edgeEl.style.background = '';
edgeEl.style.boxShadow = '';
}
});
}
async function testCalibrationEdge(edge) {
function setStartPosition(position) {
document.getElementById('cal-start-position').value = position;
updateCalibrationPreview();
}
function toggleDirection() {
const select = document.getElementById('cal-layout');
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
updateCalibrationPreview();
}
async function toggleTestEdge(edge) {
const deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error');
if (!calibrationTestState[deviceId]) {
calibrationTestState[deviceId] = new Set();
}
// Toggle edge
if (calibrationTestState[deviceId].has(edge)) {
calibrationTestState[deviceId].delete(edge);
} else {
calibrationTestState[deviceId].add(edge);
}
// Build edges dict for API
const edges = {};
calibrationTestState[deviceId].forEach(e => {
edges[e] = EDGE_TEST_COLORS[e];
});
// Update visual state immediately
updateCalibrationPreview();
try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'POST',
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color
body: JSON.stringify({ edges })
});
if (response.status === 401) {
@@ -951,25 +1112,45 @@ async function testCalibrationEdge(edge) {
return;
}
if (response.ok) {
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
} else {
if (!response.ok) {
const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block';
}
} catch (err) {
console.error('Failed to test edge:', err);
error.textContent = 'Failed to test edge';
console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to toggle test edge';
error.style.display = 'block';
}
}
async function clearTestMode(deviceId) {
if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) {
return;
}
calibrationTestState[deviceId] = new Set();
try {
await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ edges: {} })
});
} catch (err) {
console.error('Failed to clear test mode:', err);
}
}
async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
const error = document.getElementById('calibration-error');
// Clear test mode before saving
await clearTestMode(deviceId);
updateCalibrationPreview();
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
@@ -1036,7 +1217,7 @@ async function saveCalibration() {
if (response.ok) {
showToast('Calibration saved', 'success');
closeCalibrationModal();
forceCloseCalibrationModal();
loadDevices();
} else {
const errorData = await response.json();
@@ -1085,6 +1266,40 @@ function shouldReverse(edge, startPosition, layout) {
return rules ? rules[edge] : false;
}
// Close modals on backdrop click
document.addEventListener('click', (e) => {
if (!e.target.classList.contains('modal')) return;
const modalId = e.target.id;
// Confirm modal: backdrop click acts as Cancel
if (modalId === 'confirm-modal') {
closeConfirmModal(false);
return;
}
// Login modal: close only if cancel button is visible (not required login)
if (modalId === 'api-key-modal') {
const cancelBtn = document.getElementById('modal-cancel-btn');
if (cancelBtn && cancelBtn.style.display !== 'none') {
closeApiKeyModal();
}
return;
}
// Settings modal: dirty check
if (modalId === 'device-settings-modal') {
closeDeviceSettingsModal();
return;
}
// Calibration modal: dirty check
if (modalId === 'calibration-modal') {
closeCalibrationModal();
return;
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (refreshInterval) {

View File

@@ -10,10 +10,12 @@
<body style="visibility: hidden;">
<div class="container">
<header>
<h1 data-i18n="app.title">WLED Screen Controller</h1>
<div class="server-info">
<span id="server-version"><span data-i18n="app.version">Version:</span> <span id="version-number">Loading...</span></span>
<div class="header-title">
<span id="server-status" class="status-badge"></span>
<h1 data-i18n="app.title">WLED Screen Controller</h1>
<span id="server-version"><span id="version-number"></span></span>
</div>
<div class="server-info">
<button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span>
</button>
@@ -21,9 +23,6 @@
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
<span id="auth-status" style="margin-left: 10px; display: none; white-space: nowrap;">
<span id="logged-in-user" style="color: #4CAF50;" data-i18n="auth.authenticated">Authenticated</span>
</span>
<button id="login-btn" class="btn btn-primary" onclick="showLogin()" style="display: none; padding: 6px 16px; font-size: 0.85rem; margin-left: 10px;">
🔑 <span data-i18n="auth.login">Login</span>
</button>
@@ -34,25 +33,15 @@
</header>
<section class="displays-section">
<h2 data-i18n="displays.title">Available Displays</h2>
<h2 data-i18n="displays.layout">Display Layout</h2>
<!-- Visual Layout Preview -->
<div class="display-layout-preview">
<h3 data-i18n="displays.layout">Display Layout</h3>
<div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading" data-i18n="displays.loading">Loading layout...</div>
</div>
<div class="layout-legend">
<span class="legend-item"><span class="legend-dot primary"></span> <span data-i18n="displays.legend.primary">Primary Display</span></span>
<span class="legend-item"><span class="legend-dot secondary"></span> <span data-i18n="displays.legend.secondary">Secondary Display</span></span>
</div>
</div>
<!-- Display Cards -->
<h3 style="margin-top: 30px;" data-i18n="displays.information">Display Information</h3>
<div id="displays-list" class="displays-grid">
<div class="loading" data-i18n="displays.loading">Loading displays...</div>
</div>
<div id="displays-list" style="display: none;"></div>
</section>
<section class="devices-section">
@@ -78,11 +67,6 @@
<label for="device-url" data-i18n="device.url">WLED URL:</label>
<input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group">
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
<input type="number" id="device-led-count" value="150" min="1" required>
<small class="input-hint" data-i18n="device.led_count.hint">Number of LEDs configured in your WLED device</small>
</div>
<button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
</form>
</section>
@@ -108,115 +92,81 @@
</div>
<div class="modal-body">
<input type="hidden" id="calibration-device-id">
<p style="margin-bottom: 20px; color: var(--text-secondary);" data-i18n="calibration.description">
Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.
<p style="margin-bottom: 12px; color: var(--text-secondary);" data-i18n="calibration.description">
Configure how your LED strip is mapped to screen edges. Click an edge to toggle test mode.
</p>
<!-- Visual Preview -->
<div style="margin-bottom: 25px;">
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;">
<!-- Screen representation -->
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 300px; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 14px;" data-i18n="calibration.preview.screen">
Screen
<!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="margin-bottom: 12px;">
<div class="calibration-preview">
<!-- Screen with direction toggle -->
<div class="preview-screen">
<span data-i18n="calibration.preview.screen">Screen</span>
<button type="button" class="direction-toggle" onclick="toggleDirection()" title="Toggle direction">
<span id="direction-icon"></span> <span id="direction-label">CW</span>
</button>
</div>
<!-- Edge labels -->
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
<span data-i18n="calibration.preview.top">Top:</span> <span id="preview-top-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
<!-- Clickable edge bars with LED count inputs -->
<div class="preview-edge edge-top" onclick="toggleTestEdge('top')">
<span>T</span>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div>
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
<span data-i18n="calibration.preview.right">Right:</span> <span id="preview-right-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
<div class="preview-edge edge-right" onclick="toggleTestEdge('right')">
<span>R</span>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div>
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);">
<span data-i18n="calibration.preview.bottom">Bottom:</span> <span id="preview-bottom-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
<div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')">
<span>B</span>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div>
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;">
<span data-i18n="calibration.preview.left">Left:</span> <span id="preview-left-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span>
<div class="preview-edge edge-left" onclick="toggleTestEdge('left')">
<span>L</span>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div>
<!-- Starting position indicator -->
<div id="start-indicator" style="position: absolute; bottom: 10px; left: 10px; width: 12px; height: 12px; background: #4CAF50; border-radius: 50%; border: 2px solid white;"></div>
<!-- Corner start position buttons -->
<div class="preview-corner corner-top-left" onclick="setStartPosition('top_left')"></div>
<div class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></div>
<div class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></div>
<div class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></div>
</div>
<p class="preview-hint" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p>
</div>
<!-- Layout Configuration -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-start-position" data-i18n="calibration.start_position">Starting Position:</label>
<select id="cal-start-position" onchange="updateCalibrationPreview()">
<option value="bottom_left" data-i18n="calibration.position.bottom_left">Bottom Left</option>
<option value="bottom_right" data-i18n="calibration.position.bottom_right">Bottom Right</option>
<option value="top_left" data-i18n="calibration.position.top_left">Top Left</option>
<option value="top_right" data-i18n="calibration.position.top_right">Top Right</option>
<!-- Hidden selects (used by saveCalibration) -->
<div style="display: none;">
<select id="cal-start-position">
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
</select>
<select id="cal-layout">
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</div>
<div class="form-group">
<label for="cal-layout" data-i18n="calibration.direction">Direction:</label>
<select id="cal-layout" onchange="updateCalibrationPreview()">
<option value="clockwise" data-i18n="calibration.direction.clockwise">Clockwise</option>
<option value="counterclockwise" data-i18n="calibration.direction.counterclockwise">Counterclockwise</option>
</select>
</div>
<div class="form-group">
<div class="form-group" style="margin-bottom: 12px;">
<label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
<input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
<small style="color: #aaa; display: block; margin-top: 4px;" data-i18n="calibration.offset_hint">LEDs from LED 0 to start corner (along strip)</small>
</div>
</div>
<!-- LED Counts per Edge -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;">
<div class="form-group">
<label for="cal-top-leds" data-i18n="calibration.leds.top">Top LEDs:</label>
<input type="number" id="cal-top-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-right-leds" data-i18n="calibration.leds.right">Right LEDs:</label>
<input type="number" id="cal-right-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-bottom-leds" data-i18n="calibration.leds.bottom">Bottom LEDs:</label>
<input type="number" id="cal-bottom-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
<div class="form-group">
<label for="cal-left-leds" data-i18n="calibration.leds.left">Left LEDs:</label>
<input type="number" id="cal-left-leds" min="0" value="0" oninput="updateCalibrationPreview()">
</div>
</div>
<div style="padding: 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 20px;">
<div style="padding: 8px 10px; background: rgba(255, 193, 7, 0.1); border-left: 4px solid #FFC107; border-radius: 4px; margin-bottom: 12px;">
<strong data-i18n="calibration.total">Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
</div>
<!-- Test Buttons -->
<div style="margin-bottom: 15px;">
<p style="font-weight: 600; margin-bottom: 10px;" data-i18n="calibration.test">Test Edges (lights up each edge):</p>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;">
<button class="btn btn-secondary" onclick="testCalibrationEdge('top')" style="font-size: 0.9rem; padding: 8px;">
⬆️ <span data-i18n="calibration.test.top">Top</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('right')" style="font-size: 0.9rem; padding: 8px;">
➡️ <span data-i18n="calibration.test.right">Right</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('bottom')" style="font-size: 0.9rem; padding: 8px;">
⬇️ <span data-i18n="calibration.test.bottom">Bottom</span>
</button>
<button class="btn btn-secondary" onclick="testCalibrationEdge('left')" style="font-size: 0.9rem; padding: 8px;">
⬅️ <span data-i18n="calibration.test.left">Left</span>
</button>
</div>
</div>
<div id="calibration-error" class="error-message" style="display: none;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCalibrationModal()" data-i18n="calibration.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="saveCalibration()" data-i18n="calibration.button.save">Save Calibration</button>
<button class="btn btn-primary" onclick="saveCalibration()" data-i18n="calibration.button.save">Save</button>
</div>
</div>
</div>
@@ -242,18 +192,11 @@
<small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-led-count" data-i18n="device.led_count">LED Count:</label>
<input type="number" id="settings-device-led-count" min="1" required>
<small class="input-hint" data-i18n="device.led_count.hint">Number of LEDs configured in your WLED device</small>
</div>
<div class="form-group">
<label for="settings-device-brightness"><span data-i18n="settings.brightness">Brightness:</span> <span id="brightness-value">100%</span></label>
<input type="range" id="settings-device-brightness" min="0" max="100" value="100"
oninput="document.getElementById('brightness-value').textContent = this.value + '%'"
style="width: 100%;">
<small class="input-hint" data-i18n="settings.brightness.hint">Global brightness for this WLED device (0-100%)</small>
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
<small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
</div>
<div id="settings-error" class="error-message" style="display: none;"></div>
@@ -346,24 +289,13 @@
const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn');
const authStatus = document.getElementById('auth-status');
const loggedInUser = document.getElementById('logged-in-user');
if (apiKey) {
// Logged in
loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block';
authStatus.style.display = 'inline';
// Show masked key
const masked = apiKey.substring(0, 8) + '...';
loggedInUser.textContent = `● Authenticated`;
loggedInUser.title = `API Key: ${masked}`;
} else {
// Logged out
loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none';
authStatus.style.display = 'none';
}
}
@@ -419,7 +351,7 @@
input.placeholder = 'Enter your API key...';
error.style.display = 'none';
modal.style.display = 'flex';
document.body.classList.add('modal-open');
lockBody();
// Hide cancel button if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
@@ -430,7 +362,7 @@
function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal');
modal.style.display = 'none';
document.body.classList.remove('modal-open');
unlockBody();
}
function submitApiKey(event) {

View File

@@ -43,10 +43,11 @@
"devices.wled_note2": "This controller sends pixel color data and controls brightness per device.",
"device.name": "Device Name:",
"device.name.placeholder": "Living Room TV",
"device.url": "WLED URL:",
"device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "LED Count:",
"device.led_count.hint": "Number of LEDs configured in your WLED device",
"device.led_count.hint.auto": "Auto-detected from WLED device",
"device.button.add": "Add Device",
"device.button.start": "Start",
"device.button.stop": "Stop",
@@ -69,22 +70,23 @@
"device.metrics.target_fps": "Target FPS",
"device.metrics.frames": "Frames",
"device.metrics.errors": "Errors",
"device.health.online": "WLED Online",
"device.health.offline": "WLED Offline",
"device.health.checking": "Checking...",
"settings.title": "Device Settings",
"settings.brightness": "Brightness:",
"settings.brightness.hint": "Global brightness for this WLED device (0-100%)",
"settings.url.hint": "IP address or hostname of your WLED device",
"settings.button.cancel": "Cancel",
"settings.health_interval": "Health Check Interval (s):",
"settings.health_interval.hint": "How often to check the WLED device status (5-600 seconds)",
"settings.button.save": "Save Changes",
"settings.saved": "Settings saved successfully",
"settings.failed": "Failed to save settings",
"calibration.title": "LED Calibration",
"calibration.description": "Configure how your LED strip is mapped to screen edges. Use test buttons to verify each edge lights up correctly.",
"calibration.description": "Configure how your LED strip is mapped to screen edges. Click an edge to toggle test mode.",
"calibration.preview.screen": "Screen",
"calibration.preview.top": "Top:",
"calibration.preview.right": "Right:",
"calibration.preview.bottom": "Bottom:",
"calibration.preview.left": "Left:",
"calibration.preview.leds": "LEDs",
"calibration.preview.click_hint": "Click an edge to toggle test LEDs on/off",
"calibration.start_position": "Starting Position:",
"calibration.position.bottom_left": "Bottom Left",
"calibration.position.bottom_right": "Bottom Right",
@@ -100,21 +102,16 @@
"calibration.leds.bottom": "Bottom LEDs:",
"calibration.leds.left": "Left LEDs:",
"calibration.total": "Total LEDs:",
"calibration.test": "Test Edges (lights up each edge):",
"calibration.test.top": "Top",
"calibration.test.right": "Right",
"calibration.test.bottom": "Bottom",
"calibration.test.left": "Left",
"calibration.button.cancel": "Cancel",
"calibration.button.save": "Save Calibration",
"calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully",
"calibration.failed": "Failed to save calibration",
"calibration.testing": "Testing {edge} edge...",
"server.healthy": "Server online",
"server.offline": "Server offline",
"error.unauthorized": "Unauthorized - please login",
"error.network": "Network error",
"error.unknown": "An error occurred",
"modal.discard_changes": "You have unsaved changes. Discard them?",
"confirm.title": "Confirm Action",
"confirm.yes": "Yes",
"confirm.no": "No"

View File

@@ -43,10 +43,11 @@
"devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.",
"device.name": "Имя Устройства:",
"device.name.placeholder": "ТВ в Гостиной",
"device.url": "WLED URL:",
"device.url": "URL:",
"device.url.placeholder": "http://192.168.1.100",
"device.led_count": "Количество Светодиодов:",
"device.led_count.hint": "Количество светодиодов, настроенных в вашем WLED устройстве",
"device.led_count.hint.auto": "Автоматически определяется из WLED устройства",
"device.button.add": "Добавить Устройство",
"device.button.start": "Запустить",
"device.button.stop": "Остановить",
@@ -69,22 +70,23 @@
"device.metrics.target_fps": "Целев. FPS",
"device.metrics.frames": "Кадры",
"device.metrics.errors": "Ошибки",
"device.health.online": "WLED Онлайн",
"device.health.offline": "WLED Недоступен",
"device.health.checking": "Проверка...",
"settings.title": "Настройки Устройства",
"settings.brightness": "Яркость:",
"settings.brightness.hint": "Общая яркость для этого WLED устройства (0-100%)",
"settings.url.hint": "IP адрес или имя хоста вашего WLED устройства",
"settings.button.cancel": "Отмена",
"settings.health_interval": "Интервал Проверки (с):",
"settings.health_interval.hint": "Как часто проверять статус WLED устройства (5-600 секунд)",
"settings.button.save": "Сохранить Изменения",
"settings.saved": "Настройки успешно сохранены",
"settings.failed": "Не удалось сохранить настройки",
"calibration.title": "Калибровка Светодиодов",
"calibration.description": "Настройте как ваша светодиодная лента сопоставляется с краями экрана. Используйте кнопки тестирования чтобы проверить что каждый край светится правильно.",
"calibration.description": "Настройте как ваша светодиодная лента сопоставляется с краями экрана. Нажмите на край для теста.",
"calibration.preview.screen": "Экран",
"calibration.preview.top": "Сверху:",
"calibration.preview.right": "Справа:",
"calibration.preview.bottom": "Снизу:",
"calibration.preview.left": "Слева:",
"calibration.preview.leds": "Светодиодов",
"calibration.preview.click_hint": "Нажмите на край чтобы включить/выключить тест светодиодов",
"calibration.start_position": "Начальная Позиция:",
"calibration.position.bottom_left": "Нижний Левый",
"calibration.position.bottom_right": "Нижний Правый",
@@ -100,21 +102,16 @@
"calibration.leds.bottom": "Светодиодов Снизу:",
"calibration.leds.left": "Светодиодов Слева:",
"calibration.total": "Всего Светодиодов:",
"calibration.test": "Тест Краев (подсвечивает каждый край):",
"calibration.test.top": "Сверху",
"calibration.test.right": "Справа",
"calibration.test.bottom": "Снизу",
"calibration.test.left": "Слева",
"calibration.button.cancel": "Отмена",
"calibration.button.save": "Сохранить Калибровку",
"calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена",
"calibration.failed": "Не удалось сохранить калибровку",
"calibration.testing": "Тестирование {edge} края...",
"server.healthy": "Сервер онлайн",
"server.offline": "Сервер офлайн",
"error.unauthorized": "Не авторизован - пожалуйста, войдите",
"error.network": "Сетевая ошибка",
"error.unknown": "Произошла ошибка",
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
"confirm.title": "Подтверждение Действия",
"confirm.yes": "Да",
"confirm.no": "Нет"

View File

@@ -63,6 +63,12 @@ header {
margin-bottom: 30px;
}
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
h1 {
font-size: 2rem;
color: var(--primary-color);
@@ -80,6 +86,16 @@ h2 {
gap: 15px;
}
#server-version {
font-size: 0.75rem;
font-weight: 400;
color: var(--text-secondary);
background: var(--border-color);
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.03em;
}
.status-badge {
font-size: 1.5rem;
animation: pulse 2s infinite;
@@ -98,6 +114,45 @@ h2 {
50% { opacity: 0.5; }
}
/* WLED device health indicator */
.health-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
flex-shrink: 0;
}
.health-dot.health-online {
background-color: #4CAF50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.6);
}
.health-dot.health-offline {
background-color: var(--danger-color);
box-shadow: 0 0 6px rgba(244, 67, 54, 0.6);
}
.health-dot.health-unknown {
background-color: #9E9E9E;
animation: pulse 2s infinite;
}
.health-latency {
font-size: 0.7rem;
font-weight: 400;
color: #4CAF50;
margin-left: auto;
padding-left: 8px;
opacity: 0.85;
}
.health-latency.offline {
color: var(--danger-color);
}
section {
margin-bottom: 40px;
}
@@ -118,7 +173,6 @@ section {
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
@@ -129,9 +183,35 @@ section {
margin-bottom: 15px;
}
.card-header-badges {
display: flex;
align-items: center;
gap: 8px;
}
.display-badge,
.led-count-badge {
font-size: 0.8rem;
color: var(--info-color);
opacity: 0.8;
}
.card-title {
font-size: 1.2rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.wled-version {
font-size: 0.65rem;
font-weight: 400;
color: var(--text-secondary);
background: var(--border-color);
padding: 1px 6px;
border-radius: 8px;
letter-spacing: 0.03em;
}
.badge {
@@ -254,14 +334,9 @@ section {
color: var(--info-color);
}
.badge-primary {
background: var(--primary-color);
color: white;
}
.badge-secondary {
background: var(--border-color);
color: var(--text-secondary);
.primary-star {
color: var(--primary-color);
font-size: 1.2rem;
}
/* Display Layout Visualization */
@@ -324,6 +399,22 @@ section {
background: linear-gradient(135deg, rgba(128, 128, 128, 0.1), rgba(128, 128, 128, 0.05));
}
.layout-position-label {
position: absolute;
top: 4px;
left: 6px;
font-size: 0.7rem;
color: var(--text-secondary);
}
.layout-index-label {
position: absolute;
bottom: 4px;
left: 6px;
font-size: 0.7rem;
color: var(--text-secondary);
}
.layout-display-label {
text-align: center;
padding: 5px;
@@ -386,6 +477,16 @@ section {
background: rgba(128, 128, 128, 0.2);
}
/* Card brightness slider */
.brightness-control {
padding: 0;
margin-bottom: 12px;
}
.brightness-slider {
width: 100%;
}
.add-device-section {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -668,6 +769,193 @@ input:-webkit-autofill:focus {
text-decoration: underline;
}
/* Interactive Calibration Preview Edges */
.calibration-preview {
position: relative;
width: 100%;
max-width: 500px;
aspect-ratio: 16 / 10;
margin: 0 auto;
background: var(--card-bg);
border: 2px solid var(--border-color);
border-radius: 8px;
}
.preview-screen {
position: absolute;
top: 37px;
left: 57px;
right: 57px;
bottom: 37px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: white;
font-size: 14px;
}
.preview-edge {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text-secondary);
background: rgba(128, 128, 128, 0.15);
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
z-index: 2;
user-select: none;
}
.preview-edge:hover {
background: rgba(128, 128, 128, 0.3);
}
.preview-edge.active {
color: white;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.edge-top {
top: 0;
left: 56px;
right: 56px;
height: 36px;
border-radius: 6px 6px 0 0;
flex-direction: row;
gap: 8px;
}
.edge-bottom {
bottom: 0;
left: 56px;
right: 56px;
height: 36px;
border-radius: 0 0 6px 6px;
flex-direction: row;
gap: 8px;
}
.edge-left {
left: 0;
top: 36px;
bottom: 36px;
width: 56px;
flex-direction: column;
border-radius: 6px 0 0 6px;
gap: 4px;
}
.edge-right {
right: 0;
top: 36px;
bottom: 36px;
width: 56px;
flex-direction: column;
border-radius: 0 6px 6px 0;
gap: 4px;
}
.edge-led-input {
width: 46px;
padding: 3px 2px;
font-size: 12px;
text-align: center;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 3px;
color: inherit;
}
.edge-led-input:focus {
border-color: var(--primary-color);
outline: none;
}
.edge-top .edge-led-input,
.edge-bottom .edge-led-input {
width: 56px;
}
/* Hide spinner arrows on edge inputs to save space */
.edge-led-input::-webkit-outer-spin-button,
.edge-led-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.edge-led-input[type=number] {
-moz-appearance: textfield;
}
/* Corner start-position buttons */
.preview-corner {
position: absolute;
width: 56px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: rgba(128, 128, 128, 0.4);
cursor: pointer;
z-index: 5;
transition: color 0.2s, transform 0.2s, text-shadow 0.2s;
user-select: none;
}
.preview-corner:hover {
color: rgba(76, 175, 80, 0.6);
transform: scale(1.2);
}
.preview-corner.active {
color: #4CAF50;
text-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
font-size: 22px;
}
.corner-top-left { top: 0; left: 0; }
.corner-top-right { top: 0; right: 0; }
.corner-bottom-left { bottom: 0; left: 0; }
.corner-bottom-right { bottom: 0; right: 0; }
/* Direction toggle inside screen */
.direction-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 12px;
color: white;
font-size: 12px;
cursor: pointer;
transition: background 0.2s;
user-select: none;
}
.direction-toggle:hover {
background: rgba(255, 255, 255, 0.25);
}
.direction-toggle #direction-icon {
font-size: 16px;
}
.preview-hint {
text-align: center;
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 8px;
}
@media (max-width: 768px) {
.displays-grid,
.devices-grid {

View File

@@ -12,7 +12,7 @@ from wled_controller.core.calibration import (
calibration_to_dict,
create_default_calibration,
)
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL, ProcessingSettings
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -77,6 +77,7 @@ class Device:
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
},
"calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(),
@@ -103,6 +104,10 @@ class Device:
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get(
"state_check_interval",
settings_data.get("health_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
),
)
calibration_data = data.get("calibration")