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