Add DDP protocol support, fix event loop blocking, and add LED offset calibration
Some checks failed
Validate / validate (push) Failing after 8s

- Add DDP client for LED strips >500 LEDs (UDP port 4048), with automatic
  fallback from HTTP JSON API when LED count exceeds limit
- Wrap blocking operations (screen capture, image processing) in
  asyncio.to_thread() to prevent event loop starvation
- Turn on WLED device and enable live mode when starting DDP streaming
- Add LED strip offset field to calibration (rotates color array to match
  physical LED position vs start corner)
- Add server management scripts (start, stop, restart, background start)
- Fix WebUI auth error handling and auto-refresh loop
- Add development API key to default config
- Add i18n translations for offset field (en/ru)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 12:44:06 +03:00
parent ec3c40d59c
commit 579821a69b
15 changed files with 504 additions and 48 deletions

View File

@@ -35,6 +35,25 @@ netstat -an | grep 8080
- Documentation files (`*.md`)
- Configuration files in `config/` if server supports hot-reload (check implementation)
### Git Push Policy
**CRITICAL**: NEVER push commits to the remote repository without explicit user approval.
#### Rules
- You MAY create commits when requested or when appropriate
- You MUST NOT push commits unless explicitly instructed by the user
- If the user says "commit", create a commit but DO NOT push
- If the user says "commit and push", you may push after committing
- Always wait for explicit permission before any push operation
#### Workflow
1. Make changes to code
2. Create commit when appropriate (with user consent)
3. **STOP and WAIT** - do not push
4. Only push when user explicitly requests it (e.g., "push", "commit and push", "push to remote")
### Testing Changes
After restarting the server with new code:

View File

@@ -10,10 +10,7 @@ auth:
# Format: label: "api-key"
api_keys:
# Generate secure keys: openssl rand -hex 32
# IMPORTANT: Add at least one key before starting the server
# home_assistant: "your-secure-api-key-1"
# web_dashboard: "your-secure-api-key-2"
# monitoring_script: "your-secure-api-key-3"
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
processing:
default_fps: 30

View File

@@ -0,0 +1,24 @@
@echo off
REM WLED Screen Controller Restart Script
REM This script restarts the WLED screen controller server
echo Restarting WLED Screen Controller...
echo.
REM Stop the server first
echo [1/2] Stopping server...
call "%~dp0\stop-server.bat"
REM Wait a moment
timeout /t 2 /nobreak >nul
REM Change to parent directory (server root)
cd /d "%~dp0\.."
REM Start the server
echo.
echo [2/2] Starting server...
python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
REM If the server exits, pause to show any error messages
pause

View File

@@ -0,0 +1,7 @@
Set WshShell = CreateObject("WScript.Shell")
Set FSO = CreateObject("Scripting.FileSystemObject")
' Get parent folder of scripts folder (server root)
WshShell.CurrentDirectory = FSO.GetParentFolderName(FSO.GetParentFolderName(WScript.ScriptFullName))
WshShell.Run "python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080", 0, False
Set FSO = Nothing
Set WshShell = Nothing

View File

@@ -0,0 +1,15 @@
@echo off
REM WLED Screen Controller Startup Script
REM This script starts the WLED screen controller server
echo Starting WLED Screen Controller...
echo.
REM Change to the server directory (parent of scripts folder)
cd /d "%~dp0\.."
REM Start the server
python -m uvicorn wled_controller.main:app --host 0.0.0.0 --port 8080
REM If the server exits, pause to show any error messages
pause

View File

@@ -0,0 +1,19 @@
@echo off
REM WLED Screen Controller Stop Script
REM This script stops the running WLED screen controller server
echo Stopping WLED Screen Controller...
echo.
REM Find and kill Python processes running wled_controller.main
for /f "tokens=2" %%i in ('tasklist /FI "IMAGENAME eq python.exe" /FO LIST ^| findstr /B "PID:"') do (
wmic process where "ProcessId=%%i" get CommandLine 2>nul | findstr /C:"wled_controller.main" >nul
if not errorlevel 1 (
taskkill /PID %%i /F
echo WLED controller process (PID %%i) terminated.
)
)
echo.
echo Done! WLED Screen Controller stopped.
pause

