28 KiB
Phase 3: Communication Protocol - Research
Researched: 2026-04-06 Domain: UDP networking, LED protocol encoding, animation library design, asyncio datagram sockets Confidence: HIGH
Summary
Phase 3 builds the wire protocol and animation library that microcontrollers will implement in Phase 7. The target is WLED-compatible UDP — specifically DRGB (full RGB frame), DRGBW (full RGBW frame), and DNRGB (multi-packet variant for large strips). The protocol is well-documented by the WLED project and has a fixed, binary byte layout. Nothing needs to be invented here: we adopt the WLED spec wholesale.
The existing codebase already has the scaffolding Phase 3 needs. BaseDevice ABC is defined in lightsync/devices/base.py with encode_frame and encode_animation_cmd stubs. SK6812Device and WS2801Device exist with working pixel serialization but stub animation encoding. The device registry can instantiate live device objects. Phase 3 completes the stubs and adds the UDP transport layer on top.
The two implementation risks are (1) multi-packet framing for large strips — DNRGB must split 300-LED RGBW frames (1200 bytes payload) across multiple 489-LED-per-packet chunks — and (2) defining a custom animation command packet format (the WLED protocol doesn't specify this; it's only for raw frames). Animation commands are custom binary messages Phase 7 firmware will parse.
Primary recommendation: Use a single shared asyncio UDP socket (one DatagramTransport) with sendto(data, addr) calls to each device. No socket pool needed — UDP is connectionless and a single socket can send to N addresses without overhead. Implement the simulator as an asyncio UDP server on localhost using the same port convention (21324).
Standard Stack
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| asyncio (stdlib) | 3.11 | Async UDP transport | No dependency, DatagramTransport handles non-blocking sends |
| struct (stdlib) | 3.11 | Binary packet encoding | Correct byte-order packing, fast, zero dependency |
| dataclasses / Pydantic | already in project | Animation parameter models | Pydantic already a dependency (pyproject.toml) |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| asyncio.DatagramProtocol | stdlib | UDP send/receive base class | Simulator receiver, optional transport wrapper |
| typing.TypedDict | stdlib | Structured animation params | Lightweight param typing without full Pydantic model |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| asyncio DatagramTransport | asyncudp 0.10 | asyncudp adds nicer await-based API but adds a dependency for minimal gain |
| struct.pack | manual bytearray | struct is faster, clearer, handles endianness correctly |
| Pydantic for anim params | plain dicts | Plain dicts are fine for params since show.py CueModel already uses dict[str, Any] |
No new dependencies required. All encoding and transport fits cleanly in stdlib asyncio + struct.
Architecture Patterns
Recommended Project Structure
lightsync/
├── devices/
│ ├── base.py # BaseDevice ABC (already exists — extend, don't replace)
│ ├── registry.py # DeviceRegistry (already exists — add instantiate pool)
│ ├── sk6812.py # SK6812Device (already exists — fill encode_animation_cmd)
│ ├── ws2801.py # WS2801Device (already exists — fill encode_animation_cmd)
│ └── udp_sender.py # NEW: UDPSender — shared socket, send_frame / send_cmd
├── protocol/
│ ├── __init__.py
│ ├── drgb.py # NEW: encode_drgb, encode_drgbw, encode_dnrgb
│ ├── animation_cmd.py # NEW: encode_animation_cmd, decode_animation_cmd
│ └── simulator.py # NEW: asyncio UDP receiver + packet logger
└── animations/
├── __init__.py
├── base.py # NEW: AnimationBase ABC — render(t, led_count) -> list[tuple]
├── chase.py
├── pulse.py
├── rainbow.py
├── strobe.py
├── color_wipe.py
├── fire.py
└── solid_color.py
Pattern 1: Single Socket UDP Sender
What: One asyncio DatagramTransport opened at app startup, held on app.state. All device sends use transport.sendto(payload, (ip, port)).
When to use: Sending animation commands and pixel frames to N devices from a single FastAPI process.
# Source: Python docs asyncio-protocol.html
import asyncio
class _NullProtocol(asyncio.DatagramProtocol):
"""Minimal protocol — we only send, never receive on this socket."""
def error_received(self, exc: Exception) -> None:
pass # UDP send errors are non-fatal for LED strip delivery
class UDPSender:
def __init__(self):
self._transport: asyncio.DatagramTransport | None = None
async def start(self) -> None:
loop = asyncio.get_running_loop()
self._transport, _ = await loop.create_datagram_endpoint(
_NullProtocol,
local_addr=("0.0.0.0", 0), # OS assigns ephemeral port
)
def send(self, payload: bytes, ip: str, port: int) -> None:
if self._transport:
self._transport.sendto(payload, (ip, port))
async def stop(self) -> None:
if self._transport:
self._transport.close()
Pattern 2: WLED DRGB/DNRGB Packet Encoding
What: Binary struct packing per the WLED spec. Two-byte header (type, timeout), then pixel data. When to use: Sending full pixel frames to WLED-compatible or LightSync firmware devices.
# Source: kno.wled.ge/interfaces/udp-realtime/
import struct
WLED_PORT = 21324
PROTOCOL_DRGB = 2
PROTOCOL_DRGBW = 3
PROTOCOL_DNRGB = 4
TIMEOUT_BYTE = 2 # seconds before device returns to normal mode
# Max pixels per packet (stays under 1472 byte UDP payload limit)
DRGB_MAX_PX = 490 # 2 header + 490*3 = 1472
DRGBW_MAX_PX = 367 # 2 header + 367*4 = 1470
DNRGB_MAX_PX = 489 # 4 header + 489*3 = 1471
def encode_drgb(pixels: list[tuple[int, int, int]]) -> bytes:
"""Full DRGB frame — up to 490 pixels."""
header = bytes([PROTOCOL_DRGB, TIMEOUT_BYTE])
body = bytearray()
for r, g, b in pixels[:DRGB_MAX_PX]:
body.extend([r & 0xFF, g & 0xFF, b & 0xFF])
return header + bytes(body)
def encode_drgbw(pixels: list[tuple[int, int, int, int]]) -> bytes:
"""Full DRGBW frame — up to 367 pixels."""
header = bytes([PROTOCOL_DRGBW, TIMEOUT_BYTE])
body = bytearray()
for r, g, b, w in pixels[:DRGBW_MAX_PX]:
body.extend([r & 0xFF, g & 0xFF, b & 0xFF, w & 0xFF])
return header + bytes(body)
def encode_dnrgb_packets(
pixels: list[tuple[int, int, int]],
start_index: int = 0,
) -> list[bytes]:
"""Split large RGB frame into DNRGB chunks, each under MTU."""
packets = []
for offset in range(0, len(pixels), DNRGB_MAX_PX):
chunk = pixels[offset:offset + DNRGB_MAX_PX]
idx = start_index + offset
header = struct.pack(">BBH", PROTOCOL_DNRGB, TIMEOUT_BYTE, idx)
body = bytearray()
for r, g, b in chunk:
body.extend([r & 0xFF, g & 0xFF, b & 0xFF])
packets.append(header + bytes(body))
return packets
Pattern 3: Custom Animation Command Packet
What: A custom binary packet format for sending "start animation X with params P" to a device. Not part of WLED spec — specific to LightSync firmware. When to use: Phase 5 live execution fires animation commands when cue timestamps are reached.
Proposed format (all big-endian):
Byte 0: Protocol type = 0xAC (animation command marker, distinguishes from WLED)
Byte 1: Protocol version = 1
Byte 2: Animation type ID (0-6, see table below)
Byte 3: Flags (bit 0 = loop, bit 1 = reverse, bits 2-7 reserved)
Bytes 4-7: Speed (float32 big-endian, range 0.0-1.0)
Bytes 8-10: Primary color R, G, B
Bytes 11-13: Secondary color R, G, B
Byte 14: White channel (for SK6812, 0 for RGB-only strips)
Byte 15: Density/length (0-255, animation-specific meaning)
Animation type IDs:
0 = solid_color
1 = chase
2 = pulse
3 = rainbow
4 = strobe
5 = color_wipe
6 = fire
Pattern 4: Animation Base + Render Protocol
What: Each animation is a class with a render(t: float, led_count: int) -> list[tuple] method. Pure computation, no I/O.
When to use: Phase 3 generates frames for simulator validation; Phase 5 live execution calls render() in real-time.
from abc import ABC, abstractmethod
class AnimationBase(ABC):
"""Pure animation renderer — no I/O, stateless or state-per-instance."""
@abstractmethod
def render(self, t: float, led_count: int) -> list[tuple]:
"""
Render frame at time t (seconds since animation started).
Returns list of (R, G, B) or (R, G, B, W) tuples, length == led_count.
"""
...
@classmethod
@abstractmethod
def from_params(cls, params: dict) -> "AnimationBase":
"""Construct from CueModel.params dict."""
...
Anti-Patterns to Avoid
- Creating one UDP socket per device: Unnecessary OS resource usage. One socket can sendto() N addresses.
- Using TCP for LED data: Adds head-of-line blocking; a dropped LED frame is better than blocking.
- Blocking socket calls in async context: Never call
socket.send()directly in a coroutine. Always use DatagramTransport.sendto(). - IP fragmentation of large frames: Always split at the application layer using DNRGB multi-packet. Do not rely on OS fragmentation reassembly on WiFi hardware.
- Storing animation state in the packet spec: Animation command packets are fire-and-forget commands, not streaming state machines. The microcontroller owns animation loop state after receiving the command.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Binary packing | Manual bytearray bit manipulation | struct.pack(">BBH", ...) |
struct handles endianness, sign, alignment |
| Fire animation math | Custom heat diffusion | Port Fire2012 algorithm (cooling + sparking) | FastLED Fire2012 is battle-tested on thousands of strips |
| Rainbow hue cycling | Custom HSV math | colorsys.hsv_to_rgb() (stdlib) |
stdlib handles edge cases, no dependency |
| MTU calculation | Custom fragmentation logic | WLED DNRGB spec (489 px/packet) | Max values already calculated for standard Ethernet MTU 1500 |
Key insight: The WLED protocol spec eliminates all packet design work. The only custom protocol element is the animation command packet (0xAC header), which is simple enough to implement with struct in ~30 lines.
WLED Protocol Specification (HIGH confidence)
Source: kno.wled.ge/interfaces/udp-realtime/
Full Byte Layout
Common header (all protocols):
- Byte 0: Protocol type (1=WARLS, 2=DRGB, 3=DRGBW, 4=DNRGB)
- Byte 1: Timeout in seconds (recommended: 2; 255=never timeout)
DRGB (type=2, max 490 LEDs):
[0x02][timeout][R0][G0][B0][R1][G1][B1]...[Rn][Gn][Bn]
payload = 2 + led_count * 3 bytes
DRGBW (type=3, max 367 LEDs):
[0x03][timeout][R0][G0][B0][W0][R1][G1][B1][W1]...[Rn][Gn][Bn][Wn]
payload = 2 + led_count * 4 bytes
DNRGB (type=4, max 489 LEDs per packet — multi-packet):
[0x04][timeout][start_hi][start_lo][R0][G0][B0]...[Rn][Gn][Bn]
payload = 4 + chunk_led_count * 3 bytes
start index is 16-bit big-endian (max 65535 LEDs addressable)
MTU Math (verified)
Standard Ethernet MTU = 1500 bytes IP header = 20 bytes, UDP header = 8 bytes Max UDP payload = 1472 bytes
- DRGB max: (1472 - 2) / 3 = 490 LEDs
- DRGBW max: (1472 - 2) / 4 = 367 LEDs
- DNRGB max: (1472 - 4) / 3 = 489 LEDs per packet
SK6812 300-LED strip in DRGBW mode requires ceil(300/367) = 1 packet — fits in MTU. SK6812 300-LED RGBW payload = 2 + 300*4 = 1202 bytes — fits in single packet. However, to stay compliant with DNRGB multi-packet splitting for arbitrarily large strips, Phase 3 MUST implement the chunked sender regardless — the UDP-03 requirement mandates it.
Default Port
WLED listens on UDP port 21324 (configurable in WLED settings). LightSync should use the same default for firmware compatibility.
Animation Parameter Shapes
All 7 required animation types with their parameters (ANI-01, ANI-02):
solid_color
params = {
"color": [r, g, b], # Primary color RGB 0-255
"white": 0, # W channel for SK6812 (ANI-03)
}
chase
params = {
"color": [r, g, b],
"white": 0,
"bg_color": [0, 0, 0], # Background color
"speed": 0.5, # 0.0-1.0 (maps to px/sec)
"size": 3, # Lit pixel run length
"spacing": 7, # Dark gap after each run
"reverse": False,
}
pulse
params = {
"color": [r, g, b],
"white": 0,
"speed": 0.5, # Period of one breath cycle (0.0-1.0 → 0.1-5.0 sec)
"min_brightness": 0, # Floor brightness 0-255
"max_brightness": 255, # Ceiling brightness 0-255
}
rainbow
params = {
"speed": 0.5, # Hue shift speed (0.0-1.0)
"period": 1.0, # How many full rainbow cycles fit on strip (1.0 = one cycle)
}
# No color param — rainbow defines its own color progression
strobe
params = {
"color": [r, g, b],
"white": 0,
"speed": 0.5, # Flashes per second (maps to 1-20 Hz)
"duty_cycle": 0.1, # Fraction of period that LEDs are on (0.05-0.5)
}
color_wipe
params = {
"color": [r, g, b],
"white": 0,
"speed": 0.5, # Fill rate (0.0-1.0 → px/sec)
"reverse": False, # Direction: False = start→end, True = end→start
}
fire
params = {
"cooling": 55, # Heat loss per cycle — 20 (tall flames) to 100 (short flames)
"sparking": 120, # Probability of new spark (0-255) — 50 (flicker) to 200 (roaring)
"speed": 0.5, # Frame rate multiplier
}
# Uses Fire2012 algorithm: cool → drift up → spark → render heat as color
# Color palette: black → red → yellow → white (heat intensity)
Common fields across all animations:
speed(float 0.0-1.0): Normalized speed that each animation maps to its native unitcolor([R,G,B]): Primary color for animations that use itwhite(int 0-255): W channel, only meaningful for SK6812 (ANI-03 / ANI-04)reverse(bool): Direction flag where applicable
Common Pitfalls
Pitfall 1: DNRGB Start Index Byte Order
What goes wrong: Encoding start index as little-endian instead of big-endian — simulator and firmware parse wrong start position.
Why it happens: Python struct.pack("<H", idx) is the default C-style little-endian; WLED spec requires big-endian.
How to avoid: Always use struct.pack(">H", idx) or struct.pack(">BBH", type, timeout, idx) for DNRGB header.
Warning signs: Simulator logs show LED segments rendering at wrong strip positions.
Pitfall 2: Single Socket Used Across Threads
What goes wrong: DatagramTransport.sendto() called from a non-asyncio thread (e.g., from a sync endpoint) causes race conditions.
Why it happens: asyncio transports are not thread-safe (documented in Python stdlib).
How to avoid: Always call sender.send() from within the asyncio event loop using loop.call_soon_threadsafe() if bridging from sync code.
Warning signs: Sporadic RuntimeError: no running event loop or corrupted packets.
Pitfall 3: SK6812 Frame Mapped to DRGB Instead of DRGBW
What goes wrong: SK6812 device sends DRGB packets — W channel silently dropped, strip shows wrong colors.
Why it happens: encode_frame() returns correct bytes but the dispatch logic sends DRGB type byte (0x02) instead of DRGBW (0x03).
How to avoid: Device dispatch must check device.bytes_per_pixel — 4 bytes → DRGBW, 3 bytes → DRGB. The type byte must match the data layout.
Warning signs: Simulator shows 4 bytes per pixel in payload but type byte = 0x02.
Pitfall 4: Animation Command Packet Conflicts with WLED Type Byte
What goes wrong: If animation command type byte (0xAC) ever matches WARLS (0x01), DRGB (0x02), DRGBW (0x03), or DNRGB (0x04), WLED firmware would misparse it. Why it happens: Not reserving a clean namespace for the custom protocol. How to avoid: Use 0xAC (172) as the animation command marker — well clear of WLED's 0x01-0x04 range.
Pitfall 5: Simulator Bound to Same Port as Sender
What goes wrong: Simulator and the app both try to bind UDP port 21324 on the same machine — bind fails with OSError: [Errno 98] Address already in use.
Why it happens: Simulator acts as receiver (needs to bind), app is sender (doesn't bind to destination port).
How to avoid: Simulator binds to 0.0.0.0:21324. App sender socket uses local_addr=("0.0.0.0", 0) (OS assigns ephemeral source port). These don't conflict.
Pitfall 6: Fire Animation Indexing Direction
What goes wrong: Fire animation renders with flames at top instead of bottom, or runs at wrong speed. Why it happens: Python's natural list iteration goes index 0 → N; fire should rise from index 0 (bottom). How to avoid: Heat array index 0 = bottom of strip (floor/heat source), index N-1 = top. Sparks added at indices 0-2, heat drifts toward index N-1.
Code Examples
Asyncio UDP Simulator (Receiver)
# Source: Python docs asyncio-protocol.html (DatagramProtocol pattern)
import asyncio
import struct
class SimulatorProtocol(asyncio.DatagramProtocol):
PROTOCOL_NAMES = {1: "WARLS", 2: "DRGB", 3: "DRGBW", 4: "DNRGB", 0xAC: "ANIM_CMD"}
def datagram_received(self, data: bytes, addr: tuple) -> None:
if len(data) < 2:
return
proto_type = data[0]
timeout = data[1]
name = self.PROTOCOL_NAMES.get(proto_type, f"UNKNOWN(0x{proto_type:02X})")
if proto_type == 2: # DRGB
led_count = (len(data) - 2) // 3
print(f"[SIM] DRGB from {addr}: {led_count} LEDs, timeout={timeout}s")
elif proto_type == 3: # DRGBW
led_count = (len(data) - 2) // 4
print(f"[SIM] DRGBW from {addr}: {led_count} LEDs (RGBW), timeout={timeout}s")
elif proto_type == 4: # DNRGB
start = struct.unpack_from(">H", data, 2)[0]
led_count = (len(data) - 4) // 3
print(f"[SIM] DNRGB from {addr}: {led_count} LEDs starting at {start}")
elif proto_type == 0xAC: # Animation command
print(f"[SIM] ANIM_CMD from {addr}: {data[2:].hex()}")
async def run_simulator(host: str = "0.0.0.0", port: int = 21324) -> None:
loop = asyncio.get_running_loop()
transport, _ = await loop.create_datagram_endpoint(
SimulatorProtocol,
local_addr=(host, port),
)
print(f"[SIM] Listening on {host}:{port}")
try:
await asyncio.sleep(float("inf"))
finally:
transport.close()
Fire2012 Animation (Python Port)
# Source: FastLED Fire2012 algorithm (fastled.io/docs)
import random
import colorsys
def fire_frame(
heat: list[int], # mutable heat array, length = led_count
led_count: int,
cooling: int = 55,
sparking: int = 120,
) -> list[tuple[int, int, int]]:
"""One Fire2012 step. Mutates heat array. Returns RGB pixel list."""
# Step 1: Cool every cell
for i in range(led_count):
cooldown = random.randint(0, (cooling * 10 // led_count) + 2)
heat[i] = max(0, heat[i] - cooldown)
# Step 2: Heat drifts up (index 0 = bottom)
for i in range(led_count - 1, 1, -1):
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) // 3
# Step 3: Randomly ignite new sparks near bottom
if random.randint(0, 255) < sparking:
y = random.randint(0, 6)
if y < led_count:
heat[y] = min(255, heat[y] + random.randint(160, 255))
# Step 4: Map heat to color (black → red → yellow → white)
pixels = []
for h in heat:
if h < 85:
pixels.append((h * 3, 0, 0))
elif h < 170:
h2 = h - 85
pixels.append((255, h2 * 3, 0))
else:
h2 = h - 170
pixels.append((255, 255, h2 * 3))
return pixels
Existing Code Integration (from Phase 1-2)
Phase 3 builds directly on these existing files — do not replace them:
| File | What Already Exists | Phase 3 Action |
|---|---|---|
lightsync/devices/base.py |
BaseDevice ABC with encode_frame, encode_animation_cmd, bytes_per_pixel |
Add send_frame(sender) method OR leave I/O in UDPSender |
lightsync/devices/sk6812.py |
encode_frame() working, encode_animation_cmd() returns b"" |
Fill encode_animation_cmd() using struct |
lightsync/devices/ws2801.py |
Same as SK6812 — frame works, cmd stub | Same — fill cmd stub |
lightsync/devices/registry.py |
instantiate(device_id) creates live device |
Use this in UDPSender to get device object for encoding |
lightsync/models/show.py |
CueModel has animation: str, params: dict[str, Any] |
Animation params dict shape must match ANI-02 param schemas |
lightsync/models/device.py |
StripType = Literal["sk6812", "ws2801", "generic"] |
No change needed — strip type determines DRGB vs DRGBW |
Important: encode_animation_cmd stub in SK6812Device/WS2801Device currently returns b"". Phase 3 replaces these stubs with real struct packing using the 0xAC animation command format.
The app.state pattern (established in Phase 2 for engine/beats) should be extended to hold app.state.udp_sender: UDPSender started in the FastAPI lifespan handler.
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| One socket per device | Single socket, sendto() per device | Always correct for UDP | Simpler, fewer OS resources |
| Custom UDP protocol | WLED-compatible DRGB/DRGBW | WLED established ~2020 | Reuse WLED ecosystem clients; firmware Phase 7 has reference implementations |
| Polling-based animation loop | Event-driven render-on-demand | N/A — this is Phase 3's new code | Render() called at cue time, not in background |
Deprecated/outdated:
- python-mpv: Removed in Phase 2 redesign — browser audio handles playback. No impact on Phase 3.
Open Questions
-
Animation command: should direction be in the packet or only in params dict?
- What we know: CueModel.params is
dict[str, Any]— flexible - What's unclear: Whether firmware should get
reverseas a packet field or we just send a second animation ID for reversed variant - Recommendation: Include
flagsbyte (bit 0 = reverse) in animation command packet — avoids doubling animation IDs
- What we know: CueModel.params is
-
Should UDPSender retry on OS error?
- What we know: UDP is fire-and-forget; DatagramProtocol.error_received() is called on OS-level send errors
- What's unclear: Whether LED strip devices on WiFi need any retry logic for dropped packets
- Recommendation: No retry in Phase 3 — log errors only. Phase 5 can add retry if needed. Cue timing correctness matters more than delivery guarantee.
-
Simulator: standalone script or integrated FastAPI endpoint?
- What we know: Phase 3 requirement UDP-04 says "software UDP receiver/simulator for testing without physical hardware"
- What's unclear: Whether it should be a background task in the app or a separate runnable
- Recommendation: Implement as both — a background asyncio task startable via
lightsync/protocol/simulator.pyas a__main__runnable AND optionally started via FastAPI on a debug endpoint.
Environment Availability
No external dependencies beyond stdlib asyncio. All required packages are already in pyproject.toml (fastapi, pydantic, structlog). No new packages needed for Phase 3.
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| asyncio stdlib | UDP transport | Yes | Python 3.11 stdlib | — |
| struct stdlib | Packet encoding | Yes | Python 3.11 stdlib | — |
| colorsys stdlib | Rainbow hue math | Yes | Python 3.11 stdlib | — |
| UDP port 21324 | Simulator receiver | Likely free | — | Use any open port in tests |
Missing dependencies with no fallback: None.
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| UDP-01 | Animation command packet: device ID, animation type, params — sent when block starts | Custom 0xAC binary packet format specified; struct encoding pattern documented |
| UDP-02 | Raw pixel frame packet: WLED DRGB/DNRGB/DRGBW protocol — used for AI-generated sequences | Full WLED spec documented with byte layout, confirmed from official kno.wled.ge docs |
| UDP-03 | Multi-packet framing for frames >MTU (SK6812 300 LEDs RGBW = 1200 bytes, exceeds WiFi MTU) | DNRGB chunked encoding documented; MTU math confirmed (489 px per packet) |
| UDP-04 | Software UDP receiver/simulator for testing without physical hardware | asyncio DatagramProtocol pattern documented; simulator code example provided |
| ANI-01 | Built-in animation types: chase, pulse, rainbow, strobe, color wipe, fire, solid color | All 7 types specified with render approach and parameter shapes |
| ANI-02 | Per-animation parameters: speed, primary color(s), secondary color(s), direction, length/density | Full param shapes documented for all 7 animation types |
| ANI-03 | RGBW-aware color handling for SK6812 (separate W channel) | DRGBW packet uses 4 bytes/pixel (R,G,B,W); white param included in all animation param shapes |
| ANI-04 | RGB color handling for WS2801 | DRGB packet uses 3 bytes/pixel; existing WS2801Device.encode_frame() already correct — needs only dispatch to use type=0x02 |
| </phase_requirements> |
Sources
Primary (HIGH confidence)
- kno.wled.ge/interfaces/udp-realtime/ — Full WLED UDP protocol spec, byte layouts, protocol types, max LED counts
- docs.python.org/3/library/asyncio-protocol.html — DatagramProtocol, DatagramTransport.sendto() API and thread safety notes
- fastled.io/docs — Fire2012 algorithm — Canonical fire animation: cooling, sparking, heat diffusion
Secondary (MEDIUM confidence)
- GitHub WLED wiki UDP-Realtime-Control — Secondary source confirming protocol byte values
- WLED discourse: DRGB/DNRGB/WARLS differences — Community clarification on protocol selection
- CircuitPython LED Animation API — Reference for animation parameter conventions (chase, color wipe, pulse, rainbow)
Tertiary (LOW confidence)
- WebSearch results on asyncio socket pool patterns — confirmed LOW: single socket with sendto() is the correct approach, no pool needed
Metadata
Confidence breakdown:
- UDP wire protocol (WLED DRGB/DNRGB/DRGBW): HIGH — verified against official kno.wled.ge documentation
- Animation command packet format (0xAC): MEDIUM — custom design, no external reference, but straightforward struct layout
- Animation parameter shapes: MEDIUM — informed by Adafruit library conventions and Fire2012 spec, adapted to project requirements
- Asyncio UDP patterns: HIGH — verified against Python stdlib docs
- Fire animation algorithm: HIGH — FastLED Fire2012 is canonical reference implementation
Research date: 2026-04-06 Valid until: 2026-10-06 (WLED protocol is stable; Python asyncio API is stable)