Three sequential plans for MicroPython ESP32 firmware: - 07-01: Scaffold (UDP listener, packet discriminator, LED drivers, config) - 07-02: Animation renderer (all 7 types ported from lightsync) - 07-03: Frame mode (DRGB/DRGBW/DNRGB parsing, reassembly, timeout) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
527 lines
18 KiB
Markdown
527 lines
18 KiB
Markdown
# Plan 07-01: Firmware Scaffold
|
|
|
|
**Phase**: 7 — Microcontroller Firmware
|
|
**Goal**: A MicroPython firmware skeleton that connects to WiFi, listens for UDP packets, discriminates packet types, and drives SK6812 or WS2801 LED strips via a unified driver abstraction.
|
|
|
|
## Prompt
|
|
|
|
Create the firmware directory at `firmware/` in the project root. This is standalone MicroPython code for ESP32 (primary) / Pico W (secondary). It does NOT import from `lightsync/` — all code is self-contained.
|
|
|
|
### File 1: `firmware/config.json`
|
|
|
|
Create the default device configuration:
|
|
|
|
```json
|
|
{
|
|
"ssid": "MyWiFi",
|
|
"password": "secret",
|
|
"led_count": 300,
|
|
"strip_type": "sk6812",
|
|
"led_pin": 5,
|
|
"spi_id": 1,
|
|
"spi_clk_pin": 18,
|
|
"spi_dat_pin": 23,
|
|
"udp_port": 21324
|
|
}
|
|
```
|
|
|
|
- `strip_type`: either `"sk6812"` (RGBW, neopixel) or `"ws2801"` (RGB, SPI)
|
|
- `led_pin`: GPIO pin for neopixel data line (SK6812 only)
|
|
- `spi_id`, `spi_clk_pin`, `spi_dat_pin`: SPI config for WS2801 only
|
|
- `udp_port`: UDP listen port (WLED default 21324)
|
|
|
|
### File 2: `firmware/led_driver.py`
|
|
|
|
Create the LED strip driver abstraction. Two classes, no ABC (MicroPython has no `abc` module in standard builds):
|
|
|
|
```python
|
|
class NeoPixelStrip:
|
|
"""SK6812 RGBW driver using built-in neopixel module."""
|
|
|
|
def __init__(self, pin_num, led_count):
|
|
import neopixel
|
|
from machine import Pin
|
|
self.np = neopixel.NeoPixel(Pin(pin_num), led_count, bpp=4, timing=1)
|
|
self.led_count = led_count
|
|
|
|
def write_rgb(self, pixels):
|
|
"""Write list of (R,G,B) tuples. W channel set to 0."""
|
|
for i, (r, g, b) in enumerate(pixels):
|
|
if i >= self.led_count:
|
|
break
|
|
self.np[i] = (r, g, b, 0)
|
|
self.np.write()
|
|
|
|
def write_rgbw(self, pixels):
|
|
"""Write list of (R,G,B,W) tuples."""
|
|
for i, (r, g, b, w) in enumerate(pixels):
|
|
if i >= self.led_count:
|
|
break
|
|
self.np[i] = (r, g, b, w)
|
|
self.np.write()
|
|
|
|
def fill(self, r, g, b, w=0):
|
|
"""Fill all pixels with one color."""
|
|
self.np.fill((r, g, b, w))
|
|
self.np.write()
|
|
|
|
def clear(self):
|
|
self.fill(0, 0, 0, 0)
|
|
|
|
|
|
class WS2801Strip:
|
|
"""WS2801 RGB driver using SPI — no external library needed."""
|
|
|
|
def __init__(self, spi_id, clk_pin, dat_pin, led_count):
|
|
from machine import SPI, Pin
|
|
self.spi = SPI(spi_id, baudrate=1000000, polarity=0, phase=0,
|
|
sck=Pin(clk_pin), mosi=Pin(dat_pin))
|
|
self.led_count = led_count
|
|
self._buf = bytearray(led_count * 3)
|
|
|
|
def write_rgb(self, pixels):
|
|
"""Write list of (R,G,B) tuples."""
|
|
for i, (r, g, b) in enumerate(pixels):
|
|
if i >= self.led_count:
|
|
break
|
|
off = i * 3
|
|
self._buf[off] = r
|
|
self._buf[off + 1] = g
|
|
self._buf[off + 2] = b
|
|
self.spi.write(self._buf)
|
|
|
|
def write_rgbw(self, pixels):
|
|
"""Write RGBW tuples — W channel is discarded (WS2801 is RGB only)."""
|
|
for i, (r, g, b, _w) in enumerate(pixels):
|
|
if i >= self.led_count:
|
|
break
|
|
off = i * 3
|
|
self._buf[off] = r
|
|
self._buf[off + 1] = g
|
|
self._buf[off + 2] = b
|
|
self.spi.write(self._buf)
|
|
|
|
def fill(self, r, g, b, w=0):
|
|
for i in range(self.led_count):
|
|
off = i * 3
|
|
self._buf[off] = r
|
|
self._buf[off + 1] = g
|
|
self._buf[off + 2] = b
|
|
self.spi.write(self._buf)
|
|
|
|
def clear(self):
|
|
self.fill(0, 0, 0)
|
|
|
|
|
|
def create_strip(config):
|
|
"""Factory: create the right strip driver from config dict."""
|
|
if config['strip_type'] == 'sk6812':
|
|
return NeoPixelStrip(config['led_pin'], config['led_count'])
|
|
elif config['strip_type'] == 'ws2801':
|
|
return WS2801Strip(config['spi_id'], config.get('spi_clk_pin', 18),
|
|
config.get('spi_dat_pin', 23), config['led_count'])
|
|
else:
|
|
raise ValueError('Unknown strip_type: ' + config['strip_type'])
|
|
```
|
|
|
|
Both classes MUST implement the same interface: `write_rgb(pixels)`, `write_rgbw(pixels)`, `fill(r,g,b,w)`, `clear()`. This is the contract that `main.py` and animation/frame code will use.
|
|
|
|
### File 3: `firmware/protocol.py`
|
|
|
|
Create packet parsers that decode the exact binary formats produced by `lightsync/protocol/drgb.py` and `lightsync/protocol/animation_cmd.py`. These must be byte-exact mirrors of the Python encoders.
|
|
|
|
```python
|
|
import struct
|
|
|
|
# Protocol type bytes (first byte of UDP packet)
|
|
PROTO_DRGB = 0x02
|
|
PROTO_DRGBW = 0x03
|
|
PROTO_DNRGB = 0x04
|
|
PROTO_ANIM_CMD = 0xAC
|
|
|
|
ANIMATION_NAMES = {
|
|
0: 'solid_color', 1: 'chase', 2: 'pulse',
|
|
3: 'rainbow', 4: 'strobe', 5: 'color_wipe', 6: 'fire'
|
|
}
|
|
|
|
|
|
def classify_packet(data):
|
|
"""Return packet type string from first byte, or None if unknown."""
|
|
if not data:
|
|
return None
|
|
b = data[0]
|
|
if b == PROTO_DRGB:
|
|
return 'drgb'
|
|
elif b == PROTO_DRGBW:
|
|
return 'drgbw'
|
|
elif b == PROTO_DNRGB:
|
|
return 'dnrgb'
|
|
elif b == PROTO_ANIM_CMD:
|
|
return 'anim_cmd'
|
|
return None
|
|
|
|
|
|
def parse_drgb(data):
|
|
"""Parse DRGB packet -> list of (R,G,B) tuples.
|
|
|
|
Format: [0x02][timeout][R0][G0][B0]...[Rn][Gn][Bn]
|
|
"""
|
|
n = (len(data) - 2) // 3
|
|
pixels = []
|
|
for i in range(n):
|
|
off = 2 + i * 3
|
|
pixels.append((data[off], data[off + 1], data[off + 2]))
|
|
return pixels
|
|
|
|
|
|
def parse_drgbw(data):
|
|
"""Parse DRGBW packet -> list of (R,G,B,W) tuples.
|
|
|
|
Format: [0x03][timeout][R0][G0][B0][W0]...[Rn][Gn][Bn][Wn]
|
|
"""
|
|
n = (len(data) - 2) // 4
|
|
pixels = []
|
|
for i in range(n):
|
|
off = 2 + i * 4
|
|
pixels.append((data[off], data[off + 1], data[off + 2], data[off + 3]))
|
|
return pixels
|
|
|
|
|
|
def parse_dnrgb(data):
|
|
"""Parse DNRGB packet -> (start_index, list of (R,G,B) tuples).
|
|
|
|
Format: [0x04][timeout][start_hi][start_lo][R0][G0][B0]...
|
|
Start index is big-endian uint16.
|
|
"""
|
|
start_index = struct.unpack('>H', data[2:4])[0]
|
|
n = (len(data) - 4) // 3
|
|
pixels = []
|
|
for i in range(n):
|
|
off = 4 + i * 3
|
|
pixels.append((data[off], data[off + 1], data[off + 2]))
|
|
return start_index, pixels
|
|
|
|
|
|
def parse_animation_cmd(data):
|
|
"""Parse animation command packet -> dict with animation name and params.
|
|
|
|
Format (16 bytes, big-endian):
|
|
Byte 0: 0xAC marker
|
|
Byte 1: version (1)
|
|
Byte 2: animation type ID (0-6)
|
|
Byte 3: flags (bit 0 = reverse)
|
|
Bytes 4-7: speed (float32 big-endian)
|
|
Bytes 8-10: primary color R, G, B
|
|
Bytes 11-13: background color R, G, B
|
|
Byte 14: white channel
|
|
Byte 15: density/size/cooling
|
|
"""
|
|
if len(data) < 16 or data[0] != PROTO_ANIM_CMD:
|
|
return None
|
|
anim_id = data[2]
|
|
flags = data[3]
|
|
speed = struct.unpack('>f', data[4:8])[0]
|
|
anim_name = ANIMATION_NAMES.get(anim_id)
|
|
if anim_name is None:
|
|
return None
|
|
return {
|
|
'animation': anim_name,
|
|
'speed': speed,
|
|
'color': (data[8], data[9], data[10]),
|
|
'bg_color': (data[11], data[12], data[13]),
|
|
'white': data[14],
|
|
'density': data[15],
|
|
'reverse': bool(flags & 0x01),
|
|
}
|
|
```
|
|
|
|
### File 4: `firmware/boot.py`
|
|
|
|
WiFi connection on startup. This runs before `main.py` automatically on MicroPython.
|
|
|
|
```python
|
|
import network
|
|
import time
|
|
import json
|
|
|
|
def load_config():
|
|
try:
|
|
with open('config.json') as f:
|
|
return json.load(f)
|
|
except OSError:
|
|
print('[boot] config.json not found, using defaults')
|
|
return {
|
|
'ssid': 'MyWiFi', 'password': 'secret',
|
|
'led_count': 300, 'strip_type': 'sk6812',
|
|
'led_pin': 5, 'spi_id': 1, 'udp_port': 21324
|
|
}
|
|
|
|
def connect_wifi(ssid, password, timeout=15):
|
|
wlan = network.WLAN(network.STA_IF)
|
|
wlan.active(True)
|
|
if wlan.isconnected():
|
|
print('[boot] Already connected:', wlan.ifconfig()[0])
|
|
return wlan
|
|
print('[boot] Connecting to', ssid, '...')
|
|
wlan.connect(ssid, password)
|
|
start = time.time()
|
|
while not wlan.isconnected():
|
|
if time.time() - start > timeout:
|
|
print('[boot] WiFi timeout after', timeout, 'seconds')
|
|
return wlan
|
|
time.sleep(0.5)
|
|
print('[boot] Connected:', wlan.ifconfig()[0])
|
|
return wlan
|
|
|
|
config = load_config()
|
|
wlan = connect_wifi(config['ssid'], config['password'])
|
|
```
|
|
|
|
### File 5: `firmware/main.py`
|
|
|
|
The main asyncio loop with two tasks: UDP listener and animation render loop. This is the scaffold only — animation rendering and frame passthrough will be added by plans 07-02 and 07-03.
|
|
|
|
```python
|
|
import asyncio
|
|
import socket
|
|
import select
|
|
import time
|
|
import json
|
|
|
|
# Import firmware modules
|
|
from protocol import classify_packet, parse_animation_cmd
|
|
from led_driver import create_strip
|
|
|
|
# --- Global state ---
|
|
config = None
|
|
strip = None
|
|
current_mode = 'idle' # 'idle', 'animation', 'frame'
|
|
current_animation = None # dict from parse_animation_cmd
|
|
animation_start_ms = 0
|
|
frame_pixels = None # set by frame packets
|
|
|
|
def load_config():
|
|
try:
|
|
with open('config.json') as f:
|
|
return json.load(f)
|
|
except OSError:
|
|
return {'led_count': 300, 'strip_type': 'sk6812', 'led_pin': 5,
|
|
'spi_id': 1, 'udp_port': 21324}
|
|
|
|
async def udp_listener_task(port):
|
|
"""Non-blocking UDP listener using select.poll(0) inside asyncio."""
|
|
global current_mode, current_animation, animation_start_ms, frame_pixels
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
sock.bind(('0.0.0.0', port))
|
|
sock.setblocking(False)
|
|
poller = select.poll()
|
|
poller.register(sock, select.POLLIN)
|
|
|
|
print('[udp] Listening on port', port)
|
|
|
|
while True:
|
|
events = poller.poll(0)
|
|
if events:
|
|
try:
|
|
data, addr = sock.recvfrom(1500)
|
|
ptype = classify_packet(data)
|
|
|
|
if ptype == 'anim_cmd':
|
|
cmd = parse_animation_cmd(data)
|
|
if cmd:
|
|
current_animation = cmd
|
|
current_mode = 'animation'
|
|
animation_start_ms = time.ticks_ms()
|
|
print('[udp] Animation:', cmd['animation'])
|
|
|
|
elif ptype in ('drgb', 'drgbw', 'dnrgb'):
|
|
# Frame handling added by Plan 07-03
|
|
current_mode = 'frame'
|
|
print('[udp] Frame packet:', ptype, len(data), 'bytes')
|
|
|
|
else:
|
|
print('[udp] Unknown packet type:', data[0] if data else '?')
|
|
except OSError:
|
|
pass
|
|
|
|
await asyncio.sleep_ms(5)
|
|
|
|
async def render_task():
|
|
"""Animation render loop at ~60fps. Rendering logic added by Plans 07-02/07-03."""
|
|
global current_mode, frame_pixels
|
|
|
|
while True:
|
|
if current_mode == 'animation' and current_animation:
|
|
# Plan 07-02 adds: render_animation() call here
|
|
pass
|
|
elif current_mode == 'frame' and frame_pixels:
|
|
# Plan 07-03 adds: write frame_pixels to strip here
|
|
pass
|
|
|
|
await asyncio.sleep_ms(16) # ~60 fps
|
|
|
|
async def main():
|
|
global config, strip
|
|
|
|
config = load_config()
|
|
strip = create_strip(config)
|
|
strip.clear()
|
|
|
|
print('[main] LightSync firmware ready')
|
|
print('[main] Strip:', config['strip_type'], '/', config['led_count'], 'LEDs')
|
|
|
|
asyncio.create_task(udp_listener_task(config['udp_port']))
|
|
asyncio.create_task(render_task())
|
|
|
|
# Run forever
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
|
|
# Entry point
|
|
asyncio.run(main())
|
|
```
|
|
|
|
**CRITICAL**: The `await asyncio.sleep_ms(5)` in the UDP listener is essential. Without it, the listener monopolizes MicroPython's cooperative scheduler and the render task never runs.
|
|
|
|
### File 6: `tests/test_firmware_protocol.py`
|
|
|
|
Create host-side tests that validate the firmware protocol parsers produce correct output for packets generated by the existing Python encoders. These tests run on the development machine (regular Python, not MicroPython), importing `firmware/protocol.py` directly since it uses only stdlib modules (`struct`).
|
|
|
|
```python
|
|
"""Host-side tests for firmware protocol parsers.
|
|
|
|
These tests verify that firmware/protocol.py correctly decodes packets
|
|
produced by lightsync/protocol/drgb.py and lightsync/protocol/animation_cmd.py.
|
|
Run with: python -m pytest tests/test_firmware_protocol.py -v
|
|
"""
|
|
import sys
|
|
import os
|
|
import pytest
|
|
|
|
# Add firmware/ to path so we can import protocol.py
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'firmware'))
|
|
|
|
from protocol import classify_packet, parse_drgb, parse_drgbw, parse_dnrgb, parse_animation_cmd
|
|
|
|
# Import the server-side encoders for round-trip testing
|
|
from lightsync.protocol.drgb import encode_drgb, encode_drgbw, encode_dnrgb_packets
|
|
from lightsync.protocol.animation_cmd import encode_animation_cmd
|
|
|
|
|
|
class TestClassifyPacket:
|
|
def test_drgb(self):
|
|
assert classify_packet(bytes([0x02, 0x00])) == 'drgb'
|
|
|
|
def test_drgbw(self):
|
|
assert classify_packet(bytes([0x03, 0x00])) == 'drgbw'
|
|
|
|
def test_dnrgb(self):
|
|
assert classify_packet(bytes([0x04, 0x00])) == 'dnrgb'
|
|
|
|
def test_anim_cmd(self):
|
|
assert classify_packet(bytes([0xAC, 0x00])) == 'anim_cmd'
|
|
|
|
def test_unknown(self):
|
|
assert classify_packet(bytes([0xFF])) is None
|
|
|
|
def test_empty(self):
|
|
assert classify_packet(b'') is None
|
|
|
|
|
|
class TestDRGBRoundTrip:
|
|
def test_simple_pixels(self):
|
|
pixels = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
|
|
packet = encode_drgb(pixels)
|
|
decoded = parse_drgb(packet)
|
|
assert decoded == pixels
|
|
|
|
def test_300_leds(self):
|
|
pixels = [(i % 256, (i * 2) % 256, (i * 3) % 256) for i in range(300)]
|
|
packet = encode_drgb(pixels)
|
|
decoded = parse_drgb(packet)
|
|
assert len(decoded) == 300
|
|
assert decoded[0] == pixels[0]
|
|
assert decoded[299] == pixels[299]
|
|
|
|
|
|
class TestDRGBWRoundTrip:
|
|
def test_simple_pixels(self):
|
|
pixels = [(255, 0, 0, 128), (0, 255, 0, 64)]
|
|
packet = encode_drgbw(pixels)
|
|
decoded = parse_drgbw(packet)
|
|
assert decoded == pixels
|
|
|
|
def test_300_leds(self):
|
|
pixels = [(i % 256, (i * 2) % 256, (i * 3) % 256, (i * 4) % 256) for i in range(300)]
|
|
packet = encode_drgbw(pixels)
|
|
decoded = parse_drgbw(packet)
|
|
assert len(decoded) == 300
|
|
assert decoded[0] == pixels[0]
|
|
|
|
|
|
class TestDNRGBRoundTrip:
|
|
def test_single_packet(self):
|
|
pixels = [(255, 0, 0)] * 100
|
|
packets = encode_dnrgb_packets(pixels)
|
|
assert len(packets) == 1
|
|
start_idx, decoded = parse_dnrgb(packets[0])
|
|
assert start_idx == 0
|
|
assert len(decoded) == 100
|
|
|
|
def test_multi_packet(self):
|
|
pixels = [(i % 256, 0, 0) for i in range(600)]
|
|
packets = encode_dnrgb_packets(pixels)
|
|
assert len(packets) == 2
|
|
start0, px0 = parse_dnrgb(packets[0])
|
|
start1, px1 = parse_dnrgb(packets[1])
|
|
assert start0 == 0
|
|
assert start1 == 489
|
|
assert len(px0) == 489
|
|
assert len(px1) == 111
|
|
|
|
|
|
class TestAnimationCmdRoundTrip:
|
|
@pytest.mark.parametrize("anim_name", [
|
|
'solid_color', 'chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire'
|
|
])
|
|
def test_all_animations(self, anim_name):
|
|
params = {'color': [255, 128, 0], 'speed': 0.7, 'white': 32,
|
|
'bg_color': [10, 20, 30], 'reverse': True, 'density': 42}
|
|
packet = encode_animation_cmd(anim_name, params)
|
|
decoded = parse_animation_cmd(packet)
|
|
assert decoded is not None
|
|
assert decoded['animation'] == anim_name
|
|
assert decoded['color'] == (255, 128, 0)
|
|
assert decoded['bg_color'] == (10, 20, 30)
|
|
assert decoded['white'] == 32
|
|
assert decoded['reverse'] is True
|
|
assert abs(decoded['speed'] - 0.7) < 0.001
|
|
|
|
def test_invalid_marker(self):
|
|
assert parse_animation_cmd(bytes(16)) is None
|
|
|
|
def test_too_short(self):
|
|
assert parse_animation_cmd(bytes([0xAC, 0x01])) is None
|
|
```
|
|
|
|
These tests are the primary verification mechanism. They prove that firmware parsers correctly decode packets from the server-side encoders — the exact round-trip that must work in production.
|
|
|
|
## Verification
|
|
|
|
- [ ] `firmware/` directory exists with 5 files: `boot.py`, `main.py`, `config.json`, `led_driver.py`, `protocol.py`
|
|
- [ ] `python -m pytest tests/test_firmware_protocol.py -v` passes all tests (run from project root)
|
|
- [ ] `firmware/protocol.py` correctly round-trips all 4 packet types against server-side encoders
|
|
- [ ] `firmware/led_driver.py` has both `NeoPixelStrip` and `WS2801Strip` classes with identical interface (`write_rgb`, `write_rgbw`, `fill`, `clear`)
|
|
- [ ] `firmware/main.py` has two asyncio tasks (`udp_listener_task` and `render_task`) with proper `await asyncio.sleep_ms()` yields
|
|
- [ ] `firmware/boot.py` reads `config.json` and connects to WiFi
|
|
- [ ] `firmware/config.json` has all 9 fields: ssid, password, led_count, strip_type, led_pin, spi_id, spi_clk_pin, spi_dat_pin, udp_port
|
|
- [ ] Packet discriminator handles all 4 types: 0x02 (DRGB), 0x03 (DRGBW), 0x04 (DNRGB), 0xAC (animation cmd)
|
|
|
|
## Notes
|
|
|
|
- The `led_driver.py` module uses `machine` and `neopixel` imports which only exist on MicroPython. The host-side tests do NOT test `led_driver.py` — that is device-only verification. Tests focus on `protocol.py` which is pure Python and runs on both CPython and MicroPython.
|
|
- The `main.py` render_task and frame handling are intentionally stubbed. Plan 07-02 fills in animation rendering, Plan 07-03 fills in frame passthrough.
|
|
- WS2801 `write_rgbw` discards the W channel since WS2801 is RGB-only. This is correct — the protocol may send DRGBW packets to any device, and the driver silently drops W.
|
|
- Use `broad except OSError` for socket errors, not specific `EAGAIN` — MicroPython on ESP8266 raises `ETIMEDOUT` instead of `EAGAIN` for non-blocking recv.
|