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.""" """DDP (Distributed Display Protocol) client for WLED."""
import asyncio import asyncio
import struct 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 from wled_controller.utils import get_logger
logger = get_logger(__name__) 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: class DDPClient:
"""UDP DDP client for sending pixel data to WLED devices.""" """UDP DDP client for sending pixel data to WLED devices."""
DDP_PORT = 4048 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 DDP_TYPE_RGB = 0x01
# Color order mappings: input (R,G,B) -> output indices def __init__(self, host: str, port: int = DDP_PORT, rgbw: bool = False):
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. """Initialize DDP client.
Args: Args:
host: WLED device IP address or hostname host: WLED device IP address or hostname
port: DDP port (default 4048) port: DDP port (default 4048)
rgbw: True for RGBW LEDs (4 bytes/LED), False for RGB (3 bytes/LED) 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.host = host
self.port = port self.port = port
self.rgbw = rgbw self.rgbw = rgbw
self.color_order = color_order
self._transport = None self._transport = None
self._protocol = None self._protocol = None
self._sequence = 0 self._sequence = 0
self._buses: List[BusConfig] = []
async def connect(self): async def connect(self):
"""Establish UDP connection.""" """Establish UDP connection."""
@@ -64,32 +73,50 @@ class DDPClient:
self._protocol = None self._protocol = None
logger.debug(f"Closed DDP connection to {self.host}:{self.port}") 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( def _build_ddp_packet(
self, self,
rgb_data: bytes, rgb_data: bytes,
offset: int = 0, offset: int = 0,
sequence: int = 1 sequence: int = 1,
push: bool = False,
) -> bytes: ) -> bytes:
"""Build a DDP packet. """Build a DDP packet.
DDP packet format (10-byte header + data): 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 1: Sequence number
- Byte 2: Data type (0x01 = RGB) - Byte 2: Data type (0x01 = RGB)
- Byte 3: Source/Destination ID - Byte 3: Source/Destination ID
- Bytes 4-7: Data offset in bytes (4-byte little-endian) - Bytes 4-7: Data offset (4 bytes, big-endian)
- Bytes 8-9: Data length in bytes (2-byte big-endian) - Bytes 8-9: Data length (2 bytes, big-endian)
- Bytes 10+: Pixel data - Bytes 10+: Pixel data
Args: Args:
rgb_data: RGB pixel data as bytes rgb_data: RGB pixel data as bytes
offset: Byte offset (pixel_index * 3) offset: Byte offset (pixel_index * 3)
sequence: Sequence number (0-255) sequence: Sequence number (0-255)
push: True for the last packet of a frame
Returns: Returns:
Complete DDP packet as bytes Complete DDP packet as bytes
""" """
flags = self.DDP_FLAGS_VER1 flags = self.DDP_FLAGS_VER1
if push:
flags |= self.DDP_FLAGS_PUSH
data_type = self.DDP_TYPE_RGB data_type = self.DDP_TYPE_RGB
source_id = 0x01 source_id = 0x01
data_len = len(rgb_data) data_len = len(rgb_data)
@@ -107,6 +134,40 @@ class DDPClient:
return header + rgb_data 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( async def send_pixels(
self, self,
pixels: List[Tuple[int, int, int]], pixels: List[Tuple[int, int, int]],
@@ -128,21 +189,20 @@ class DDPClient:
raise RuntimeError("DDP client not connected") raise RuntimeError("DDP client not connected")
try: try:
# Get color order mapping # Send plain RGB — WLED handles per-bus color order conversion
order_map = self.COLOR_ORDER_MAP.get(self.color_order, (0, 1, 2)) # internally when outputting to hardware.
bpp = 4 if self.rgbw else 3 # bytes per pixel
# Convert pixels to flat RGB or RGBW bytes with color reordering
pixel_bytes = bytearray() pixel_bytes = bytearray()
for r, g, b in pixels: for r, g, b in pixels:
# Reorder color channels based on WLED configuration pixel_bytes.extend((int(r), int(g), int(b)))
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: if self.rgbw:
pixel_bytes.append(0) # White channel = 0 pixel_bytes.append(0) # White channel = 0
total_bytes = len(pixel_bytes) 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 # Split into multiple packets if needed
num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet num_packets = (total_bytes + bytes_per_packet - 1) // bytes_per_packet
@@ -156,17 +216,22 @@ class DDPClient:
start = i * bytes_per_packet start = i * bytes_per_packet
end = min(start + bytes_per_packet, total_bytes) end = min(start + bytes_per_packet, total_bytes)
chunk = bytes(pixel_bytes[start:end]) chunk = bytes(pixel_bytes[start:end])
is_last = (i == num_packets - 1)
# Increment sequence number # Increment sequence number
self._sequence = (self._sequence + 1) % 256 self._sequence = (self._sequence + 1) % 256
# Build and send packet # Build and send packet (set PUSH on last packet)
packet = self._build_ddp_packet(chunk, offset=start, sequence=self._sequence) packet = self._build_ddp_packet(
chunk, offset=start,
sequence=self._sequence, push=is_last,
)
self._transport.sendto(packet) self._transport.sendto(packet)
logger.debug( logger.debug(
f"Sent DDP packet {i+1}/{num_packets}: " f"Sent DDP packet {i+1}/{num_packets}: "
f"{len(chunk)} bytes at offset {start}" f"{len(chunk)} bytes at offset {start}"
f"{' [PUSH]' if is_last else ''}"
) )
return True return True

View File

@@ -1,14 +1,14 @@
"""WLED client for controlling LED devices via HTTP or DDP.""" """WLED client for controlling LED devices via HTTP or DDP."""
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
from urllib.parse import urlparse 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 from wled_controller.core.ddp_client import BusConfig, DDPClient
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -26,6 +26,7 @@ class WLEDInfo:
ip: str ip: str
rgbw: bool = False # True if RGBW LEDs (4 bytes/pixel), False if RGB (3 bytes/pixel) 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 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: class WLEDClient:
@@ -100,8 +101,10 @@ class WLEDClient:
# Create DDP client if needed # Create DDP client if needed
if self.use_ddp: 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)
self._ddp_client = DDPClient(self.host, rgbw=False, color_order=1) # 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() await self._ddp_client.connect()
# Turn on the device and disable Audio Reactive mode for DDP # Turn on the device and disable Audio Reactive mode for DDP
@@ -230,14 +233,32 @@ class WLEDClient:
data = await self._request("GET", "/json/info") data = await self._request("GET", "/json/info")
leds_info = data.get("leds", {}) 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") cfg_data = await self._request("GET", "/json/cfg")
color_order = 1 # Default to RGB color_order = 1 # Default to RGB
buses: List[BusConfig] = []
if "hw" in cfg_data and "led" in cfg_data["hw"]: if "hw" in cfg_data and "led" in cfg_data["hw"]:
led_cfg = cfg_data["hw"]["led"] 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 # 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( return WLEDInfo(
name=data.get("name", "Unknown"), name=data.get("name", "Unknown"),
@@ -247,8 +268,9 @@ class WLEDClient:
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 rgbw=leds_info.get("rgbw", False),
color_order=color_order, # Auto-detect color order (0=GRB, 1=RGB, etc.) color_order=color_order,
buses=buses,
) )
except Exception as e: except Exception as e: