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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user