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

@@ -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: