From 4c21ae51781bdd374233031f8cf2c5909a217ccc Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 12 Feb 2026 00:39:15 +0300 Subject: [PATCH] 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 --- server/src/wled_controller/core/ddp_client.py | 125 +++++++++++++----- .../src/wled_controller/core/wled_client.py | 40 ++++-- 2 files changed, 126 insertions(+), 39 deletions(-) diff --git a/server/src/wled_controller/core/ddp_client.py b/server/src/wled_controller/core/ddp_client.py index 2038908..084bdf7 100644 --- a/server/src/wled_controller/core/ddp_client.py +++ b/server/src/wled_controller/core/ddp_client.py @@ -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 diff --git a/server/src/wled_controller/core/wled_client.py b/server/src/wled_controller/core/wled_client.py index ff18714..e1dedfc 100644 --- a/server/src/wled_controller/core/wled_client.py +++ b/server/src/wled_controller/core/wled_client.py @@ -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: