Add DDP protocol support, fix event loop blocking, and add LED offset calibration
Some checks failed
Validate / validate (push) Failing after 8s
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
24
server/scripts/restart-server.bat
Normal file
24
server/scripts/restart-server.bat
Normal 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
|
||||
7
server/scripts/start-server-background.vbs
Normal file
7
server/scripts/start-server-background.vbs
Normal 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
|
||||
15
server/scripts/start-server.bat
Normal file
15
server/scripts/start-server.bat
Normal 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
|
||||
19
server/scripts/stop-server.bat
Normal file
19
server/scripts/stop-server.bat
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
185
server/src/wled_controller/core/ddp_client.py
Normal file
185
server/src/wled_controller/core/ddp_client.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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": "Светодиодов Снизу:",
|
||||
|
||||
Reference in New Issue
Block a user