323 lines
14 KiB
Markdown
323 lines
14 KiB
Markdown
---
|
|
phase: 03-communication-protocol
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- lightsync/protocol/__init__.py
|
|
- lightsync/protocol/drgb.py
|
|
- lightsync/protocol/animation_cmd.py
|
|
- lightsync/protocol/udp_sender.py
|
|
- lightsync/devices/sk6812.py
|
|
- lightsync/devices/ws2801.py
|
|
- tests/test_protocol.py
|
|
autonomous: true
|
|
requirements: [UDP-01, UDP-02, UDP-03, ANI-03, ANI-04]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "DRGB encoder produces correct 2-byte header + 3-byte-per-pixel body"
|
|
- "DRGBW encoder produces correct 2-byte header + 4-byte-per-pixel body"
|
|
- "DNRGB encoder splits 300+ LED frames into multiple packets under 1472 bytes each"
|
|
- "Animation command packet uses 0xAC marker and encodes all 7 animation type IDs"
|
|
- "SK6812 encode_animation_cmd returns real bytes, not empty stub"
|
|
- "WS2801 encode_animation_cmd returns real bytes, not empty stub"
|
|
- "UDPSender can send to arbitrary IP:port via asyncio DatagramTransport"
|
|
artifacts:
|
|
- path: "lightsync/protocol/drgb.py"
|
|
provides: "DRGB, DRGBW, DNRGB encoders"
|
|
exports: ["encode_drgb", "encode_drgbw", "encode_dnrgb_packets"]
|
|
- path: "lightsync/protocol/animation_cmd.py"
|
|
provides: "Animation command packet encoder/decoder"
|
|
exports: ["encode_animation_cmd", "decode_animation_cmd", "ANIMATION_IDS"]
|
|
- path: "lightsync/protocol/udp_sender.py"
|
|
provides: "Async UDP transport wrapper"
|
|
exports: ["UDPSender"]
|
|
- path: "tests/test_protocol.py"
|
|
provides: "Protocol encoding tests"
|
|
min_lines: 80
|
|
key_links:
|
|
- from: "lightsync/devices/sk6812.py"
|
|
to: "lightsync/protocol/animation_cmd.py"
|
|
via: "import encode_animation_cmd"
|
|
pattern: "from lightsync.protocol.animation_cmd import encode_animation_cmd"
|
|
- from: "lightsync/protocol/drgb.py"
|
|
to: "WLED spec"
|
|
via: "byte layout constants"
|
|
pattern: "PROTOCOL_DRGB.*=.*2"
|
|
---
|
|
|
|
<objective>
|
|
Build the UDP packet protocol layer: WLED-compatible frame encoders (DRGB/DRGBW/DNRGB), custom animation command packet format, async UDP sender, and fill device encode_animation_cmd stubs.
|
|
|
|
Purpose: This is the wire contract that microcontrollers (Phase 7) will implement. All packet formats must be correct and tested before building anything on top.
|
|
Output: lightsync/protocol/ package with encoders, UDP sender, and passing tests.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/03-communication-protocol/03-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Existing contracts the executor must work with -->
|
|
|
|
From lightsync/devices/base.py:
|
|
```python
|
|
class BaseDevice(ABC):
|
|
def __init__(self, config: DeviceConfig):
|
|
self.config = config
|
|
|
|
@property
|
|
def id(self): return self.config.id
|
|
|
|
@property
|
|
def led_count(self) -> int: return self.config.led_count
|
|
|
|
@abstractmethod
|
|
def encode_frame(self, pixels: list[tuple]) -> bytes: ...
|
|
|
|
@abstractmethod
|
|
def encode_animation_cmd(self, animation: str, params: dict) -> bytes: ...
|
|
|
|
@property
|
|
@abstractmethod
|
|
def bytes_per_pixel(self) -> int: ...
|
|
```
|
|
|
|
From lightsync/models/show.py:
|
|
```python
|
|
class CueModel(BaseModel):
|
|
id: UUID = Field(default_factory=uuid4)
|
|
timestamp: float
|
|
mode: Literal["animation", "frame_sequence"] = "animation"
|
|
animation: str | None = None
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
```
|
|
|
|
From lightsync/models/device.py:
|
|
```python
|
|
StripType = Literal["sk6812", "ws2801", "generic"]
|
|
|
|
class DeviceConfig(BaseModel):
|
|
id: UUID = Field(default_factory=uuid4)
|
|
name: str
|
|
strip_type: StripType
|
|
led_count: int = Field(gt=0, le=1000)
|
|
ip: str
|
|
port: int = Field(ge=1, le=65535)
|
|
enabled: bool = True
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Protocol encoders — DRGB/DRGBW/DNRGB + animation command</name>
|
|
<files>lightsync/protocol/__init__.py, lightsync/protocol/drgb.py, lightsync/protocol/animation_cmd.py, tests/test_protocol.py</files>
|
|
<read_first>
|
|
- lightsync/devices/base.py (BaseDevice ABC — encode_animation_cmd signature)
|
|
- lightsync/models/show.py (CueModel — animation and params fields)
|
|
- .planning/phases/03-communication-protocol/03-RESEARCH.md (WLED byte layout spec, animation command format)
|
|
</read_first>
|
|
<behavior>
|
|
- encode_drgb([(255,0,0),(0,255,0)]) returns bytes([0x02, 2, 255, 0, 0, 0, 255, 0])
|
|
- encode_drgbw([(255,0,0,128)]) returns bytes([0x03, 2, 255, 0, 0, 128])
|
|
- encode_drgb with 490 pixels returns exactly 1472 bytes (2 header + 490*3)
|
|
- encode_drgbw with 367 pixels returns exactly 1470 bytes (2 header + 367*4)
|
|
- encode_drgb with 491 pixels truncates to 490 pixels
|
|
- encode_dnrgb_packets with 600 pixels returns 2 packets (489 + 111 LEDs)
|
|
- encode_dnrgb_packets second packet has start_index=489 in big-endian at bytes 2-3
|
|
- Each DNRGB packet is <= 1472 bytes
|
|
- encode_animation_cmd("chase", {"color":[255,0,0],"white":0,"speed":0.5,"reverse":False}) returns 16-byte packet starting with 0xAC, 0x01
|
|
- encode_animation_cmd animation type IDs: solid_color=0, chase=1, pulse=2, rainbow=3, strobe=4, color_wipe=5, fire=6
|
|
- decode_animation_cmd round-trips with encode_animation_cmd for all 7 types
|
|
- Unknown animation type raises ValueError
|
|
</behavior>
|
|
<action>
|
|
Create `lightsync/protocol/__init__.py` (empty, makes it a package).
|
|
|
|
Create `lightsync/protocol/drgb.py` with these exact constants and functions:
|
|
```python
|
|
WLED_PORT = 21324
|
|
PROTOCOL_DRGB = 2
|
|
PROTOCOL_DRGBW = 3
|
|
PROTOCOL_DNRGB = 4
|
|
TIMEOUT_BYTE = 2
|
|
|
|
DRGB_MAX_PX = 490 # (1472 - 2) // 3
|
|
DRGBW_MAX_PX = 367 # (1472 - 2) // 4
|
|
DNRGB_MAX_PX = 489 # (1472 - 4) // 3
|
|
```
|
|
|
|
Functions:
|
|
- `encode_drgb(pixels: list[tuple[int,int,int]]) -> bytes` — header [PROTOCOL_DRGB, TIMEOUT_BYTE] + RGB body, truncate at DRGB_MAX_PX
|
|
- `encode_drgbw(pixels: list[tuple[int,int,int,int]]) -> bytes` — header [PROTOCOL_DRGBW, TIMEOUT_BYTE] + RGBW body, truncate at DRGBW_MAX_PX
|
|
- `encode_dnrgb_packets(pixels: list[tuple[int,int,int]], start_index: int = 0) -> list[bytes]` — split into chunks of DNRGB_MAX_PX, each with 4-byte header using `struct.pack(">BBH", PROTOCOL_DNRGB, TIMEOUT_BYTE, idx)`. Use big-endian for start index per WLED spec.
|
|
|
|
Create `lightsync/protocol/animation_cmd.py` with:
|
|
```python
|
|
ANIM_CMD_MARKER = 0xAC
|
|
ANIM_CMD_VERSION = 1
|
|
|
|
ANIMATION_IDS = {
|
|
"solid_color": 0, "chase": 1, "pulse": 2, "rainbow": 3,
|
|
"strobe": 4, "color_wipe": 5, "fire": 6,
|
|
}
|
|
ANIMATION_NAMES = {v: k for k, v in ANIMATION_IDS.items()}
|
|
```
|
|
|
|
Functions:
|
|
- `encode_animation_cmd(animation: str, params: dict) -> bytes` — 16-byte packet:
|
|
Byte 0: 0xAC, Byte 1: version=1, Byte 2: animation type ID (from ANIMATION_IDS, raise ValueError if unknown),
|
|
Byte 3: flags (bit 0 = params.get("reverse", False)),
|
|
Bytes 4-7: struct.pack(">f", params.get("speed", 0.5)),
|
|
Bytes 8-10: primary color RGB from params.get("color", [0,0,0]),
|
|
Bytes 11-13: secondary color RGB from params.get("bg_color", [0,0,0]),
|
|
Byte 14: params.get("white", 0),
|
|
Byte 15: params.get("density", params.get("size", params.get("cooling", 0))) & 0xFF
|
|
|
|
- `decode_animation_cmd(data: bytes) -> dict` — inverse of encode. Returns {"animation": str, "params": dict} with speed, color, bg_color, white, density, reverse, flags.
|
|
|
|
Create `tests/test_protocol.py` with pytest tests covering all behaviors listed above.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -m pytest tests/test_protocol.py -v</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/protocol/__init__.py exists
|
|
- lightsync/protocol/drgb.py contains "PROTOCOL_DRGB = 2" and "PROTOCOL_DRGBW = 3" and "PROTOCOL_DNRGB = 4"
|
|
- lightsync/protocol/drgb.py contains "def encode_drgb(" and "def encode_drgbw(" and "def encode_dnrgb_packets("
|
|
- lightsync/protocol/drgb.py contains 'struct.pack(">BBH"'
|
|
- lightsync/protocol/animation_cmd.py contains "ANIM_CMD_MARKER = 0xAC"
|
|
- lightsync/protocol/animation_cmd.py contains "def encode_animation_cmd(" and "def decode_animation_cmd("
|
|
- lightsync/protocol/animation_cmd.py contains '"solid_color": 0' and '"chase": 1' and '"fire": 6'
|
|
- tests/test_protocol.py exits 0 with all tests passing
|
|
- tests/test_protocol.py contains at least 10 test functions (test_encode_drgb, test_encode_drgbw, test_dnrgb_split, test_dnrgb_start_index, test_animation_cmd_chase, test_animation_cmd_roundtrip, test_animation_cmd_unknown, etc.)
|
|
</acceptance_criteria>
|
|
<done>All WLED frame encoders produce spec-compliant packets. Animation command encoder/decoder round-trips correctly for all 7 types. All tests pass.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: UDP sender + device stub completion</name>
|
|
<files>lightsync/protocol/udp_sender.py, lightsync/devices/sk6812.py, lightsync/devices/ws2801.py</files>
|
|
<read_first>
|
|
- lightsync/devices/sk6812.py (current encode_animation_cmd stub returning b"")
|
|
- lightsync/devices/ws2801.py (current encode_animation_cmd stub returning b"")
|
|
- lightsync/devices/base.py (BaseDevice ABC contract)
|
|
- lightsync/protocol/animation_cmd.py (just created in Task 1 — encode_animation_cmd function)
|
|
- lightsync/main.py (lifespan pattern — app.state usage)
|
|
- .planning/phases/03-communication-protocol/03-RESEARCH.md (UDPSender pattern, single socket design)
|
|
</read_first>
|
|
<action>
|
|
Create `lightsync/protocol/udp_sender.py`:
|
|
```python
|
|
import asyncio
|
|
|
|
class _NullProtocol(asyncio.DatagramProtocol):
|
|
def error_received(self, exc: Exception) -> None:
|
|
pass # UDP send errors non-fatal for LED 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),
|
|
)
|
|
|
|
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()
|
|
self._transport = None
|
|
|
|
@property
|
|
def is_running(self) -> bool:
|
|
return self._transport is not None
|
|
```
|
|
|
|
Update `lightsync/devices/sk6812.py` — replace the encode_animation_cmd stub:
|
|
- Add import: `from lightsync.protocol.animation_cmd import encode_animation_cmd as _encode_cmd`
|
|
- Replace `encode_animation_cmd` method body from `return b""` to `return _encode_cmd(animation, params)`
|
|
- Keep the existing `encode_frame` method unchanged.
|
|
|
|
Update `lightsync/devices/ws2801.py` — identical change:
|
|
- Add import: `from lightsync.protocol.animation_cmd import encode_animation_cmd as _encode_cmd`
|
|
- Replace `encode_animation_cmd` method body from `return b""` to `return _encode_cmd(animation, params)`
|
|
- Keep the existing `encode_frame` method unchanged.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -c "
|
|
from lightsync.protocol.udp_sender import UDPSender
|
|
s = UDPSender()
|
|
assert not s.is_running
|
|
print('UDPSender: OK')
|
|
|
|
from lightsync.devices.sk6812 import SK6812Device
|
|
from lightsync.devices.ws2801 import WS2801Device
|
|
from lightsync.models.device import DeviceConfig
|
|
cfg = DeviceConfig(name='test', strip_type='sk6812', led_count=10, ip='127.0.0.1', port=21324)
|
|
sk = SK6812Device(cfg)
|
|
result = sk.encode_animation_cmd('solid_color', {'color': [255,0,0], 'white': 128})
|
|
assert len(result) == 16 and result[0] == 0xAC, f'SK6812 cmd failed: {result.hex()}'
|
|
print(f'SK6812 encode_animation_cmd: {result.hex()} OK')
|
|
|
|
cfg2 = DeviceConfig(name='test2', strip_type='ws2801', led_count=10, ip='127.0.0.1', port=21324)
|
|
ws = WS2801Device(cfg2)
|
|
result2 = ws.encode_animation_cmd('chase', {'color': [0,255,0], 'speed': 0.7})
|
|
assert len(result2) == 16 and result2[0] == 0xAC, f'WS2801 cmd failed: {result2.hex()}'
|
|
print(f'WS2801 encode_animation_cmd: {result2.hex()} OK')
|
|
print('ALL OK')
|
|
"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/protocol/udp_sender.py contains "class UDPSender" and "class _NullProtocol"
|
|
- lightsync/protocol/udp_sender.py contains "def send(self, payload: bytes, ip: str, port: int)"
|
|
- lightsync/protocol/udp_sender.py contains 'local_addr=("0.0.0.0", 0)'
|
|
- lightsync/devices/sk6812.py contains "from lightsync.protocol.animation_cmd import encode_animation_cmd"
|
|
- lightsync/devices/sk6812.py does NOT contain 'return b""'
|
|
- lightsync/devices/ws2801.py contains "from lightsync.protocol.animation_cmd import encode_animation_cmd"
|
|
- lightsync/devices/ws2801.py does NOT contain 'return b""'
|
|
- SK6812Device.encode_animation_cmd("solid_color", {"color":[255,0,0]}) returns 16 bytes starting with 0xAC
|
|
- WS2801Device.encode_animation_cmd("chase", {"color":[0,255,0]}) returns 16 bytes starting with 0xAC
|
|
</acceptance_criteria>
|
|
<done>UDPSender class ready for integration into FastAPI lifespan. Both SK6812Device and WS2801Device produce real 16-byte animation command packets instead of empty stubs.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `python -m pytest tests/test_protocol.py -v` — all protocol encoding tests pass
|
|
- `python -c "from lightsync.protocol.drgb import encode_drgb, encode_drgbw, encode_dnrgb_packets; print('imports OK')"` — no import errors
|
|
- `python -c "from lightsync.protocol.udp_sender import UDPSender; print('UDPSender OK')"` — no import errors
|
|
- SK6812 and WS2801 encode_animation_cmd return 16-byte packets, not b""
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- WLED DRGB/DRGBW/DNRGB packets match spec byte layout
|
|
- DNRGB splits frames exceeding MTU into correct chunks with big-endian start indices
|
|
- Animation command 0xAC packets encode/decode round-trip for all 7 animation types
|
|
- UDPSender provides async start/stop lifecycle and synchronous send()
|
|
- Device stubs replaced with real encoding
|
|
- All tests pass
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-communication-protocol/03-01-SUMMARY.md`
|
|
</output>
|