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 datetime import datetime
from typing import List from typing import List
import httpx
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from wled_controller import __version__ from wled_controller import __version__
@@ -19,6 +20,8 @@ from wled_controller.api.schemas import (
DeviceListResponse, DeviceListResponse,
ProcessingSettings as ProcessingSettingsSchema, ProcessingSettings as ProcessingSettingsSchema,
Calibration as CalibrationSchema, Calibration as CalibrationSchema,
CalibrationTestModeRequest,
CalibrationTestModeResponse,
ProcessingState, ProcessingState,
MetricsResponse, MetricsResponse,
) )
@@ -147,11 +150,44 @@ async def create_device(
try: try:
logger.info(f"Creating device: {device_data.name}") 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( device = store.create_device(
name=device_data.name, name=device_data.name,
url=device_data.url, url=device_data.url,
led_count=device_data.led_count, led_count=wled_led_count,
) )
# Add to processor manager # Add to processor manager
@@ -175,6 +211,7 @@ async def create_device(
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
brightness=device.settings.brightness, brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at, created_at=device.created_at,
@@ -207,6 +244,8 @@ async def list_devices(
display_index=device.settings.display_index, display_index=device.settings.display_index,
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, 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)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at, created_at=device.created_at,
@@ -248,6 +287,8 @@ async def get_device(
display_index=device.settings.display_index, display_index=device.settings.display_index,
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, 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)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at, created_at=device.created_at,
@@ -284,6 +325,7 @@ async def update_device(
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
brightness=device.settings.brightness, brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
), ),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
created_at=device.created_at, created_at=device.created_at,
@@ -409,6 +451,7 @@ async def get_settings(
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
brightness=device.settings.brightness, 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, brightness=settings.color_correction.brightness if settings.color_correction else 1.0,
gamma=settings.color_correction.gamma if settings.color_correction else 2.2, gamma=settings.color_correction.gamma if settings.color_correction else 2.2,
saturation=settings.color_correction.saturation if settings.color_correction else 1.0, saturation=settings.color_correction.saturation if settings.color_correction else 1.0,
state_check_interval=settings.state_check_interval,
) )
# Update in storage # Update in storage
@@ -446,6 +490,8 @@ async def update_settings(
display_index=device.settings.display_index, display_index=device.settings.display_index,
fps=device.settings.fps, fps=device.settings.fps,
border_width=device.settings.border_width, border_width=device.settings.border_width,
brightness=device.settings.brightness,
state_check_interval=device.settings.state_check_interval,
) )
except ValueError as e: except ValueError as e:
@@ -504,71 +550,62 @@ async def update_calibration(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/devices/{device_id}/calibration/test", tags=["Calibration"]) @router.put(
async def test_calibration( "/api/v1/devices/{device_id}/calibration/test",
response_model=CalibrationTestModeResponse,
tags=["Calibration"],
)
async def set_calibration_test_mode(
device_id: str, device_id: str,
body: CalibrationTestModeRequest,
_auth: AuthRequired, _auth: AuthRequired,
edge: str = "top",
color: List[int] = [255, 0, 0],
store: DeviceStore = Depends(get_device_store), 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: try:
# Get device
device = store.get_device(device_id) device = store.get_device(device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found") raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Find the segment for this edge # Validate edge names and colors
segment = None valid_edges = {"top", "right", "bottom", "left"}
for seg in device.calibration.segments: for edge_name, color in body.edges.items():
if seg.edge == edge: if edge_name not in valid_edges:
segment = seg raise HTTPException(
break 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: await manager.set_test_mode(device_id, body.edges)
raise HTTPException(status_code=400, detail=f"No LEDs configured for {edge} edge")
# Create pixel array - all black except for the test edge active_edges = list(body.edges.keys())
pixels = [(0, 0, 0)] * device.led_count logger.info(
f"Test mode {'activated' if active_edges else 'deactivated'} "
f"for device {device_id}: {active_edges}"
)
# Light up the test edge return CalibrationTestModeResponse(
r, g, b = color if len(color) == 3 else [255, 0, 0] test_mode=len(active_edges) > 0,
for i in range(segment.led_start, segment.led_start + segment.led_count): active_edges=active_edges,
if i < device.led_count: device_id=device_id,
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,
}
except HTTPException: except HTTPException:
raise raise
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as 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)) 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 pydantic import BaseModel, Field, HttpUrl
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
# Health and Version Schemas # Health and Version Schemas
@@ -53,7 +55,6 @@ class DeviceCreate(BaseModel):
name: str = Field(description="Device name", min_length=1, max_length=100) 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)") 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): class DeviceUpdate(BaseModel):
@@ -61,7 +62,6 @@ class DeviceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Device name", min_length=1, max_length=100)
url: Optional[str] = Field(None, description="WLED device URL") 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") 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) 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) 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) 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( color_correction: Optional[ColorCorrection] = Field(
default_factory=ColorCorrection, default_factory=ColorCorrection,
description="Color correction settings" 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): class DeviceResponse(BaseModel):
"""Device information response.""" """Device information response."""
@@ -154,6 +177,13 @@ class ProcessingState(BaseModel):
display_index: int = Field(description="Current display index") display_index: int = Field(description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update") last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") 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): class MetricsResponse(BaseModel):

View File

@@ -4,7 +4,9 @@ import asyncio
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Dict, Optional from typing import Dict, List, Optional, Tuple
import httpx
from wled_controller.core.calibration import ( from wled_controller.core.calibration import (
CalibrationConfig, CalibrationConfig,
@@ -18,6 +20,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks
@dataclass @dataclass
class ProcessingSettings: class ProcessingSettings:
@@ -31,6 +35,20 @@ class ProcessingSettings:
saturation: float = 1.0 saturation: float = 1.0
smoothing: float = 0.3 smoothing: float = 0.3
interpolation_mode: str = "average" 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 @dataclass
@@ -60,6 +78,10 @@ class ProcessorState:
task: Optional[asyncio.Task] = None task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics) metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
previous_colors: Optional[list] = None 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: class ProcessorManager:
@@ -68,8 +90,16 @@ class ProcessorManager:
def __init__(self): def __init__(self):
"""Initialize processor manager.""" """Initialize processor manager."""
self._processors: Dict[str, ProcessorState] = {} self._processors: Dict[str, ProcessorState] = {}
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
logger.info("Processor manager initialized") 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( def add_device(
self, self,
device_id: str, device_id: str,
@@ -105,6 +135,11 @@ class ProcessorManager:
) )
self._processors[device_id] = state 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") logger.info(f"Added device {device_id} with {led_count} LEDs")
def remove_device(self, device_id: str): def remove_device(self, device_id: str):
@@ -123,6 +158,9 @@ class ProcessorManager:
if self._processors[device_id].is_running: if self._processors[device_id].is_running:
raise RuntimeError(f"Cannot remove device {device_id} while processing") 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] del self._processors[device_id]
logger.info(f"Removed device {device_id}") logger.info(f"Removed device {device_id}")
@@ -291,6 +329,11 @@ class ProcessorManager:
while state.is_running: while state.is_running:
loop_start = time.time() loop_start = time.time()
# Skip capture/send while in calibration test mode
if state.test_mode_active:
await asyncio.sleep(frame_time)
continue
try: try:
# Run blocking operations in thread pool to avoid blocking event loop # Run blocking operations in thread pool to avoid blocking event loop
# Capture screen (blocking I/O) # Capture screen (blocking I/O)
@@ -375,6 +418,7 @@ class ProcessorManager:
state = self._processors[device_id] state = self._processors[device_id]
metrics = state.metrics metrics = state.metrics
h = state.health
return { return {
"device_id": device_id, "device_id": device_id,
@@ -384,8 +428,77 @@ class ProcessorManager:
"display_index": state.settings.display_index, "display_index": state.settings.display_index,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "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: def get_metrics(self, device_id: str) -> dict:
"""Get detailed metrics for a device. """Get detailed metrics for a device.
@@ -447,9 +560,12 @@ class ProcessorManager:
return list(self._processors.keys()) return list(self._processors.keys())
async def stop_all(self): async def stop_all(self):
"""Stop processing for all devices.""" """Stop processing and health monitoring for all devices."""
device_ids = list(self._processors.keys()) # Stop health monitoring
await self.stop_health_monitoring()
# Stop processing
device_ids = list(self._processors.keys())
for device_id in device_ids: for device_id in device_ids:
if self._processors[device_id].is_running: if self._processors[device_id].is_running:
try: try:
@@ -457,4 +573,116 @@ class ProcessorManager:
except Exception as e: except Exception as e:
logger.error(f"Error stopping device {device_id}: {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") 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") logger.info(f"Loaded {len(devices)} devices from storage")
# Start background health monitoring for all devices
await processor_manager.start_health_monitoring()
yield yield
# Shutdown # Shutdown

View File

@@ -5,6 +5,31 @@ let apiKey = null;
// Track logged errors to avoid console spam // Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } 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 // Locale management
let currentLocale = 'en'; let currentLocale = 'en';
let translations = {}; let translations = {};
@@ -238,7 +263,7 @@ async function loadServerInfo() {
const response = await fetch('/health'); const response = await fetch('/health');
const data = await response.json(); 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').textContent = '●';
document.getElementById('server-status').className = 'status-badge online'; document.getElementById('server-status').className = 'status-badge online';
} catch (error) { } catch (error) {
@@ -270,32 +295,6 @@ async function loadDisplays() {
return; 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 // Render visual layout
renderDisplayLayout(data.displays); renderDisplayLayout(data.displays);
} catch (error) { } catch (error) {
@@ -349,9 +348,12 @@ function renderDisplayLayout(displays) {
<div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}" <div class="layout-display ${display.is_primary ? 'primary' : 'secondary'}"
style="left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;" 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})"> 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"> <div class="layout-display-label">
<strong>${display.name}</strong> <strong>${display.name}</strong>
<small>${display.width}×${display.height}</small> <small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div> </div>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''} ${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div> </div>
@@ -466,28 +468,56 @@ function createDeviceCard(device) {
const settings = device.settings || {}; const settings = device.settings || {};
const isProcessing = state.processing || false; 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 statusKey = isProcessing ? 'device.status.processing' : 'device.status.idle';
const status = isProcessing ? 'processing' : '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 ` return `
<div class="card" data-device-id="${device.id}"> <div class="card" data-device-id="${device.id}">
<div class="card-header"> <div class="card-header">
<div class="card-title">${device.name || device.id}</div> <div class="card-title">
<span class="badge ${status}">${t(statusKey)}</span> <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>
<div class="card-content"> <div class="card-content">
<div class="info-row"> <div class="info-row">
<span class="info-label">${t('device.url')}</span> <span class="info-label">${t('device.url')}</span>
<span class="info-value">${device.url || 'N/A'}</span> <span class="info-value">${device.url || 'N/A'}</span>
</div> </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 ? ` ${isProcessing ? `
<div class="metrics-grid"> <div class="metrics-grid">
<div class="metric"> <div class="metric">
@@ -509,6 +539,13 @@ function createDeviceCard(device) {
</div> </div>
` : ''} ` : ''}
</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"> <div class="card-actions">
${isProcessing ? ` ${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopProcessing('${device.id}')" title="${t('device.button.stop')}"> <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-id').value = device.id;
document.getElementById('settings-device-name').value = device.name; document.getElementById('settings-device-name').value = device.name;
document.getElementById('settings-device-url').value = device.url; 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) // Snapshot initial values for dirty checking
const brightnessPercent = Math.round((device.settings.brightness || 1.0) * 100); settingsInitialValues = {
document.getElementById('settings-device-brightness').value = brightnessPercent; name: device.name,
document.getElementById('brightness-value').textContent = brightnessPercent + '%'; url: device.url,
state_check_interval: String(device.settings.state_check_interval || 30),
};
// Show modal // Show modal
const modal = document.getElementById('device-settings-modal'); const modal = document.getElementById('device-settings-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
document.body.classList.add('modal-open'); lockBody();
// Focus first input // Focus first input
setTimeout(() => { 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 modal = document.getElementById('device-settings-modal');
const error = document.getElementById('settings-error'); const error = document.getElementById('settings-error');
modal.style.display = 'none'; modal.style.display = 'none';
error.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() { async function saveDeviceSettings() {
const deviceId = document.getElementById('settings-device-id').value; const deviceId = document.getElementById('settings-device-id').value;
const name = document.getElementById('settings-device-name').value.trim(); const name = document.getElementById('settings-device-name').value.trim();
const url = document.getElementById('settings-device-url').value.trim(); const url = document.getElementById('settings-device-url').value.trim();
const led_count = parseInt(document.getElementById('settings-device-led-count').value); const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const brightnessPercent = parseInt(document.getElementById('settings-device-brightness').value);
const brightness = brightnessPercent / 100.0; // Convert to 0.0-1.0
const error = document.getElementById('settings-error'); const error = document.getElementById('settings-error');
// Validation // Validation
if (!name || !url || !led_count || led_count < 1) { if (!name || !url) {
error.textContent = 'Please fill in all fields correctly'; error.textContent = 'Please fill in all fields correctly';
error.style.display = 'block'; error.style.display = 'block';
return; return;
} }
try { try {
// Update device info (name, url, led_count) // Update device info (name, url)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ name, url, led_count }) body: JSON.stringify({ name, url })
}); });
if (deviceResponse.status === 401) { if (deviceResponse.status === 401) {
@@ -714,11 +769,11 @@ async function saveDeviceSettings() {
return; return;
} }
// Update settings (brightness) // Update settings (health check interval)
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
method: 'PUT', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ brightness }) body: JSON.stringify({ state_check_interval })
}); });
if (settingsResponse.status === 401) { if (settingsResponse.status === 401) {
@@ -728,7 +783,7 @@ async function saveDeviceSettings() {
if (settingsResponse.ok) { if (settingsResponse.ok) {
showToast('Device settings updated', 'success'); showToast('Device settings updated', 'success');
closeDeviceSettingsModal(); forceCloseDeviceSettingsModal();
loadDevices(); loadDevices();
} else { } else {
const errorData = await settingsResponse.json(); 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 // Add device form handler
async function handleAddDevice(event) { async function handleAddDevice(event) {
event.preventDefault(); event.preventDefault();
const name = document.getElementById('device-name').value; const name = document.getElementById('device-name').value;
const url = document.getElementById('device-url').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 { try {
const response = await fetch(`${API_BASE}/devices`, { const response = await fetch(`${API_BASE}/devices`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ name, url, led_count }) body: JSON.stringify({ name, url })
}); });
if (response.status === 401) { if (response.status === 401) {
@@ -825,14 +899,14 @@ function showConfirm(message, title = null) {
noBtn.textContent = t('confirm.no'); noBtn.textContent = t('confirm.no');
modal.style.display = 'flex'; modal.style.display = 'flex';
document.body.classList.add('modal-open'); lockBody();
}); });
} }
function closeConfirmModal(result) { function closeConfirmModal(result) {
const modal = document.getElementById('confirm-modal'); const modal = document.getElementById('confirm-modal');
modal.style.display = 'none'; modal.style.display = 'none';
document.body.classList.remove('modal-open'); unlockBody();
if (confirmResolve) { if (confirmResolve) {
confirmResolve(result); confirmResolve(result);
@@ -881,13 +955,27 @@ async function showCalibration(deviceId) {
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom; document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
document.getElementById('cal-left-leds').value = edgeCounts.left; 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 // Update preview
updateCalibrationPreview(); updateCalibrationPreview();
// Show modal // Show modal
const modal = document.getElementById('calibration-modal'); const modal = document.getElementById('calibration-modal');
modal.style.display = 'flex'; modal.style.display = 'flex';
document.body.classList.add('modal-open'); lockBody();
} catch (error) { } catch (error) {
console.error('Failed to load calibration:', 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 modal = document.getElementById('calibration-modal');
const error = document.getElementById('calibration-error'); const error = document.getElementById('calibration-error');
modal.style.display = 'none'; modal.style.display = 'none';
error.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() { function updateCalibrationPreview() {
// Update edge counts in preview // Calculate total from edge inputs
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
const total = parseInt(document.getElementById('cal-top-leds').value || 0) + const total = parseInt(document.getElementById('cal-top-leds').value || 0) +
parseInt(document.getElementById('cal-right-leds').value || 0) + parseInt(document.getElementById('cal-right-leds').value || 0) +
parseInt(document.getElementById('cal-bottom-leds').value || 0) + parseInt(document.getElementById('cal-bottom-leds').value || 0) +
parseInt(document.getElementById('cal-left-leds').value || 0); parseInt(document.getElementById('cal-left-leds').value || 0);
document.getElementById('cal-total-leds').textContent = total; 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 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 = { // Update direction toggle display
'bottom_left': { bottom: '10px', left: '10px', top: 'auto', right: 'auto' }, const direction = document.getElementById('cal-layout').value;
'bottom_right': { bottom: '10px', right: '10px', top: 'auto', left: 'auto' }, const dirIcon = document.getElementById('direction-icon');
'top_left': { top: '10px', left: '10px', bottom: 'auto', right: 'auto' }, const dirLabel = document.getElementById('direction-label');
'top_right': { top: '10px', right: '10px', bottom: 'auto', left: 'auto' } if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
}; if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
const pos = positions[startPos]; // Update edge highlight states
indicator.style.top = pos.top; const deviceId = document.getElementById('calibration-device-id').value;
indicator.style.right = pos.right; const activeEdges = calibrationTestState[deviceId] || new Set();
indicator.style.bottom = pos.bottom;
indicator.style.left = pos.left; ['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 deviceId = document.getElementById('calibration-device-id').value;
const error = document.getElementById('calibration-error'); 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 { try {
const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { const response = await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, {
method: 'POST', method: 'PUT',
headers: getHeaders(), headers: getHeaders(),
body: JSON.stringify({ edge, color: [255, 0, 0] }) // Red color body: JSON.stringify({ edges })
}); });
if (response.status === 401) { if (response.status === 401) {
@@ -951,25 +1112,45 @@ async function testCalibrationEdge(edge) {
return; return;
} }
if (response.ok) { if (!response.ok) {
showToast(`Testing ${edge} edge (2 seconds)`, 'info');
} else {
const errorData = await response.json(); const errorData = await response.json();
error.textContent = `Test failed: ${errorData.detail}`; error.textContent = `Test failed: ${errorData.detail}`;
error.style.display = 'block'; error.style.display = 'block';
} }
} catch (err) { } catch (err) {
console.error('Failed to test edge:', err); console.error('Failed to toggle test edge:', err);
error.textContent = 'Failed to test edge'; error.textContent = 'Failed to toggle test edge';
error.style.display = 'block'; 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() { async function saveCalibration() {
const deviceId = document.getElementById('calibration-device-id').value; const deviceId = document.getElementById('calibration-device-id').value;
const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent); const deviceLedCount = parseInt(document.getElementById('cal-device-led-count').textContent);
const error = document.getElementById('calibration-error'); 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 topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0); const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0); const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
@@ -1036,7 +1217,7 @@ async function saveCalibration() {
if (response.ok) { if (response.ok) {
showToast('Calibration saved', 'success'); showToast('Calibration saved', 'success');
closeCalibrationModal(); forceCloseCalibrationModal();
loadDevices(); loadDevices();
} else { } else {
const errorData = await response.json(); const errorData = await response.json();
@@ -1085,6 +1266,40 @@ function shouldReverse(edge, startPosition, layout) {
return rules ? rules[edge] : false; 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 // Cleanup on page unload
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (refreshInterval) { if (refreshInterval) {

View File

@@ -10,10 +10,12 @@
<body style="visibility: hidden;"> <body style="visibility: hidden;">
<div class="container"> <div class="container">
<header> <header>
<h1 data-i18n="app.title">WLED Screen Controller</h1> <div class="header-title">
<div class="server-info">
<span id="server-version"><span data-i18n="app.version">Version:</span> <span id="version-number">Loading...</span></span>
<span id="server-status" class="status-badge"></span> <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"> <button class="theme-toggle" onclick="toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
<span id="theme-icon">🌙</span> <span id="theme-icon">🌙</span>
</button> </button>
@@ -21,9 +23,6 @@
<option value="en">English</option> <option value="en">English</option>
<option value="ru">Русский</option> <option value="ru">Русский</option>
</select> </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;"> <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> 🔑 <span data-i18n="auth.login">Login</span>
</button> </button>
@@ -34,25 +33,15 @@
</header> </header>
<section class="displays-section"> <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"> <div class="display-layout-preview">
<h3 data-i18n="displays.layout">Display Layout</h3>
<div id="display-layout-canvas" class="display-layout-canvas"> <div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading" data-i18n="displays.loading">Loading layout...</div> <div class="loading" data-i18n="displays.loading">Loading layout...</div>
</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> </div>
<!-- Display Cards --> <div id="displays-list" style="display: none;"></div>
<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>
</section> </section>
<section class="devices-section"> <section class="devices-section">
@@ -78,11 +67,6 @@
<label for="device-url" data-i18n="device.url">WLED URL:</label> <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> <input type="url" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div> </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> <button type="submit" class="btn btn-primary" data-i18n="device.button.add">Add Device</button>
</form> </form>
</section> </section>
@@ -108,115 +92,81 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="calibration-device-id"> <input type="hidden" id="calibration-device-id">
<p style="margin-bottom: 20px; color: var(--text-secondary);" data-i18n="calibration.description"> <p style="margin-bottom: 12px; 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. Configure how your LED strip is mapped to screen edges. Click an edge to toggle test mode.
</p> </p>
<!-- Visual Preview --> <!-- Interactive Preview with integrated LED inputs and test toggles -->
<div style="margin-bottom: 25px;"> <div style="margin-bottom: 12px;">
<div style="position: relative; width: 400px; height: 250px; margin: 0 auto; background: var(--card-bg); border: 2px solid var(--border-color); border-radius: 8px;"> <div class="calibration-preview">
<!-- Screen representation --> <!-- Screen with direction toggle -->
<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"> <div class="preview-screen">
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> </div>
<!-- Edge labels --> <!-- Clickable edge bars with LED count inputs -->
<div style="position: absolute; top: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);"> <div class="preview-edge edge-top" onclick="toggleTestEdge('top')">
<span data-i18n="calibration.preview.top">Top:</span> <span id="preview-top-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span> <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>
<div style="position: absolute; right: 5px; top: 50%; transform: translateY(-50%) rotate(90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;"> <div class="preview-edge edge-right" onclick="toggleTestEdge('right')">
<span data-i18n="calibration.preview.right">Right:</span> <span id="preview-right-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span> <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>
<div style="position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); font-size: 12px; color: var(--text-secondary);"> <div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')">
<span data-i18n="calibration.preview.bottom">Bottom:</span> <span id="preview-bottom-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span> <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>
<div style="position: absolute; left: 5px; top: 50%; transform: translateY(-50%) rotate(-90deg); font-size: 12px; color: var(--text-secondary); white-space: nowrap;"> <div class="preview-edge edge-left" onclick="toggleTestEdge('left')">
<span data-i18n="calibration.preview.left">Left:</span> <span id="preview-left-count">0</span> <span data-i18n="calibration.preview.leds">LEDs</span> <span>L</span>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<!-- Starting position indicator --> <!-- Corner start position buttons -->
<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> <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> </div>
<p class="preview-hint" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p>
</div> </div>
<!-- Layout Configuration --> <!-- Hidden selects (used by saveCalibration) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;"> <div style="display: none;">
<div class="form-group"> <select id="cal-start-position">
<label for="cal-start-position" data-i18n="calibration.start_position">Starting Position:</label> <option value="bottom_left">Bottom Left</option>
<select id="cal-start-position" onchange="updateCalibrationPreview()"> <option value="bottom_right">Bottom Right</option>
<option value="bottom_left" data-i18n="calibration.position.bottom_left">Bottom Left</option> <option value="top_left">Top Left</option>
<option value="bottom_right" data-i18n="calibration.position.bottom_right">Bottom Right</option> <option value="top_right">Top Right</option>
<option value="top_left" data-i18n="calibration.position.top_left">Top Left</option> </select>
<option value="top_right" data-i18n="calibration.position.top_right">Top Right</option> <select id="cal-layout">
</select> <option value="clockwise">Clockwise</option>
</div> <option value="counterclockwise">Counterclockwise</option>
</select>
<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">
<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> </div>
<!-- LED Counts per Edge --> <div class="form-group" style="margin-bottom: 12px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px;"> <label for="cal-offset" data-i18n="calibration.offset">LED Offset:</label>
<div class="form-group"> <input type="number" id="cal-offset" min="0" value="0" oninput="updateCalibrationPreview()">
<label for="cal-top-leds" data-i18n="calibration.leds.top">Top LEDs:</label> <small style="color: #aaa; display: block; margin-top: 4px;" data-i18n="calibration.offset_hint">LEDs from LED 0 to start corner (along strip)</small>
<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>
<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> <strong data-i18n="calibration.total">Total LEDs:</strong> <span id="cal-total-leds">0</span> / <span id="cal-device-led-count">0</span>
</div> </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 id="calibration-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="closeCalibrationModal()" data-i18n="calibration.button.cancel">Cancel</button> <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> </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> <small class="input-hint" data-i18n="settings.url.hint">IP address or hostname of your WLED device</small>
</div> </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"> <div class="form-group">
<label for="settings-device-brightness"><span data-i18n="settings.brightness">Brightness:</span> <span id="brightness-value">100%</span></label> <label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="range" id="settings-device-brightness" min="0" max="100" value="100" <input type="number" id="settings-health-interval" min="5" max="600" value="30">
oninput="document.getElementById('brightness-value').textContent = this.value + '%'" <small class="input-hint" data-i18n="settings.health_interval.hint">How often to check the WLED device status (5-600 seconds)</small>
style="width: 100%;">
<small class="input-hint" data-i18n="settings.brightness.hint">Global brightness for this WLED device (0-100%)</small>
</div> </div>
<div id="settings-error" class="error-message" style="display: none;"></div> <div id="settings-error" class="error-message" style="display: none;"></div>
@@ -346,24 +289,13 @@
const apiKey = localStorage.getItem('wled_api_key'); const apiKey = localStorage.getItem('wled_api_key');
const loginBtn = document.getElementById('login-btn'); const loginBtn = document.getElementById('login-btn');
const logoutBtn = document.getElementById('logout-btn'); const logoutBtn = document.getElementById('logout-btn');
const authStatus = document.getElementById('auth-status');
const loggedInUser = document.getElementById('logged-in-user');
if (apiKey) { if (apiKey) {
// Logged in
loginBtn.style.display = 'none'; loginBtn.style.display = 'none';
logoutBtn.style.display = 'inline-block'; 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 { } else {
// Logged out
loginBtn.style.display = 'inline-block'; loginBtn.style.display = 'inline-block';
logoutBtn.style.display = 'none'; logoutBtn.style.display = 'none';
authStatus.style.display = 'none';
} }
} }
@@ -419,7 +351,7 @@
input.placeholder = 'Enter your API key...'; input.placeholder = 'Enter your API key...';
error.style.display = 'none'; error.style.display = 'none';
modal.style.display = 'flex'; modal.style.display = 'flex';
document.body.classList.add('modal-open'); lockBody();
// Hide cancel button if this is required login (no existing session) // Hide cancel button if this is required login (no existing session)
cancelBtn.style.display = hideCancel ? 'none' : 'inline-block'; cancelBtn.style.display = hideCancel ? 'none' : 'inline-block';
@@ -430,7 +362,7 @@
function closeApiKeyModal() { function closeApiKeyModal() {
const modal = document.getElementById('api-key-modal'); const modal = document.getElementById('api-key-modal');
modal.style.display = 'none'; modal.style.display = 'none';
document.body.classList.remove('modal-open'); unlockBody();
} }
function submitApiKey(event) { function submitApiKey(event) {

View File

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

View File

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

View File

@@ -63,6 +63,12 @@ header {
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-title {
display: flex;
align-items: center;
gap: 10px;
}
h1 { h1 {
font-size: 2rem; font-size: 2rem;
color: var(--primary-color); color: var(--primary-color);
@@ -80,6 +86,16 @@ h2 {
gap: 15px; 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 { .status-badge {
font-size: 1.5rem; font-size: 1.5rem;
animation: pulse 2s infinite; animation: pulse 2s infinite;
@@ -98,6 +114,45 @@ h2 {
50% { opacity: 0.5; } 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 { section {
margin-bottom: 40px; margin-bottom: 40px;
} }
@@ -118,7 +173,6 @@ section {
} }
.card:hover { .card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
@@ -129,9 +183,35 @@ section {
margin-bottom: 15px; 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 { .card-title {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 600; 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 { .badge {
@@ -254,14 +334,9 @@ section {
color: var(--info-color); color: var(--info-color);
} }
.badge-primary { .primary-star {
background: var(--primary-color); color: var(--primary-color);
color: white; font-size: 1.2rem;
}
.badge-secondary {
background: var(--border-color);
color: var(--text-secondary);
} }
/* Display Layout Visualization */ /* Display Layout Visualization */
@@ -324,6 +399,22 @@ section {
background: linear-gradient(135deg, rgba(128, 128, 128, 0.1), rgba(128, 128, 128, 0.05)); 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 { .layout-display-label {
text-align: center; text-align: center;
padding: 5px; padding: 5px;
@@ -386,6 +477,16 @@ section {
background: rgba(128, 128, 128, 0.2); background: rgba(128, 128, 128, 0.2);
} }
/* Card brightness slider */
.brightness-control {
padding: 0;
margin-bottom: 12px;
}
.brightness-slider {
width: 100%;
}
.add-device-section { .add-device-section {
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -668,6 +769,193 @@ input:-webkit-autofill:focus {
text-decoration: underline; 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) { @media (max-width: 768px) {
.displays-grid, .displays-grid,
.devices-grid { .devices-grid {

View File

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