375 lines
16 KiB
Markdown
375 lines
16 KiB
Markdown
---
|
|
phase: 03-communication-protocol
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["03-01", "03-02"]
|
|
files_modified:
|
|
- lightsync/protocol/simulator.py
|
|
- lightsync/main.py
|
|
- tests/test_e2e_protocol.py
|
|
autonomous: true
|
|
requirements: [UDP-04]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Simulator receives and logs DRGB packets with correct LED count"
|
|
- "Simulator receives and logs DRGBW packets distinguishing 4-byte pixels"
|
|
- "Simulator receives and logs DNRGB multi-packet frames with correct start indices"
|
|
- "Simulator receives and logs animation command packets (0xAC) with decoded params"
|
|
- "UDPSender is started in FastAPI lifespan and available as app.state.udp_sender"
|
|
- "End-to-end test sends packets via UDPSender and simulator receives them correctly"
|
|
artifacts:
|
|
- path: "lightsync/protocol/simulator.py"
|
|
provides: "Async UDP simulator/receiver with packet logging"
|
|
exports: ["SimulatorProtocol", "run_simulator"]
|
|
contains: "class SimulatorProtocol"
|
|
- path: "tests/test_e2e_protocol.py"
|
|
provides: "End-to-end protocol validation tests"
|
|
min_lines: 60
|
|
key_links:
|
|
- from: "lightsync/protocol/simulator.py"
|
|
to: "lightsync/protocol/drgb.py"
|
|
via: "protocol type byte constants"
|
|
pattern: "PROTOCOL_DRGB|0x02|0x03|0x04|0xAC"
|
|
- from: "lightsync/main.py"
|
|
to: "lightsync/protocol/udp_sender.py"
|
|
via: "app.state.udp_sender"
|
|
pattern: "app.state.udp_sender"
|
|
- from: "tests/test_e2e_protocol.py"
|
|
to: "lightsync/protocol/udp_sender.py"
|
|
via: "UDPSender send()"
|
|
pattern: "UDPSender"
|
|
---
|
|
|
|
<objective>
|
|
Build the software UDP simulator, integrate UDPSender into FastAPI lifespan, and run end-to-end protocol validation proving all packet types are sent and received correctly.
|
|
|
|
Purpose: UDP-04 requires a simulator for hardware-free protocol testing. This plan proves the full stack works: animation renders pixels -> encoder creates packets -> sender transmits -> simulator receives and validates.
|
|
Output: Working simulator, FastAPI integration, end-to-end test suite.
|
|
</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
|
|
@.planning/phases/03-communication-protocol/03-01-SUMMARY.md
|
|
@.planning/phases/03-communication-protocol/03-02-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- From Plan 01 outputs -->
|
|
|
|
From lightsync/protocol/drgb.py:
|
|
```python
|
|
WLED_PORT = 21324
|
|
PROTOCOL_DRGB = 2
|
|
PROTOCOL_DRGBW = 3
|
|
PROTOCOL_DNRGB = 4
|
|
TIMEOUT_BYTE = 2
|
|
DRGB_MAX_PX = 490
|
|
DRGBW_MAX_PX = 367
|
|
DNRGB_MAX_PX = 489
|
|
|
|
def encode_drgb(pixels: list[tuple[int,int,int]]) -> bytes: ...
|
|
def encode_drgbw(pixels: list[tuple[int,int,int,int]]) -> bytes: ...
|
|
def encode_dnrgb_packets(pixels: list[tuple[int,int,int]], start_index: int = 0) -> list[bytes]: ...
|
|
```
|
|
|
|
From lightsync/protocol/animation_cmd.py:
|
|
```python
|
|
ANIM_CMD_MARKER = 0xAC
|
|
ANIMATION_IDS = {"solid_color": 0, "chase": 1, "pulse": 2, "rainbow": 3, "strobe": 4, "color_wipe": 5, "fire": 6}
|
|
def encode_animation_cmd(animation: str, params: dict) -> bytes: ...
|
|
def decode_animation_cmd(data: bytes) -> dict: ...
|
|
```
|
|
|
|
From lightsync/protocol/udp_sender.py:
|
|
```python
|
|
class UDPSender:
|
|
async def start(self) -> None: ...
|
|
def send(self, payload: bytes, ip: str, port: int) -> None: ...
|
|
async def stop(self) -> None: ...
|
|
@property
|
|
def is_running(self) -> bool: ...
|
|
```
|
|
|
|
<!-- From Plan 02 outputs -->
|
|
|
|
From lightsync/animations/__init__.py:
|
|
```python
|
|
ANIMATION_REGISTRY: dict[str, type[AnimationBase]] = { ... } # 7 types
|
|
def create_animation(name: str, params: dict) -> AnimationBase: ...
|
|
```
|
|
|
|
From lightsync/animations/base.py:
|
|
```python
|
|
class AnimationBase(ABC):
|
|
def render(self, t: float, led_count: int) -> list[tuple]: ...
|
|
@classmethod
|
|
def from_params(cls, params: dict) -> "AnimationBase": ...
|
|
```
|
|
|
|
From lightsync/main.py (current lifespan):
|
|
```python
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global registry, show_store
|
|
registry = DeviceRegistry(Path("devices.json"))
|
|
await registry.load()
|
|
show_store = ShowStore(Path("shows"))
|
|
show_store.ensure_dir()
|
|
app.state.beats = {}
|
|
yield
|
|
await registry.save()
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: UDP simulator with packet logging</name>
|
|
<files>lightsync/protocol/simulator.py, tests/test_e2e_protocol.py</files>
|
|
<read_first>
|
|
- lightsync/protocol/drgb.py (protocol constants PROTOCOL_DRGB, PROTOCOL_DRGBW, PROTOCOL_DNRGB, WLED_PORT)
|
|
- lightsync/protocol/animation_cmd.py (ANIM_CMD_MARKER, decode_animation_cmd)
|
|
- lightsync/protocol/udp_sender.py (UDPSender class for sending in tests)
|
|
- lightsync/animations/__init__.py (create_animation, ANIMATION_REGISTRY)
|
|
- .planning/phases/03-communication-protocol/03-RESEARCH.md (Simulator code example, Pitfall 5: port binding)
|
|
</read_first>
|
|
<behavior>
|
|
- SimulatorProtocol.datagram_received parses DRGB packets: extracts led_count = (len(data)-2)//3
|
|
- SimulatorProtocol.datagram_received parses DRGBW packets: extracts led_count = (len(data)-2)//4
|
|
- SimulatorProtocol.datagram_received parses DNRGB packets: extracts start index (big-endian uint16 at bytes 2-3) and led_count
|
|
- SimulatorProtocol.datagram_received parses animation command packets (0xAC): calls decode_animation_cmd
|
|
- SimulatorProtocol stores received packets in a log list for test assertions
|
|
- E2E test: encode_drgb 10 pixels -> send via UDPSender -> simulator receives with led_count=10
|
|
- E2E test: encode_drgbw 10 pixels -> send -> simulator receives with led_count=10 and protocol=DRGBW
|
|
- E2E test: encode_dnrgb_packets 600 pixels -> send all packets -> simulator receives 2 packets with correct start indices (0, 489)
|
|
- E2E test: encode_animation_cmd "chase" -> send -> simulator receives and decode matches original
|
|
- E2E test: create_animation("solid_color", {...}).render() -> encode_drgb -> send -> simulator receives correct pixel count
|
|
- run_simulator is runnable as __main__ standalone
|
|
</behavior>
|
|
<action>
|
|
Create `lightsync/protocol/simulator.py`:
|
|
|
|
```python
|
|
import asyncio
|
|
import struct
|
|
from dataclasses import dataclass, field
|
|
from lightsync.protocol.drgb import PROTOCOL_DRGB, PROTOCOL_DRGBW, PROTOCOL_DNRGB, WLED_PORT
|
|
from lightsync.protocol.animation_cmd import ANIM_CMD_MARKER, decode_animation_cmd
|
|
|
|
PROTOCOL_NAMES = {
|
|
PROTOCOL_DRGB: "DRGB",
|
|
PROTOCOL_DRGBW: "DRGBW",
|
|
PROTOCOL_DNRGB: "DNRGB",
|
|
ANIM_CMD_MARKER: "ANIM_CMD",
|
|
}
|
|
|
|
@dataclass
|
|
class PacketLog:
|
|
protocol: str
|
|
addr: tuple
|
|
led_count: int = 0
|
|
start_index: int = 0
|
|
raw: bytes = b""
|
|
decoded: dict | None = None
|
|
|
|
class SimulatorProtocol(asyncio.DatagramProtocol):
|
|
def __init__(self):
|
|
self.packets: list[PacketLog] = []
|
|
self.transport: asyncio.DatagramTransport | None = None
|
|
|
|
def connection_made(self, transport):
|
|
self.transport = transport
|
|
|
|
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
|
if len(data) < 2:
|
|
return
|
|
proto_type = data[0]
|
|
name = PROTOCOL_NAMES.get(proto_type, f"UNKNOWN(0x{proto_type:02X})")
|
|
|
|
log = PacketLog(protocol=name, addr=addr, raw=data)
|
|
|
|
if proto_type == PROTOCOL_DRGB:
|
|
log.led_count = (len(data) - 2) // 3
|
|
elif proto_type == PROTOCOL_DRGBW:
|
|
log.led_count = (len(data) - 2) // 4
|
|
elif proto_type == PROTOCOL_DNRGB:
|
|
log.start_index = struct.unpack_from(">H", data, 2)[0]
|
|
log.led_count = (len(data) - 4) // 3
|
|
elif proto_type == ANIM_CMD_MARKER:
|
|
try:
|
|
log.decoded = decode_animation_cmd(data)
|
|
except Exception:
|
|
log.decoded = None
|
|
|
|
self.packets.append(log)
|
|
print(f"[SIM] {name} from {addr}: {log.led_count} LEDs"
|
|
+ (f" start={log.start_index}" if proto_type == PROTOCOL_DNRGB else "")
|
|
+ (f" cmd={log.decoded}" if log.decoded else ""))
|
|
|
|
async def run_simulator(host: str = "0.0.0.0", port: int = WLED_PORT) -> tuple[asyncio.DatagramTransport, SimulatorProtocol]:
|
|
loop = asyncio.get_running_loop()
|
|
transport, protocol = await loop.create_datagram_endpoint(
|
|
SimulatorProtocol,
|
|
local_addr=(host, port),
|
|
)
|
|
print(f"[SIM] Listening on {host}:{port}")
|
|
return transport, protocol
|
|
|
|
if __name__ == "__main__":
|
|
async def main():
|
|
transport, protocol = await run_simulator()
|
|
try:
|
|
await asyncio.sleep(float("inf"))
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
transport.close()
|
|
asyncio.run(main())
|
|
```
|
|
|
|
Create `tests/test_e2e_protocol.py` — end-to-end tests using asyncio:
|
|
|
|
Use `pytest` with `pytest-asyncio` (or manual `asyncio.run` in fixtures). Each test:
|
|
1. Start simulator on localhost with a random free port (use port=0, then read `.transport.get_extra_info("sockname")[1]` for actual port)
|
|
2. Start UDPSender
|
|
3. Encode packet, send to simulator's port on 127.0.0.1
|
|
4. `await asyncio.sleep(0.05)` for UDP delivery
|
|
5. Assert simulator.packets has expected entries
|
|
6. Cleanup: stop sender, close simulator transport
|
|
|
|
Write a reusable async fixture or helper function for setup/teardown.
|
|
|
|
Tests to write:
|
|
- `test_e2e_drgb_10_pixels`: encode_drgb with 10 red pixels, send, assert simulator got DRGB with led_count=10
|
|
- `test_e2e_drgbw_10_pixels`: encode_drgbw with 10 RGBW pixels, send, assert DRGBW with led_count=10
|
|
- `test_e2e_dnrgb_600_pixels`: encode_dnrgb_packets with 600 pixels, send all packets, assert 2 packets received with start_index 0 and 489
|
|
- `test_e2e_animation_cmd_chase`: encode_animation_cmd("chase", params), send, assert simulator decoded animation=="chase"
|
|
- `test_e2e_full_pipeline`: create_animation("solid_color", {"color":[255,0,0]}).render(0.0, 20) -> encode_drgb -> send -> simulator receives 20 LED DRGB frame
|
|
- `test_e2e_sk6812_rgbw_pipeline`: create SK6812 device, render solid_color with white=128, encode_drgbw, send, simulator receives DRGBW with 4 bytes/pixel
|
|
- `test_simulator_standalone_import`: `from lightsync.protocol.simulator import run_simulator` does not raise
|
|
|
|
If `pytest-asyncio` is not available, use this pattern instead:
|
|
```python
|
|
def test_e2e_something():
|
|
asyncio.run(_test_e2e_something())
|
|
|
|
async def _test_e2e_something():
|
|
# actual async test logic
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -m pytest tests/test_e2e_protocol.py -v</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/protocol/simulator.py contains "class SimulatorProtocol(asyncio.DatagramProtocol)"
|
|
- lightsync/protocol/simulator.py contains "class PacketLog"
|
|
- lightsync/protocol/simulator.py contains "async def run_simulator("
|
|
- lightsync/protocol/simulator.py contains 'if __name__ == "__main__"'
|
|
- lightsync/protocol/simulator.py contains "decode_animation_cmd"
|
|
- lightsync/protocol/simulator.py contains 'struct.unpack_from(">H"'
|
|
- tests/test_e2e_protocol.py exits 0 with all tests passing
|
|
- tests/test_e2e_protocol.py contains at least 5 test functions
|
|
- tests/test_e2e_protocol.py contains "UDPSender" and "SimulatorProtocol" and "encode_drgb"
|
|
</acceptance_criteria>
|
|
<done>Simulator receives and correctly parses all 4 packet types (DRGB, DRGBW, DNRGB, ANIM_CMD). End-to-end tests prove full pipeline: animation render -> encode -> send -> receive -> validate. Simulator runnable standalone via `python -m lightsync.protocol.simulator`.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Integrate UDPSender into FastAPI lifespan</name>
|
|
<files>lightsync/main.py</files>
|
|
<read_first>
|
|
- lightsync/main.py (current lifespan function — app.state.beats pattern)
|
|
- lightsync/protocol/udp_sender.py (UDPSender start/stop lifecycle)
|
|
</read_first>
|
|
<action>
|
|
Edit `lightsync/main.py` lifespan function to add UDPSender lifecycle:
|
|
|
|
1. Add import at top: `from lightsync.protocol.udp_sender import UDPSender`
|
|
|
|
2. In the lifespan function, AFTER the existing `app.state.beats = {}` line, add:
|
|
```python
|
|
# UDP sender for device communication (Phase 3)
|
|
udp_sender = UDPSender()
|
|
await udp_sender.start()
|
|
app.state.udp_sender = udp_sender
|
|
```
|
|
|
|
3. In the shutdown section (after `yield`), BEFORE `await registry.save()`, add:
|
|
```python
|
|
await app.state.udp_sender.stop()
|
|
```
|
|
|
|
Keep ALL existing lifespan code unchanged. Only add the UDPSender lines.
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -c "
|
|
import ast, sys
|
|
with open('lightsync/main.py') as f:
|
|
source = f.read()
|
|
tree = ast.parse(source)
|
|
# Check import exists
|
|
has_import = 'from lightsync.protocol.udp_sender import UDPSender' in source
|
|
# Check app.state.udp_sender assignment
|
|
has_assignment = 'app.state.udp_sender' in source
|
|
# Check stop call
|
|
has_stop = 'udp_sender.stop()' in source
|
|
# Check existing code preserved
|
|
has_beats = 'app.state.beats' in source
|
|
has_registry = 'await registry.save()' in source
|
|
results = [
|
|
('import UDPSender', has_import),
|
|
('app.state.udp_sender', has_assignment),
|
|
('udp_sender.stop()', has_stop),
|
|
('app.state.beats preserved', has_beats),
|
|
('registry.save() preserved', has_registry),
|
|
]
|
|
for name, ok in results:
|
|
status = 'OK' if ok else 'MISSING'
|
|
print(f'{name}: {status}')
|
|
all_ok = all(ok for _, ok in results)
|
|
sys.exit(0 if all_ok else 1)
|
|
"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/main.py contains "from lightsync.protocol.udp_sender import UDPSender"
|
|
- lightsync/main.py contains "app.state.udp_sender = udp_sender" (or equivalent)
|
|
- lightsync/main.py contains "await udp_sender.stop()" (or "await app.state.udp_sender.stop()")
|
|
- lightsync/main.py still contains "app.state.beats = {}" (existing code preserved)
|
|
- lightsync/main.py still contains "await registry.save()" (existing shutdown preserved)
|
|
- lightsync/main.py still contains "registry = DeviceRegistry" (existing startup preserved)
|
|
</acceptance_criteria>
|
|
<done>UDPSender starts on app boot and stops on shutdown. Available to all routes via request.app.state.udp_sender. All existing lifespan behavior unchanged.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `python -m pytest tests/test_e2e_protocol.py -v` — all end-to-end tests pass
|
|
- `python -c "from lightsync.protocol.simulator import run_simulator; print('simulator importable')"` — no import errors
|
|
- `python -m lightsync.protocol.simulator &` then `python -c "import asyncio; from lightsync.protocol.udp_sender import UDPSender; from lightsync.protocol.drgb import encode_drgb; asyncio.run((s := UDPSender()) or s.start()); s.send(encode_drgb([(255,0,0)]*10), '127.0.0.1', 21324)"` — simulator logs DRGB packet
|
|
- lightsync/main.py has app.state.udp_sender in lifespan
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Simulator correctly parses and logs all 4 packet types (DRGB, DRGBW, DNRGB, animation command)
|
|
- End-to-end tests prove: animation render -> encode -> UDP send -> simulator receive -> validate
|
|
- DNRGB multi-packet splitting verified: 600-LED frame splits into 2 packets with correct start indices
|
|
- UDPSender integrated into FastAPI lifespan (app.state.udp_sender)
|
|
- Simulator runnable standalone via `python -m lightsync.protocol.simulator`
|
|
- No hardware required for any validation
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-communication-protocol/03-03-SUMMARY.md`
|
|
</output>
|