Fix DDP multi-bus color issues with PUSH flag and packet alignment

- Add PUSH flag (0x01) on last DDP packet to signal frame completion
- Align packet payload to multiples of 3 bytes to prevent splitting
  pixel RGB channels across packet boundaries
- Add per-bus WLED configuration logging (pin, color order, LED range)
- Add BusConfig dataclass and per-bus color reorder infrastructure
  for future use
- Remove old per-pixel color reordering (WLED handles internally)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:39:15 +03:00
parent d229c9a0d5
commit 4c21ae5178
2 changed files with 126 additions and 39 deletions

View File

@@ -1,46 +1,55 @@
"""DDP (Distributed Display Protocol) client for WLED."""
import asyncio
import struct
from typing import List, Tuple
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# DDP color order codes (matches WLED's order enum)
COLOR_ORDER_MAP: Dict[int, Tuple[int, int, int]] = {
0: (1, 0, 2), # GRB → indices into (R,G,B)
1: (0, 1, 2), # RGB → no reorder
2: (2, 0, 1), # BRG
3: (0, 2, 1), # RBG
4: (2, 1, 0), # BGR
5: (1, 2, 0), # GBR
}
@dataclass
class BusConfig:
"""Physical LED bus/output configuration from WLED."""
start: int # First LED index
length: int # Number of LEDs on this bus
color_order: int # Color order code (0=GRB, 1=RGB, etc.)
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_FLAGS_VER1 = 0x40 # VER=1, all other flags 0
DDP_FLAGS_PUSH = 0x01 # PUSH flag (set on last packet of a frame)
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):
def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
"""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
self._buses: List[BusConfig] = []
async def connect(self):
"""Establish UDP connection."""
@@ -64,32 +73,50 @@ class DDPClient:
self._protocol = None
logger.debug(f"Closed DDP connection to {self.host}:{self.port}")
def set_buses(self, buses: List[BusConfig]) -> None:
"""Set WLED bus configurations for per-bus color order reordering.
Args:
buses: List of BusConfig from WLED device
"""
self._buses = buses
for bus in buses:
order_name = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
logger.info(
f"DDP bus: LEDs {bus.start}-{bus.start + bus.length - 1}, "
f"color order={order_name.get(bus.color_order, '?')} ({bus.color_order})"
)
def _build_ddp_packet(
self,
rgb_data: bytes,
offset: int = 0,
sequence: int = 1
sequence: int = 1,
push: bool = False,
) -> bytes:
"""Build a DDP packet.
DDP packet format (10-byte header + data):
- Byte 0: Flags (0x40 = VER1, PUSH)
- Byte 0: Flags (VER1 | PUSH on last packet)
- 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 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length (2 bytes, 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)
push: True for the last packet of a frame
Returns:
Complete DDP packet as bytes
"""
flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB
source_id = 0x01
data_len = len(rgb_data)
@@ -107,6 +134,40 @@ class DDPClient:
return header + rgb_data
def _reorder_pixels(
self,
pixels: List[Tuple[int, int, int]],
) -> List[Tuple[int, int, int]]:
"""Apply per-bus color order reordering.
WLED may not apply per-bus color order conversion for DDP data on
all buses (observed in multi-bus setups). We reorder pixel channels
here so the hardware receives the correct byte order directly.
Args:
pixels: List of (R, G, B) tuples in standard RGB order
Returns:
List of reordered tuples matching each bus's hardware color order
"""
if not self._buses:
return pixels
result = list(pixels)
for bus in self._buses:
order_map = COLOR_ORDER_MAP.get(bus.color_order)
if not order_map or order_map == (0, 1, 2):
continue # RGB order = no reordering needed
start = bus.start
end = min(bus.start + bus.length, len(result))
for i in range(start, end):
r, g, b = result[i]
rgb = (r, g, b)
result[i] = (rgb[order_map[0]], rgb[order_map[1]], rgb[order_map[2]])
return result
async def send_pixels(
self,
pixels: List[Tuple[int, int, int]],
@@ -128,21 +189,20 @@ class DDPClient:
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
# Send plain RGB — WLED handles per-bus color order conversion
# internally when outputting to hardware.
bpp = 4 if self.rgbw else 3 # bytes per pixel
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)
pixel_bytes.extend((int(r), int(g), int(b)))
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
# Align payload to full pixels (multiple of bpp) to avoid splitting
# a pixel's channels across packets
max_payload = max_packet_size - 10 # 10-byte header
bytes_per_packet = (max_payload // bpp) * bpp
# Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
@@ -156,17 +216,22 @@ class DDPClient:
start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end])
is_last = (i == num_packets - 1)
# Increment sequence number
self._sequence = (self._sequence + 1) % 256
# Build and send packet
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence)
# Build and send packet (set PUSH on last packet)
packet = self._build_ddp_packet(
chunk, offset=start,
sequence=self._sequence, push=is_last,
)
self._transport.sendto(packet)
logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
)
return True

View File

@@ -1,14 +1,14 @@
"""WLED client for controlling LED devices via HTTP or DDP."""
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
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
from wled_controller.core.ddp_client import BusConfig, DDPClient
logger = get_logger(__name__)
@@ -26,6 +26,7 @@ class WLEDInfo:
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
buses: List[BusConfig] = field(default_factory=list) # Per-bus/GPIO config
class WLEDClient:
@@ -100,8 +101,10 @@ class WLEDClient:
# 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)
self._ddp_client = DDPClient(self.host, rgbw=False)
# Pass per-bus config so DDP client can apply per-bus color reordering
if info.buses:
self._ddp_client.set_buses(info.buses)
await self._ddp_client.connect()
# Turn on the device and disable Audio Reactive mode for DDP
@@ -230,14 +233,32 @@ class WLEDClient:
data = await self._request("GET", "/json/info")
leds_info = data.get("leds", {})
# Get LED configuration for color order
# Get LED configuration for color order and per-bus info
cfg_data = await self._request("GET", "/json/cfg")
color_order = 1 # Default to RGB
buses: List[BusConfig] = []
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:
ins_list = led_cfg.get("ins", [])
if ins_list:
# Use color order from first LED strip
color_order = led_cfg["ins"][0].get("order", 1)
color_order = ins_list[0].get("order", 1)
# Parse all buses for per-segment color reordering
order_names = {0: "GRB", 1: "RGB", 2: "BRG", 3: "RBG", 4: "BGR", 5: "GBR"}
for idx, bus_cfg in enumerate(ins_list):
bus = BusConfig(
start=bus_cfg.get("start", 0),
length=bus_cfg.get("len", 0),
color_order=bus_cfg.get("order", 1),
)
buses.append(bus)
logger.info(
f"WLED bus {idx}: LEDs {bus.start}-{bus.start + bus.length - 1}, "
f"pin={bus_cfg.get('pin', '?')}, "
f"order={order_names.get(bus.color_order, '?')} ({bus.color_order}), "
f"type={bus_cfg.get('type', '?')}"
)
return WLEDInfo(
name=data.get("name", "Unknown"),
@@ -247,8 +268,9 @@ class WLEDClient:
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.)
rgbw=leds_info.get("rgbw", False),
color_order=color_order,
buses=buses,
)
except Exception as e: