Add WLED health monitoring, calibration test mode, and UI improvements
Some checks failed
Validate / validate (push) Failing after 8s
Some checks failed
Validate / validate (push) Failing after 8s
- Add background health checks (GET /json/info) with configurable interval per device - Auto-detect LED count from WLED device on add (remove led_count from create API) - Add calibration test mode: toggle edges on/off with colored LEDs via PUT endpoint - Show WLED firmware version badge and LED count badge on device cards - Add modal dirty tracking with discard confirmation on close/backdrop click - Fix layout jump when modals open by compensating for scrollbar width - Add state_check_interval to settings API and UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import sys
|
|||||||
from datetime import datetime
|
from 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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": "Нет"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user