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`)
|
- Documentation files (`*.md`)
|
||||||
- Configuration files in `config/` if server supports hot-reload (check implementation)
|
- 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
|
### Testing Changes
|
||||||
|
|
||||||
After restarting the server with new code:
|
After restarting the server with new code:
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ auth:
|
|||||||
# Format: label: "api-key"
|
# Format: label: "api-key"
|
||||||
api_keys:
|
api_keys:
|
||||||
# Generate secure keys: openssl rand -hex 32
|
# Generate secure keys: openssl rand -hex 32
|
||||||
# IMPORTANT: Add at least one key before starting the server
|
dev: "development-key-change-in-production" # Development key - CHANGE THIS!
|
||||||
# home_assistant: "your-secure-api-key-1"
|
|
||||||
# web_dashboard: "your-secure-api-key-2"
|
|
||||||
# monitoring_script: "your-secure-api-key-3"
|
|
||||||
|
|
||||||
processing:
|
processing:
|
||||||
default_fps: 30
|
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",
|
default="bottom_left",
|
||||||
description="Position of LED index 0"
|
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(
|
segments: List[CalibrationSegment] = Field(
|
||||||
description="LED segments for each screen edge",
|
description="LED segments for each screen edge",
|
||||||
min_length=1,
|
min_length=1,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class CalibrationConfig:
|
|||||||
layout: Literal["clockwise", "counterclockwise"]
|
layout: Literal["clockwise", "counterclockwise"]
|
||||||
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
start_position: Literal["top_left", "top_right", "bottom_left", "bottom_right"]
|
||||||
segments: List[CalibrationSegment]
|
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:
|
def validate(self) -> bool:
|
||||||
"""Validate calibration configuration.
|
"""Validate calibration configuration.
|
||||||
@@ -181,7 +182,14 @@ class PixelMapper:
|
|||||||
color = self._calc_color(pixel_segment)
|
color = self._calc_color(pixel_segment)
|
||||||
led_colors[led_idx] = color
|
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
|
return led_colors
|
||||||
|
|
||||||
def test_calibration(self, edge: str, color: Tuple[int, int, int]) -> List[Tuple[int, int, int]]:
|
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"],
|
layout=data["layout"],
|
||||||
start_position=data["start_position"],
|
start_position=data["start_position"],
|
||||||
segments=segments,
|
segments=segments,
|
||||||
|
offset=data.get("offset", 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
config.validate()
|
config.validate()
|
||||||
@@ -332,6 +341,7 @@ def calibration_to_dict(config: CalibrationConfig) -> dict:
|
|||||||
return {
|
return {
|
||||||
"layout": config.layout,
|
"layout": config.layout,
|
||||||
"start_position": config.start_position,
|
"start_position": config.start_position,
|
||||||
|
"offset": config.offset,
|
||||||
"segments": [
|
"segments": [
|
||||||
{
|
{
|
||||||
"edge": seg.edge,
|
"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
|
# Connect to WLED device
|
||||||
try:
|
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()
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
|
||||||
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||||
@@ -287,26 +292,29 @@ class ProcessorManager:
|
|||||||
loop_start = time.time()
|
loop_start = time.time()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Capture screen
|
# Run blocking operations in thread pool to avoid blocking event loop
|
||||||
capture = capture_display(settings.display_index)
|
# Capture screen (blocking I/O)
|
||||||
|
capture = await asyncio.to_thread(capture_display, settings.display_index)
|
||||||
|
|
||||||
# Extract border pixels
|
# Extract border pixels (CPU-intensive)
|
||||||
border_pixels = extract_border_pixels(capture, settings.border_width)
|
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
|
||||||
|
|
||||||
# Map to LED colors
|
# Map to LED colors (CPU-intensive)
|
||||||
led_colors = state.pixel_mapper.map_border_to_leds(border_pixels)
|
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
|
||||||
|
|
||||||
# Apply color correction
|
# Apply color correction (CPU-intensive)
|
||||||
led_colors = apply_color_correction(
|
led_colors = await asyncio.to_thread(
|
||||||
|
apply_color_correction,
|
||||||
led_colors,
|
led_colors,
|
||||||
gamma=settings.gamma,
|
gamma=settings.gamma,
|
||||||
saturation=settings.saturation,
|
saturation=settings.saturation,
|
||||||
brightness=settings.brightness,
|
brightness=settings.brightness,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply smoothing
|
# Apply smoothing (CPU-intensive)
|
||||||
if state.previous_colors and settings.smoothing > 0:
|
if state.previous_colors and settings.smoothing > 0:
|
||||||
led_colors = smooth_colors(
|
led_colors = await asyncio.to_thread(
|
||||||
|
smooth_colors,
|
||||||
led_colors,
|
led_colors,
|
||||||
state.previous_colors,
|
state.previous_colors,
|
||||||
settings.smoothing,
|
settings.smoothing,
|
||||||
@@ -331,7 +339,7 @@ class ProcessorManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
state.metrics.errors_count += 1
|
state.metrics.errors_count += 1
|
||||||
state.metrics.last_error = str(e)
|
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
|
# FPS control
|
||||||
elapsed = time.time() - loop_start
|
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
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Tuple, Optional, Dict, Any
|
from typing import List, Tuple, Optional, Dict, Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from wled_controller.utils import get_logger
|
from wled_controller.utils import get_logger
|
||||||
|
from wled_controller.core.ddp_client import DDPClient
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -22,10 +24,15 @@ class WLEDInfo:
|
|||||||
product: str
|
product: str
|
||||||
mac: str
|
mac: str
|
||||||
ip: 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:
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -33,6 +40,7 @@ class WLEDClient:
|
|||||||
timeout: int = 5,
|
timeout: int = 5,
|
||||||
retry_attempts: int = 3,
|
retry_attempts: int = 3,
|
||||||
retry_delay: int = 1,
|
retry_delay: int = 1,
|
||||||
|
use_ddp: bool = False,
|
||||||
):
|
):
|
||||||
"""Initialize WLED client.
|
"""Initialize WLED client.
|
||||||
|
|
||||||
@@ -41,13 +49,20 @@ class WLEDClient:
|
|||||||
timeout: Request timeout in seconds
|
timeout: Request timeout in seconds
|
||||||
retry_attempts: Number of retry attempts on failure
|
retry_attempts: Number of retry attempts on failure
|
||||||
retry_delay: Delay between retries in seconds
|
retry_delay: Delay between retries in seconds
|
||||||
|
use_ddp: Force DDP protocol (auto-enabled for >500 LEDs)
|
||||||
"""
|
"""
|
||||||
self.url = url.rstrip("/")
|
self.url = url.rstrip("/")
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.retry_attempts = retry_attempts
|
self.retry_attempts = retry_attempts
|
||||||
self.retry_delay = retry_delay
|
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._client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._ddp_client: Optional[DDPClient] = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
@@ -69,15 +84,45 @@ class WLEDClient:
|
|||||||
RuntimeError: If connection fails
|
RuntimeError: If connection fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Always create HTTP client for info/control
|
||||||
self._client = httpx.AsyncClient(timeout=self.timeout)
|
self._client = httpx.AsyncClient(timeout=self.timeout)
|
||||||
|
|
||||||
# Test connection by getting device info
|
# Test connection by getting device info
|
||||||
info = await self.get_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
|
self._connected = True
|
||||||
|
|
||||||
|
protocol = "DDP" if self.use_ddp else "HTTP"
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Connected to WLED device: {info.name} ({info.version}) "
|
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
|
return True
|
||||||
|
|
||||||
@@ -87,6 +132,9 @@ class WLEDClient:
|
|||||||
if self._client:
|
if self._client:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
self._client = None
|
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}")
|
raise RuntimeError(f"Failed to connect to WLED device: {e}")
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
@@ -94,6 +142,9 @@ class WLEDClient:
|
|||||||
if self._client:
|
if self._client:
|
||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
self._client = None
|
self._client = None
|
||||||
|
if self._ddp_client:
|
||||||
|
await self._ddp_client.close()
|
||||||
|
self._ddp_client = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
logger.debug(f"Closed connection to {self.url}")
|
logger.debug(f"Closed connection to {self.url}")
|
||||||
|
|
||||||
@@ -175,16 +226,29 @@ class WLEDClient:
|
|||||||
RuntimeError: If request fails
|
RuntimeError: If request fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Get basic info
|
||||||
data = await self._request("GET", "/json/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(
|
return WLEDInfo(
|
||||||
name=data.get("name", "Unknown"),
|
name=data.get("name", "Unknown"),
|
||||||
version=data.get("ver", "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"),
|
brand=data.get("brand", "WLED"),
|
||||||
product=data.get("product", "FOSS"),
|
product=data.get("product", "FOSS"),
|
||||||
mac=data.get("mac", ""),
|
mac=data.get("mac", ""),
|
||||||
ip=data.get("ip", ""),
|
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:
|
except Exception as e:
|
||||||
@@ -215,10 +279,12 @@ class WLEDClient:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Send pixel colors to WLED device.
|
"""Send pixel colors to WLED device.
|
||||||
|
|
||||||
|
Uses DDP for large LED counts (>500), HTTP JSON API for smaller counts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
pixels: List of (R, G, B) tuples for each LED
|
pixels: List of (R, G, B) tuples for each LED
|
||||||
brightness: Global brightness (0-255)
|
brightness: Global brightness (0-255)
|
||||||
segment_id: Segment ID to update
|
segment_id: Segment ID to update (HTTP only)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if successful
|
True if successful
|
||||||
@@ -234,39 +300,98 @@ class WLEDClient:
|
|||||||
if not 0 <= brightness <= 255:
|
if not 0 <= brightness <= 255:
|
||||||
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
raise ValueError(f"Brightness must be 0-255, got {brightness}")
|
||||||
|
|
||||||
# Validate and convert pixel values to Python ints (handles numpy types)
|
# Validate pixel values
|
||||||
# WLED expects a flat array: [r1, g1, b1, r2, g2, b2, ...]
|
validated_pixels = []
|
||||||
flat_pixels = []
|
|
||||||
for i, (r, g, b) in enumerate(pixels):
|
for i, (r, g, b) in enumerate(pixels):
|
||||||
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
|
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})")
|
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, ...]
|
validated_pixels.append((int(r), int(g), int(b)))
|
||||||
flat_pixels.extend([int(r), int(g), int(b)])
|
|
||||||
|
|
||||||
# Build WLED JSON state with flat pixel array
|
# Use DDP protocol if enabled
|
||||||
payload = {
|
if self.use_ddp and self._ddp_client:
|
||||||
"on": True,
|
return await self._send_pixels_ddp(validated_pixels, brightness)
|
||||||
"bri": int(brightness), # Ensure brightness is also a Python int
|
else:
|
||||||
"seg": [
|
return await self._send_pixels_http(validated_pixels, brightness, segment_id)
|
||||||
{
|
|
||||||
"id": segment_id,
|
|
||||||
"i": flat_pixels, # Individual LED colors as flat array
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Log payload details for debugging
|
async def _send_pixels_ddp(
|
||||||
logger.debug(f"Sending {len(pixels)} LEDs ({len(flat_pixels)} values) to WLED")
|
self,
|
||||||
logger.debug(f"Payload size: ~{len(str(payload))} bytes")
|
pixels: List[Tuple[int, int, int]],
|
||||||
logger.debug(f"First 3 LEDs: {flat_pixels[:9] if len(flat_pixels) >= 9 else flat_pixels}")
|
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:
|
try:
|
||||||
await self._request("POST", "/json/state", json_data=payload)
|
# Apply brightness to pixels
|
||||||
logger.debug(f"Successfully sent pixel colors to WLED device")
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
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
|
raise
|
||||||
|
|
||||||
async def set_power(self, on: bool) -> bool:
|
async def set_power(self, on: bool) -> bool:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ const API_BASE = '/api/v1';
|
|||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
let apiKey = null;
|
let apiKey = null;
|
||||||
|
|
||||||
|
// Track logged errors to avoid console spam
|
||||||
|
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||||||
|
|
||||||
// Locale management
|
// Locale management
|
||||||
let currentLocale = 'en';
|
let currentLocale = 'en';
|
||||||
let translations = {};
|
let translations = {};
|
||||||
@@ -181,6 +184,12 @@ function handle401Error() {
|
|||||||
localStorage.removeItem('wled_api_key');
|
localStorage.removeItem('wled_api_key');
|
||||||
apiKey = null;
|
apiKey = null;
|
||||||
|
|
||||||
|
// Stop auto-refresh to prevent repeated 401 errors
|
||||||
|
if (refreshInterval) {
|
||||||
|
clearInterval(refreshInterval);
|
||||||
|
refreshInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof updateAuthUI === 'function') {
|
if (typeof updateAuthUI === 'function') {
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
}
|
}
|
||||||
@@ -392,8 +401,14 @@ async function loadDevices() {
|
|||||||
});
|
});
|
||||||
const metrics = await metricsResponse.json();
|
const metrics = await metricsResponse.json();
|
||||||
|
|
||||||
// Log device info, especially if there are errors
|
// Log device errors only when they change (avoid console spam)
|
||||||
if (metrics.errors_count > 0) {
|
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)`);
|
console.warn(`[Device: ${device.name || device.id}] Has ${metrics.errors_count} error(s)`);
|
||||||
|
|
||||||
// Log recent errors from state
|
// Log recent errors from state
|
||||||
@@ -412,6 +427,16 @@ async function loadDevices() {
|
|||||||
// Log full state and metrics for debugging
|
// Log full state and metrics for debugging
|
||||||
console.log('Full state:', state);
|
console.log('Full state:', state);
|
||||||
console.log('Full metrics:', metrics);
|
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 };
|
return { ...device, state, metrics };
|
||||||
@@ -763,7 +788,10 @@ function startAutoRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshInterval = setInterval(() => {
|
refreshInterval = setInterval(() => {
|
||||||
loadDevices();
|
// Only refresh if user is authenticated
|
||||||
|
if (apiKey) {
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
}, 2000); // Refresh every 2 seconds
|
}, 2000); // Refresh every 2 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,6 +868,7 @@ async function showCalibration(deviceId) {
|
|||||||
// Set layout
|
// Set layout
|
||||||
document.getElementById('cal-start-position').value = calibration.start_position;
|
document.getElementById('cal-start-position').value = calibration.start_position;
|
||||||
document.getElementById('cal-layout').value = calibration.layout;
|
document.getElementById('cal-layout').value = calibration.layout;
|
||||||
|
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||||
|
|
||||||
// Set LED counts per edge
|
// Set LED counts per edge
|
||||||
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
|
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 = {
|
const calibration = {
|
||||||
layout: layout,
|
layout: layout,
|
||||||
start_position: startPosition,
|
start_position: startPosition,
|
||||||
|
offset: offset,
|
||||||
segments: segments
|
segments: segments
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,12 @@
|
|||||||
<option value="counterclockwise" data-i18n="calibration.direction.counterclockwise">Counterclockwise</option>
|
<option value="counterclockwise" data-i18n="calibration.direction.counterclockwise">Counterclockwise</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 -->
|
<!-- LED Counts per Edge -->
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
"calibration.direction": "Direction:",
|
"calibration.direction": "Direction:",
|
||||||
"calibration.direction.clockwise": "Clockwise",
|
"calibration.direction.clockwise": "Clockwise",
|
||||||
"calibration.direction.counterclockwise": "Counterclockwise",
|
"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.top": "Top LEDs:",
|
||||||
"calibration.leds.right": "Right LEDs:",
|
"calibration.leds.right": "Right LEDs:",
|
||||||
"calibration.leds.bottom": "Bottom LEDs:",
|
"calibration.leds.bottom": "Bottom LEDs:",
|
||||||
|
|||||||
@@ -93,6 +93,8 @@
|
|||||||
"calibration.direction": "Направление:",
|
"calibration.direction": "Направление:",
|
||||||
"calibration.direction.clockwise": "По Часовой Стрелке",
|
"calibration.direction.clockwise": "По Часовой Стрелке",
|
||||||
"calibration.direction.counterclockwise": "Против Часовой Стрелки",
|
"calibration.direction.counterclockwise": "Против Часовой Стрелки",
|
||||||
|
"calibration.offset": "Смещение LED:",
|
||||||
|
"calibration.offset_hint": "Количество LED от LED 0 до начального угла (по ленте)",
|
||||||
"calibration.leds.top": "Светодиодов Сверху:",
|
"calibration.leds.top": "Светодиодов Сверху:",
|
||||||
"calibration.leds.right": "Светодиодов Справа:",
|
"calibration.leds.right": "Светодиодов Справа:",
|
||||||
"calibration.leds.bottom": "Светодиодов Снизу:",
|
"calibration.leds.bottom": "Светодиодов Снизу:",
|
||||||
|
|||||||
Reference in New Issue
Block a user