View File

@@ -106,6 +106,11 @@ class Calibration(BaseModel):
default="bottom_left",
description="Position of LED index 0"
)
offset: int = Field(
default=0,
ge=0,
description="Number of LEDs from physical LED 0 to start corner (along strip direction)"
)
segments: List[CalibrationSegment] = Field(
description="LED segments for each screen edge",
min_length=1,

View File

@@ -32,6 +32,7 @@ class CalibrationConfig:
layout: Literal["clockwise", "counterclockwise"]
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
segments: List[CalibrationSegment]
offset: int = 0 # Physical LED offset from start corner (number of LEDs from LED 0 to start corner)
def validate(self) -> bool:
"""Validate calibration configuration.
@@ -181,7 +182,14 @@ class PixelMapper:
color = self._calc_color(pixel_segment)
led_colors[led_idx] = color
logger.debug(f"Mapped border pixels to {total_leds} LED colors")
# Apply physical LED offset by rotating the array
# Offset = number of LEDs from LED 0 to the start corner
# Physical LED[i] should get calibration color[(i - offset) % total]
offset = self.calibration.offset % total_leds if total_leds > 0 else 0
if offset > 0:
led_colors = led_colors[total_leds - offset:] + led_colors[:total_leds - offset]
logger.debug(f"Mapped border pixels to {total_leds} LED colors (offset={offset})")
return led_colors
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
@@ -309,6 +317,7 @@ def calibration_from_dict(data: dict) -> CalibrationConfig:
layout=data["layout"],
start_position=data["start_position"],
segments=segments,
offset=data.get("offset", 0),
)
config.validate()
@@ -332,6 +341,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
return {
"layout": config.layout,
"start_position": config.start_position,
"offset": config.offset,
"segments": [
{
"edge": seg.edge,

View File

@@ -0,0 +1,185 @@
"""DDP (Distributed Display Protocol) client for WLED."""
import asyncio
import struct
from typing import List, Tuple
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class DDPClient:
"""UDP DDP client for sending pixel data to WLED devices."""
DDP_PORT = 4048
DDP_FLAGS_VER1 = 0x40 # VER=1, TIMECODE=0, STORAGE=0, REPLY=0, QUERY=0, PUSH=1
DDP_TYPE_RGB = 0x01
# Color order mappings: input (R,G,B) -> output indices
COLOR_ORDER_MAP = {
0: (1, 0, 2), # GRB: G=idx1, R=idx0, B=idx2
1: (0, 1, 2), # RGB: R=idx0, G=idx1, B=idx2
2: (2, 0, 1), # BRG: B=idx2, R=idx0, G=idx1
3: (0, 2, 1), # RBG: R=idx0, B=idx2, G=idx1
4: (2, 1, 0), # BGR: B=idx2, G=idx1, R=idx0
5: (1, 2, 0), # GBR: G=idx1, B=idx2, R=idx0
}
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False, color_order: int = 1):
"""Initialize DDP client.
Args:
host: WLED device IP address or hostname
port: DDP port (default 4048)
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED)
color_order: Color order (0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR)
"""
self.host = host
self.port = port
self.rgbw = rgbw
self.color_order = color_order
self._transport = None
self._protocol = None
self._sequence = 0
async def connect(self):
"""Establish UDP connection."""
try:
loop = asyncio.get_event_loop()
self._transport, self._protocol = await loop.create_datagram_endpoint(
asyncio.DatagramProtocol,
remote_addr=(self.host, self.port)
)
logger.info(f"DDP client connected to {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect DDP client: {e}")
raise
async def close(self):
"""Close the connection."""
if self._transport:
self._transport.close()
self._transport = None
self._protocol = None
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
def _build_ddp_packet(
self,
rgb_data: bytes,
offset: int = 0,
sequence: int = 1
) -> bytes:
"""Build a DDP packet.
DDP packet format (10-byte header + data):
- Byte 0: Flags (0x40 = VER1, PUSH)
- Byte 1: Sequence number
- Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID
- Bytes 4-7: Data offset in bytes (4-byte little-endian)
- Bytes 8-9: Data length in bytes (2-byte big-endian)
- Bytes 10+: Pixel data
Args:
rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255)
Returns:
Complete DDP packet as bytes
"""
flags = self.DDP_FLAGS_VER1
data_type = self.DDP_TYPE_RGB
source_id = 0x01
data_len = len(rgb_data)
# Build header (10 bytes)
header = struct.pack(
'!BBB B I H', # Network byte order (big-endian)
flags, # Flags
sequence, # Sequence
data_type, # Data type
source_id, # Source/Destination
offset, # Data offset (4 bytes)
data_len # Data length (2 bytes)
)
return header + rgb_data
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
max_packet_size: int = 1400
) -> bool:
"""Send pixel data via DDP.
Args:
pixels: List of (R, G, B) tuples
max_packet_size: Maximum UDP packet size (default 1400 bytes for safety)
Returns:
True if successful
Raises:
RuntimeError: If send fails
"""
if not self._transport:
raise RuntimeError("DDP client not connected")
try:
# Get color order mapping
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2))
# Convert pixels to flat RGB or RGBW bytes with color reordering
pixel_bytes = bytearray()
for r, g, b in pixels:
# Reorder color channels based on WLED configuration
rgb = [int(r), int(g), int(b)]
reordered = [rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]]]
pixel_bytes.extend(reordered)
if self.rgbw:
pixel_bytes.append(0) # White channel = 0
total_bytes = len(pixel_bytes)
bytes_per_packet = max_packet_size - 10 # Account for 10-byte header
# Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
logger.debug(
f"Sending {len(pixels)} pixels ({total_bytes} bytes) "
f"in {num_packets} DDP packet(s)"
)
for i in range(num_packets):
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end])
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Build and send packet
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
)
return True
except Exception as e:
logger.error(f"Failed to send DDP pixels: {e}")
raise RuntimeError(f"DDP send failed: {e}")
async def __aenter__(self):
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()

View File

@@ -206,8 +206,13 @@ class ProcessorManager:
# Connect to WLED device
try:
state.wled_client = WLEDClient(state.device_url)
# Enable DDP for large LED counts (>500 LEDs)
use_ddp = state.led_count > 500
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
await state.wled_client.connect()
if use_ddp:
logger.info(f"Device {device_id} using DDP protocol ({state.led_count} LEDs)")
except Exception as e:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
@@ -287,26 +292,29 @@ class ProcessorManager:
loop_start = time.time()
try:
# Capture screen
capture = capture_display(settings.display_index)
# Run blocking operations in thread pool to avoid blocking event loop
# Capture screen (blocking I/O)
capture = await asyncio.to_thread(capture_display, settings.display_index)
# Extract border pixels
border_pixels = extract_border_pixels(capture, settings.border_width)
# Extract border pixels (CPU-intensive)
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
# Map to LED colors
led_colors = state.pixel_mapper.map_border_to_leds(border_pixels)
# Map to LED colors (CPU-intensive)
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
# Apply color correction
led_colors = apply_color_correction(
# Apply color correction (CPU-intensive)
led_colors = await asyncio.to_thread(
apply_color_correction,
led_colors,
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
)
# Apply smoothing
# Apply smoothing (CPU-intensive)
if state.previous_colors and settings.smoothing > 0:
led_colors = smooth_colors(
led_colors = await asyncio.to_thread(
smooth_colors,
led_colors,
state.previous_colors,
settings.smoothing,
@@ -331,7 +339,7 @@ class ProcessorManager:
except Exception as e:
state.metrics.errors_count += 1
state.metrics.last_error = str(e)
logger.error(f"Processing error for device {device_id}: {e}")
logger.error(f"Processing error for device {device_id}: {e}", exc_info=True)
# FPS control
elapsed = time.time() - loop_start

View File

@@ -1,12 +1,14 @@
"""WLED HTTP client for controlling LED devices."""
"""WLED client for controlling LED devices via HTTP or DDP."""
import asyncio
from dataclasses import dataclass
from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse
import httpx
from wled_controller.utils import get_logger
from wled_controller.core.ddp_client import DDPClient
logger = get_logger(__name__)
@@ -22,10 +24,15 @@ class WLEDInfo:
product: str
mac: str
ip: str
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel)
color_order: int = 1 # Color order: 0=GRB, 1=RGB, 2=BRG, 3=RBG, 4=BGR, 5=GBR
class WLEDClient:
"""HTTP client for WLED devices."""
"""Client for WLED devices supporting both HTTP and DDP protocols."""
# HTTP JSON API has ~10KB limit, ~500 LEDs max
HTTP_MAX_LEDS = 500
def __init__(
self,
@@ -33,6 +40,7 @@ class WLEDClient:
timeout: int = 5,
retry_attempts: int = 3,
retry_delay: int = 1,
use_ddp: bool = False,
):
"""Initialize WLED client.
@@ -41,13 +49,20 @@ class WLEDClient:
timeout: Request timeout in seconds
retry_attempts: Number of retry attempts on failure
retry_delay: Delay between retries in seconds
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
"""
self.url = url.rstrip("/")
self.timeout = timeout
self.retry_attempts = retry_attempts
self.retry_delay = retry_delay
self.use_ddp = use_ddp
# Extract hostname/IP from URL for DDP
parsed = urlparse(self.url)
self.host = parsed.hostname or parsed.netloc.split(':')[0]
self._client: Optional[httpx.AsyncClient] = None
self._ddp_client: Optional[DDPClient] = None
self._connected = False
async def __aenter__(self):
@@ -69,15 +84,45 @@ class WLEDClient:
RuntimeError: If connection fails
"""
try:
# Always create HTTP client for info/control
self._client = httpx.AsyncClient(timeout=self.timeout)
# Test connection by getting device info
info = await self.get_info()
# Auto-enable DDP for large LED counts
if info.led_count > self.HTTP_MAX_LEDS and not self.use_ddp:
logger.info(
f"Device has {info.led_count} LEDs (>{self.HTTP_MAX_LEDS}), "
"auto-enabling DDP protocol"
)
self.use_ddp = True
# Create DDP client if needed
if self.use_ddp:
# DDP always uses RGB mode (3 bytes/LED) - WLED handles RGBW and color order internally
self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1)
await self._ddp_client.connect()
# Turn on the device and disable Audio Reactive mode for DDP
try:
await self._request("POST", "/json/state", json_data={
"on": True,
"live": True,
"AudioReactive": {"on": False}
})
logger.debug("Turned on device and enabled live mode for DDP streaming")
except Exception as e:
logger.warning(f"Could not configure device for DDP: {e}")
logger.info(f"DDP protocol enabled for pixel data transmission (RGB mode)")
self._connected = True
protocol = "DDP" if self.use_ddp else "HTTP"
logger.info(
f"Connected to WLED device: {info.name} ({info.version}) "
f"with {info.led_count} LEDs"
f"with {info.led_count} LEDs via {protocol}"
)
return True
@@ -87,6 +132,9 @@ class WLEDClient:
if self._client:
await self._client.aclose()
self._client = None
if self._ddp_client:
await self._ddp_client.close()
self._ddp_client = None
raise RuntimeError(f"Failed to connect to WLED device: {e}")
async def close(self):
@@ -94,6 +142,9 @@ class WLEDClient:
if self._client:
await self._client.aclose()
self._client = None
if self._ddp_client:
await self._ddp_client.close()
self._ddp_client = None
self._connected = False
logger.debug(f"Closed connection to {self.url}")
@@ -175,16 +226,29 @@ class WLEDClient:
RuntimeError: If request fails
"""
try:
# Get basic info
data = await self._request("GET", "/json/info")
leds_info = data.get("leds", {})
# Get LED configuration for color order
cfg_data = await self._request("GET", "/json/cfg")
color_order = 1 # Default to RGB
if "hw" in cfg_data and "led" in cfg_data["hw"]:
led_cfg = cfg_data["hw"]["led"]
if "ins" in led_cfg and len(led_cfg["ins"]) > 0:
# Use color order from first LED strip
color_order = led_cfg["ins"][0].get("order", 1)
return WLEDInfo(
name=data.get("name", "Unknown"),
version=data.get("ver", "Unknown"),
led_count=data.get("leds", {}).get("count", 0),
led_count=leds_info.get("count", 0),
brand=data.get("brand", "WLED"),
product=data.get("product", "FOSS"),
mac=data.get("mac", ""),
ip=data.get("ip", ""),
rgbw=leds_info.get("rgbw", False), # Auto-detect RGBW vs RGB
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.)
)
except Exception as e:
@@ -215,10 +279,12 @@ class WLEDClient:
) -> bool:
"""Send pixel colors to WLED device.
Uses DDP for large LED counts (>500), HTTP JSON API for smaller counts.
Args:
pixels: List of (R, G, B) tuples for each LED
brightness: Global brightness (0-255)
segment_id: Segment ID to update
segment_id: Segment ID to update (HTTP only)
Returns:
True if successful
@@ -234,39 +300,98 @@ class WLEDClient:
if not 0 <= brightness <= 255:
raise ValueError(f"Brightness must be 0-255, got {brightness}")
# Validate and convert pixel values to Python ints (handles numpy types)
# WLED expects a flat array: [r1, g1, b1, r2, g2, b2, ...]
flat_pixels = []
# Validate pixel values
validated_pixels = []
for i, (r, g, b) in enumerate(pixels):
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
raise ValueError(f"Invalid RGB values at index {i}: ({r}, {g}, {b})")
# Convert to Python int and flatten to [r, g, b, r, g, b, ...]
flat_pixels.extend([int(r), int(g), int(b)])
validated_pixels.append((int(r), int(g), int(b)))
# Build WLED JSON state with flat pixel array
payload = {
"on": True,
"bri": int(brightness), # Ensure brightness is also a Python int
"seg": [
{
"id": segment_id,
"i": flat_pixels, # Individual LED colors as flat array
}
],
}
# Use DDP protocol if enabled
if self.use_ddp and self._ddp_client:
return await self._send_pixels_ddp(validated_pixels, brightness)
else:
return await self._send_pixels_http(validated_pixels, brightness, segment_id)
# Log payload details for debugging
logger.debug(f"Sending {len(pixels)} LEDs ({len(flat_pixels)} values) to WLED")
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
logger.debug(f"First 3 LEDs: {flat_pixels[:9] if len(flat_pixels) >= 9 else flat_pixels}")
async def _send_pixels_ddp(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
) -> bool:
"""Send pixels via DDP protocol.
Args:
pixels: List of (R, G, B) tuples
brightness: Global brightness (0-255)
Returns:
True if successful
"""
try:
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Successfully sent pixel colors to WLED device")
# Apply brightness to pixels
if brightness < 255:
brightness_factor = brightness / 255.0
pixels = [
(
int(r * brightness_factor),
int(g * brightness_factor),
int(b * brightness_factor)
)
for r, g, b in pixels
]
logger.debug(f"Sending {len(pixels)} LEDs via DDP")
await self._ddp_client.send_pixels(pixels)
logger.debug(f"Successfully sent pixel colors via DDP")
return True
except Exception as e:
logger.error(f"Failed to send pixels: {e}")
logger.error(f"Failed to send pixels via DDP: {e}")
raise
async def _send_pixels_http(
self,
pixels: List[Tuple[int, int, int]],
brightness: int = 255,
segment_id: int = 0,
) -> bool:
"""Send pixels via HTTP JSON API.
Args:
pixels: List of (R, G, B) tuples
brightness: Global brightness (0-255)
segment_id: Segment ID to update
Returns:
True if successful
"""
try:
# Build indexed pixel array: [led_index, r, g, b, ...]
indexed_pixels = []
for i, (r, g, b) in enumerate(pixels):
indexed_pixels.extend([i, int(r), int(g), int(b)])
# Build WLED JSON state
payload = {
"on": True,
"bri": int(brightness),
"seg": [
{
"id": segment_id,
"i": indexed_pixels,
}
],
}
logger.debug(f"Sending {len(pixels)} LEDs via HTTP ({len(indexed_pixels)} values)")
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
await self._request("POST", "/json/state", json_data=payload)
logger.debug(f"Successfully sent pixel colors via HTTP")
return True
except Exception as e:
logger.error(f"Failed to send pixels via HTTP: {e}")
raise
async def set_power(self, on: bool) -> bool:

