docs(03): research communication protocol — WLED UDP spec, async sender, animation library
This commit is contained in:
601
.planning/phases/03-communication-protocol/03-RESEARCH.md
Normal file
601
.planning/phases/03-communication-protocol/03-RESEARCH.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
# 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.
|
||||
|
||||
```python
|
||||
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/](https://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
|
||||
```python
|
||||
params = {
|
||||
"color": [r, g, b], # Primary color RGB 0-255
|
||||
"white": 0, # W channel for SK6812 (ANI-03)
|
||||
}
|
||||
```
|
||||
|
||||
### chase
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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 unit
|
||||
- `color` ([R,G,B]): Primary color for animations that use it
|
||||
- `white` (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)
|
||||
```python
|
||||
# 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)
|
||||
```python
|
||||
# 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
|
||||
|
||||
1. **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 `reverse` as a packet field or we just send a second animation ID for reversed variant
|
||||
- Recommendation: Include `flags` byte (bit 0 = reverse) in animation command packet — avoids doubling animation IDs
|
||||
|
||||
2. **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.
|
||||
|
||||
3. **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.py` as 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/](https://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](https://docs.python.org/3/library/asyncio-protocol.html) — DatagramProtocol, DatagramTransport.sendto() API and thread safety notes
|
||||
- [fastled.io/docs — Fire2012 algorithm](https://fastled.io/docs/df/d28/_fire2012_8ino-example.html) — Canonical fire animation: cooling, sparking, heat diffusion
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [GitHub WLED wiki UDP-Realtime-Control](https://github.com/Aircoookie/WLED/wiki/UDP-Realtime-Control) — Secondary source confirming protocol byte values
|
||||
- [WLED discourse: DRGB/DNRGB/WARLS differences](https://wled.discourse.group/t/drgb-dnrgb-warls-differences-best-set-up/688) — Community clarification on protocol selection
|
||||
- [CircuitPython LED Animation API](https://docs.circuitpython.org/projects/led-animation/en/latest/api.html) — 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)
|
||||
Reference in New Issue
Block a user