View File

@@ -2,6 +2,9 @@ const API_BASE = '/api/v1';
let refreshInterval = null;
let apiKey = null;
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
// Locale management
let currentLocale = 'en';
let translations = {};
@@ -181,6 +184,12 @@ function handle401Error() {
localStorage.removeItem('wled_api_key');
apiKey = null;
// Stop auto-refresh to prevent repeated 401 errors
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
if (typeof updateAuthUI === 'function') {
updateAuthUI();
}
@@ -392,8 +401,14 @@ async function loadDevices() {
});
const metrics = await metricsResponse.json();
// Log device info, especially if there are errors
if (metrics.errors_count > 0) {
// Log device errors only when they change (avoid console spam)
const deviceKey = device.id;
const lastLogged = loggedErrors.get(deviceKey);
const hasNewErrors = !lastLogged ||
lastLogged.errorCount !== metrics.errors_count ||
lastLogged.lastError !== metrics.last_error;
if (metrics.errors_count > 0 && hasNewErrors) {
console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`);
// Log recent errors from state
@@ -412,6 +427,16 @@ async function loadDevices() {
// Log full state and metrics for debugging
console.log('Full state:', state);
console.log('Full metrics:', metrics);
// Update tracking
loggedErrors.set(deviceKey, {
errorCount: metrics.errors_count,
lastError: metrics.last_error
});
} else if (metrics.errors_count === 0 && lastLogged) {
// Clear tracking when errors are resolved
console.log(`[Device: ${device.name || device.id}] Errors cleared`);
loggedErrors.delete(deviceKey);
}
return { ...device, state, metrics };
@@ -763,7 +788,10 @@ function startAutoRefresh() {
}
refreshInterval = setInterval(() => {
loadDevices();
// Only refresh if user is authenticated
if (apiKey) {
loadDevices();
}
}, 2000); // Refresh every 2 seconds
}
@@ -840,6 +868,7 @@ async function showCalibration(deviceId) {
// Set layout
document.getElementById('cal-start-position').value = calibration.start_position;
document.getElementById('cal-layout').value = calibration.layout;
document.getElementById('cal-offset').value = calibration.offset || 0;
// Set LED counts per edge
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
@@ -984,9 +1013,12 @@ async function saveCalibration() {
}
});
const offset = parseInt(document.getElementById('cal-offset').value || 0);
const calibration = {
layout: layout,
start_position: startPosition,
offset: offset,
segments: segments
};

View File

@@ -158,6 +158,12 @@
<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>
<!-- LED Counts per Edge -->

View File

@@ -93,6 +93,8 @@
"calibration.direction": "Direction:",
"calibration.direction.clockwise": "Clockwise",
"calibration.direction.counterclockwise": "Counterclockwise",
"calibration.offset": "LED Offset:",
"calibration.offset_hint": "LEDs from LED 0 to start corner (along strip)",
"calibration.leds.top": "Top LEDs:",
"calibration.leds.right": "Right LEDs:",
"calibration.leds.bottom": "Bottom LEDs:",

View File

@@ -93,6 +93,8 @@
"calibration.direction": "Направление:",
"calibration.direction.clockwise": "По Часовой Стрелке",
"calibration.direction.counterclockwise": "Против Часовой Стрелки",
"calibration.offset": "Смещение LED:",
"calibration.offset_hint": "Количество LED от LED 0 до начального угла (по ленте)",
"calibration.leds.top": "Светодиодов Сверху:",
"calibration.leds.right": "Светодиодов Справа:",
"calibration.leds.bottom": "Светодиодов Снизу:",