561 lines
24 KiB
Markdown
561 lines
24 KiB
Markdown
# Phase 7 Research: Microcontroller Firmware
|
||
|
||
**Researched:** 2026-04-07
|
||
**Domain:** MicroPython, LED strip drivers, UDP networking on embedded hardware
|
||
**Confidence:** HIGH (protocol layer) / MEDIUM (MicroPython UDP patterns) / HIGH (LED driver APIs)
|
||
|
||
---
|
||
|
||
## Key Findings
|
||
|
||
- **MicroPython asyncio does NOT support UDP natively.** There is no `create_datagram_endpoint()` equivalent. The correct pattern is a non-blocking socket with `socket.setblocking(False)` combined with `select.poll()` inside an `asyncio` task that yields via `await asyncio.sleep_ms(N)` — this keeps the event loop alive while polling for packets.
|
||
- **SK6812 (RGBW) works with the built-in `neopixel` module** using `bpp=4` — native to ESP32, ESP8266, and RP2 (Pico). WS2801 is SPI-based and requires a separate library (`micropython-ws2801`) or a trivial SPI implementation since WS2801 clocks data in on SPI CLK.
|
||
- **ESP32 is the best target**: ~106 KB free heap with WiFi active (vs Pico W's threading-related UDP buffer quirks). The Pico W has known networking issues when the wireless stack runs on the second processor; asyncio-on-main-core is the workaround.
|
||
- **Raspberry Pi 4B runs regular Python** (not MicroPython) — use `rpi_ws281x` for WS281x/SK6812 and `spidev`+`Adafruit_WS2801` for WS2801. UDP is trivial with the stdlib `socket` module.
|
||
- **Frame buffer for 300 LEDs**: DRGB = 902 bytes, DRGBW = 1202 bytes. Both fit comfortably within one UDP packet (max payload 1472 bytes). Multi-packet reassembly (DNRGB) only triggers above 489 LEDs RGB / 367 LEDs RGBW, so 300 LEDs never needs reassembly in DRGB; only DRGBW for 300 LEDs (1202 bytes) fits in one DRGBW packet (max 367 × 4 + 2 = 1470 bytes). Confirmed: both 300-LED strip types fit in a single packet — no reassembly needed.
|
||
- **Animation rendering in MicroPython** must port Python math from `lightsync/animations/*.py` directly — `math.sin`, `math` module available. `colorsys` is NOT available in standard MicroPython; rainbow needs an inline HSV-to-RGB function (~5 lines).
|
||
|
||
---
|
||
|
||
## MicroPython UDP Patterns
|
||
|
||
### The Core Problem
|
||
|
||
MicroPython's `asyncio` (as of v1.23/v1.27, current 2025) has **no UDP support**. The `open_connection()` and `start_server()` APIs are TCP-only. Issue #13382 on the MicroPython GitHub confirms this is a known gap with no simple fix planned for embedded targets due to the transport-protocol overhead.
|
||
|
||
### Recommended Pattern: Non-blocking Socket in asyncio Task
|
||
|
||
```python
|
||
import asyncio
|
||
import socket
|
||
import select
|
||
|
||
async def udp_listener(port: int, queue):
|
||
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)
|
||
|
||
while True:
|
||
events = poller.poll(0) # 0 ms timeout — non-blocking check
|
||
if events:
|
||
data, addr = sock.recvfrom(1500) # MTU-sized buffer
|
||
await queue.put(data)
|
||
await asyncio.sleep_ms(5) # yield to other tasks
|
||
```
|
||
|
||
The `await asyncio.sleep_ms(5)` is critical — it yields the event loop so the animation task can run. Without it, the listener monopolizes the scheduler.
|
||
|
||
### Alternative: Polling Loop Without asyncio
|
||
|
||
For very simple firmware (single animation, no concurrent tasks), a synchronous polling loop works:
|
||
|
||
```python
|
||
sock.setblocking(False)
|
||
while True:
|
||
try:
|
||
data = sock.recv(1500)
|
||
handle_packet(data)
|
||
except OSError:
|
||
pass # EAGAIN / ETIMEDOUT — no data available
|
||
render_frame()
|
||
time.sleep_ms(16) # ~60 fps
|
||
```
|
||
|
||
**Warning**: On ESP8266, non-blocking recv raises `ETIMEDOUT` instead of `EAGAIN` (MicroPython issue #5759). Use a broad `except OSError` not `except EAGAIN`.
|
||
|
||
### Buffer Sizing
|
||
|
||
Use `recvfrom(1500)` — slightly over the 1472-byte WLED max payload. This handles the largest possible DRGB/DRGBW packet in a single call. Do not use smaller buffers; WLED UDP packets can be exactly 1472 bytes (490-LED DRGB).
|
||
|
||
### Pico W Caveat
|
||
|
||
The Pico W's CYW43 wireless chip runs on the second RP2040 processor. There are confirmed reports of UDP packets being blocked until a `sendto()` occurs (Discussion #13214). The fix is: keep networking on the main thread/coroutine, use asyncio throughout — do NOT use `_thread` for networking on Pico W.
|
||
|
||
---
|
||
|
||
## LED Strip Drivers
|
||
|
||
### SK6812 (RGBW) — Built-in neopixel Module
|
||
|
||
The `neopixel` module is built into MicroPython for ESP32, ESP8266, and RP2 (Pico). RGBW support uses `bpp=4`:
|
||
|
||
```python
|
||
import neopixel
|
||
from machine import Pin
|
||
|
||
# SK6812 RGBW — 300 LEDs on GPIO pin 5
|
||
np = neopixel.NeoPixel(Pin(5), 300, bpp=4, timing=1)
|
||
|
||
# Set pixel 0 to R=255, G=0, B=0, W=0
|
||
np[0] = (255, 0, 0, 0)
|
||
|
||
# Write to strip
|
||
np.write()
|
||
```
|
||
|
||
**Confidence: HIGH** — verified from official MicroPython docs ([neopixel module](https://docs.micropython.org/en/latest/library/neopixel.html))
|
||
|
||
Key points:
|
||
- `bpp=4` for RGBW, `bpp=3` (default) for RGB
|
||
- `timing=1` for 800 kHz (SK6812 default), `timing=0` for 400 kHz
|
||
- `np.fill((r, g, b, w))` sets all pixels uniformly
|
||
- `np.write()` flushes to strip (blocks briefly — ~300 µs for 300 LEDs at 800 kHz)
|
||
|
||
### WS2812B (RGB, reference) — Same Module, bpp=3
|
||
|
||
```python
|
||
np = neopixel.NeoPixel(Pin(5), 300) # defaults: bpp=3, timing=1
|
||
np[0] = (255, 0, 0)
|
||
np.write()
|
||
```
|
||
|
||
### WS2801 (SPI-based RGB) — External Library or Direct SPI
|
||
|
||
WS2801 uses clock + data (SPI), not the single-wire timing protocol. Two approaches:
|
||
|
||
**Option A: micropython-ws2801 library (pip/mip install)**
|
||
```python
|
||
from machine import SPI
|
||
import ws2801
|
||
|
||
spi = SPI(1, baudrate=1000000) # 1 MHz — WS2801 max clock
|
||
pixels = ws2801.WS2801Pixels(300, spi)
|
||
pixels.set_pixels_rgb(255, 0, 0) # all red
|
||
pixels.show()
|
||
```
|
||
|
||
**Option B: Direct SPI (trivial, no dependency)**
|
||
```python
|
||
from machine import SPI, Pin
|
||
import struct
|
||
|
||
spi = SPI(1, baudrate=1000000, polarity=0, phase=0)
|
||
cs = Pin(15, Pin.OUT)
|
||
|
||
def ws2801_write(pixels_rgb):
|
||
buf = bytearray(len(pixels_rgb) * 3)
|
||
for i, (r, g, b) in enumerate(pixels_rgb):
|
||
buf[i*3] = r
|
||
buf[i*3+1] = g
|
||
buf[i*3+2] = b
|
||
cs.low()
|
||
spi.write(buf)
|
||
cs.high()
|
||
```
|
||
|
||
WS2801 has no fixed latch timing requirement like WS2812 — the clock line going idle for >500 µs acts as a reset. SPI baudrate 500 kHz–2 MHz all work.
|
||
|
||
**For 300 LEDs**: buffer = 900 bytes — trivially small.
|
||
|
||
### Raspberry Pi 4B — Regular Python
|
||
|
||
RPi 4B runs full Linux, not MicroPython. Use regular Python libraries:
|
||
|
||
| Strip Type | Library | Install |
|
||
|------------|---------|---------|
|
||
| SK6812 (RGBW) | `rpi_ws281x` | `pip install rpi-ws281x` |
|
||
| WS2801 (SPI) | `spidev` + manual | `pip install spidev` |
|
||
|
||
```python
|
||
# SK6812 on RPi 4B
|
||
from rpi_ws281x import PixelStrip, GRB
|
||
strip = PixelStrip(300, 18, strip_type=GRB)
|
||
strip.begin()
|
||
strip[0] = Color(255, 0, 0) # R, G, B
|
||
strip.show()
|
||
```
|
||
|
||
Note: RPi 4B requires fixed CPU frequency in `/boot/config.txt` to avoid SPI timing drift (`core_freq=250`).
|
||
|
||
---
|
||
|
||
## Platform Comparison (ESP32 / Pico W / RPi 4B)
|
||
|
||
| Property | ESP32 | Pico W | RPi 4B |
|
||
|----------|-------|--------|--------|
|
||
| Language | MicroPython | MicroPython | Regular Python 3 |
|
||
| WiFi | Built-in, stable | Built-in, quirky UDP | Via USB dongle or Ethernet |
|
||
| Free RAM (WiFi on) | ~106 KB heap | ~164 KB but networking issues | 4 GB RAM |
|
||
| SK6812 RGBW | neopixel bpp=4 (native) | neopixel bpp=4 (native) | rpi_ws281x library |
|
||
| WS2801 SPI | machine.SPI + ws2801 lib | machine.SPI + ws2801 lib | spidev |
|
||
| asyncio UDP | Non-blocking socket workaround | Same, but CYW43 threading caution | Full asyncio or stdlib socket |
|
||
| 300-LED frame buffer | 1202 bytes — fine | 1202 bytes — fine | trivial |
|
||
| neopixel timing | BitStream via RMT peripheral | PIO state machine | Hardware PWM/SPI |
|
||
| Deployment | Flash via Thonny/mpremote | Flash via Thonny/mpremote | Python script via SSH/service |
|
||
|
||
**Recommendation: ESP32 as primary target.** Reasons:
|
||
1. WiFi is integrated and battle-tested in MicroPython (unlike Pico W's CYW43 threading quirks)
|
||
2. Hardware RMT peripheral handles neopixel timing precisely without blocking the CPU
|
||
3. ~106 KB heap comfortably holds 300-LED frame buffers (1200 bytes = 1% of heap)
|
||
4. Widely documented for WLED UDP patterns
|
||
|
||
**Pico W as secondary target.** Works, but requires care:
|
||
- Keep all networking in the main asyncio event loop (no `_thread` for sockets)
|
||
- Use `asyncio.sleep_ms()` generously to keep the CYW43 stack serviced
|
||
|
||
**RPi 4B as tertiary.** Different code path (no MicroPython), but simplest to debug. Useful for validating protocol logic before deploying to microcontrollers.
|
||
|
||
---
|
||
|
||
## Packet Protocol Implementation
|
||
|
||
All packet formats come directly from `lightsync/protocol/drgb.py` and `lightsync/protocol/animation_cmd.py`. The firmware must decode these exactly.
|
||
|
||
### Packet Discriminator
|
||
|
||
First byte determines packet type:
|
||
|
||
| Byte 0 | Protocol | Action |
|
||
|--------|----------|--------|
|
||
| `0x02` | DRGB | RGB frame, 3 bytes/pixel from byte 2 onward |
|
||
| `0x03` | DRGBW | RGBW frame, 4 bytes/pixel from byte 2 onward |
|
||
| `0x04` | DNRGB | RGB frame with start index, 3 bytes/pixel from byte 4 onward |
|
||
| `0xAC` | Animation command | 16-byte packet, decode params |
|
||
|
||
### DRGB Decode (MicroPython)
|
||
|
||
```python
|
||
import struct
|
||
|
||
def parse_drgb(data):
|
||
# data[0] == 0x02, data[1] == timeout_seconds
|
||
led_count = (len(data) - 2) // 3
|
||
pixels = []
|
||
for i in range(led_count):
|
||
off = 2 + i * 3
|
||
pixels.append((data[off], data[off+1], data[off+2]))
|
||
return pixels
|
||
|
||
# Faster with memoryview to avoid allocation:
|
||
def parse_drgb_mv(data):
|
||
mv = memoryview(data)
|
||
n = (len(data) - 2) // 3
|
||
return [(mv[2+i*3], mv[2+i*3+1], mv[2+i*3+2]) for i in range(n)]
|
||
```
|
||
|
||
### DRGBW Decode
|
||
|
||
```python
|
||
def parse_drgbw(data):
|
||
led_count = (len(data) - 2) // 4
|
||
pixels = []
|
||
for i in range(led_count):
|
||
off = 2 + i * 4
|
||
pixels.append((data[off], data[off+1], data[off+2], data[off+3]))
|
||
return pixels
|
||
```
|
||
|
||
### DNRGB Decode (Multi-packet)
|
||
|
||
```python
|
||
def parse_dnrgb(data):
|
||
# 4-byte header: [0x04][timeout][start_hi][start_lo]
|
||
start_index = struct.unpack('>H', data[2:4])[0]
|
||
led_count = (len(data) - 4) // 3
|
||
pixels = []
|
||
for i in range(led_count):
|
||
off = 4 + i * 3
|
||
pixels.append((data[off], data[off+1], data[off+2]))
|
||
return start_index, pixels
|
||
```
|
||
|
||
### Animation Command Decode (exact MicroPython port)
|
||
|
||
```python
|
||
import struct
|
||
|
||
ANIMATION_NAMES = {
|
||
0: 'solid_color', 1: 'chase', 2: 'pulse',
|
||
3: 'rainbow', 4: 'strobe', 5: 'color_wipe', 6: 'fire'
|
||
}
|
||
|
||
def parse_animation_cmd(data):
|
||
# Byte 0: 0xAC marker, Byte 1: version, Byte 2: anim_id, Byte 3: flags
|
||
# Bytes 4-7: speed (float32 big-endian)
|
||
# Bytes 8-10: primary RGB, Bytes 11-13: bg RGB, Byte 14: white, Byte 15: density
|
||
if len(data) < 16 or data[0] != 0xAC:
|
||
return None
|
||
anim_id = data[2]
|
||
flags = data[3]
|
||
speed = struct.unpack('>f', data[4:8])[0]
|
||
return {
|
||
'animation': ANIMATION_NAMES.get(anim_id),
|
||
'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),
|
||
}
|
||
```
|
||
|
||
**Confidence: HIGH** — copied directly from `lightsync/protocol/animation_cmd.py` decode logic.
|
||
|
||
### Multi-packet Reassembly for DNRGB
|
||
|
||
For 300 LEDs with SK6812 RGBW — the strip type uses DRGBW (protocol `0x03`), not DNRGB. DRGBW for 300 LEDs = 2 + 300×4 = 1202 bytes, which fits in one UDP packet (max 1470 bytes). No reassembly needed.
|
||
|
||
For 300-LED DRGB (RGB): 2 + 300×3 = 902 bytes — also fits in one packet.
|
||
|
||
DNRGB reassembly is only needed for strips > 489 LEDs (RGB) or > 367 LEDs (RGBW). For this project's 300-LED constraint, **implement reassembly anyway** (firmware should handle any future strip size), but note it won't trigger in practice.
|
||
|
||
Reassembly pattern:
|
||
```python
|
||
frame_buffer = {} # start_index -> bytes
|
||
REASSEMBLY_TIMEOUT_MS = 200
|
||
|
||
def handle_dnrgb(data, frame_buf, expected_total):
|
||
start_idx, pixels = parse_dnrgb(data)
|
||
for i, px in enumerate(pixels):
|
||
frame_buf[start_idx + i] = px
|
||
if len(frame_buf) >= expected_total:
|
||
frame = [frame_buf[i] for i in range(expected_total)]
|
||
frame_buf.clear()
|
||
return frame
|
||
return None
|
||
```
|
||
|
||
---
|
||
|
||
## Animation Rendering Loop
|
||
|
||
### Architecture: Two asyncio Tasks
|
||
|
||
```python
|
||
async def main():
|
||
asyncio.create_task(udp_listener_task())
|
||
asyncio.create_task(animation_render_task())
|
||
await asyncio.sleep(float('inf'))
|
||
```
|
||
|
||
### Animation State Machine
|
||
|
||
The firmware receives an animation command (0xAC packet) and switches to that animation. It continues rendering that animation until a new command arrives. When a raw frame (DRGB/DRGBW) arrives, it switches to "frame passthrough mode" and stops the animation loop temporarily.
|
||
|
||
```python
|
||
import time
|
||
|
||
current_animation = None # dict with type + params
|
||
current_animation_start = 0
|
||
frame_override = None # set when raw frame received
|
||
|
||
async def animation_render_task():
|
||
global current_animation, frame_override
|
||
while True:
|
||
if frame_override is not None:
|
||
write_frame(frame_override)
|
||
frame_override = None
|
||
elif current_animation is not None:
|
||
t = (time.ticks_ms() - current_animation_start) / 1000.0
|
||
frame = render_animation(current_animation, t, LED_COUNT)
|
||
write_frame(frame)
|
||
await asyncio.sleep_ms(16) # ~60 fps
|
||
```
|
||
|
||
### Target Frame Rate
|
||
|
||
16 ms per frame = 62.5 fps. For 300 LEDs at 800 kHz, `np.write()` takes ~300 µs. The animation math for simple animations (solid, chase, strobe) is <1 ms on ESP32. Fire is ~2–3 ms due to the heat array loop. All animations fit within the 16 ms budget.
|
||
|
||
### `colorsys` is NOT Available in MicroPython
|
||
|
||
The `rainbow` animation uses `colorsys.hsv_to_rgb`. This module is not in the MicroPython standard library. Port inline:
|
||
|
||
```python
|
||
def hsv_to_rgb(h, s, v):
|
||
if s == 0.0:
|
||
return int(v*255), int(v*255), int(v*255)
|
||
i = int(h*6)
|
||
f = (h*6) - i
|
||
p = v*(1-s); q = v*(1-s*f); t = v*(1-s*(1-f))
|
||
i %= 6
|
||
if i == 0: return int(v*255), int(t*255), int(p*255)
|
||
if i == 1: return int(q*255), int(v*255), int(p*255)
|
||
if i == 2: return int(p*255), int(v*255), int(t*255)
|
||
if i == 3: return int(p*255), int(q*255), int(v*255)
|
||
if i == 4: return int(t*255), int(p*255), int(v*255)
|
||
return int(v*255), int(p*255), int(q*255)
|
||
```
|
||
|
||
### `math.sin` IS Available
|
||
|
||
`import math` works in MicroPython. The pulse animation's `math.sin` call ports directly.
|
||
|
||
### Fire Animation: Avoid List Allocation in Hot Loop
|
||
|
||
The Python `FireAnimation` builds a new list each frame. In MicroPython with limited heap, pre-allocate:
|
||
|
||
```python
|
||
_heat = bytearray(LED_COUNT) # use bytearray, not list of ints
|
||
_pixels = [(0,0,0)] * LED_COUNT # pre-allocated output
|
||
```
|
||
|
||
This avoids triggering the GC during animation rendering.
|
||
|
||
---
|
||
|
||
## Device Configuration Strategy
|
||
|
||
### Recommended: `config.json` on Flash
|
||
|
||
```python
|
||
import json
|
||
|
||
DEFAULT_CONFIG = {
|
||
'ssid': 'MyWiFi',
|
||
'password': 'secret',
|
||
'led_count': 300,
|
||
'strip_type': 'sk6812', # or 'ws2801'
|
||
'led_pin': 5, # GPIO pin for neopixel data
|
||
'spi_id': 1, # SPI bus for WS2801
|
||
'udp_port': 21324, # WLED default port
|
||
}
|
||
|
||
def load_config():
|
||
try:
|
||
with open('config.json') as f:
|
||
return json.load(f)
|
||
except OSError:
|
||
return DEFAULT_CONFIG
|
||
|
||
def save_config(cfg):
|
||
with open('config.json', 'w') as f:
|
||
json.dump(cfg, f)
|
||
```
|
||
|
||
`config.json` persists across power cycles on ESP32/Pico flash filesystem. This is the standard MicroPython pattern for device configuration.
|
||
|
||
### File Layout on Device
|
||
|
||
```
|
||
/ (MicroPython flash root)
|
||
├── boot.py # WiFi connect, GPIO init
|
||
├── main.py # asyncio.run(main())
|
||
├── config.json # device configuration
|
||
├── animations.py # all 7 animation renderers (single file)
|
||
├── protocol.py # packet parsers (DRGB/DRGBW/DNRGB/ANIM_CMD)
|
||
└── led_driver.py # strip abstraction (neopixel vs SPI)
|
||
```
|
||
|
||
Single-file approach is preferred for MicroPython — avoids import path complexity and reduces flash wear from module compilation.
|
||
|
||
### Config Fields Required
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `ssid` | str | WiFi network name |
|
||
| `password` | str | WiFi password |
|
||
| `led_count` | int | Number of LEDs (e.g., 300) |
|
||
| `strip_type` | str | `"sk6812"` or `"ws2801"` |
|
||
| `led_pin` | int | GPIO pin for neopixel data output |
|
||
| `spi_id` | int | SPI bus number for WS2801 (default 1) |
|
||
| `udp_port` | int | UDP listen port (default 21324) |
|
||
|
||
---
|
||
|
||
## Critical Risks
|
||
|
||
### Risk 1: neopixel Timing Conflicts with asyncio on ESP32
|
||
**What goes wrong:** `np.write()` uses the RMT peripheral on ESP32 and is internally non-blocking. However, if an interrupt or second task preempts in the middle of a 300-LED write sequence, timing violations can corrupt the WS2812/SK6812 signal.
|
||
**Mitigation:** Call `np.write()` only from the single animation render task. Never write from the UDP listener callback. MicroPython's asyncio is cooperative (not preemptive), so this is safe by default.
|
||
**Confidence:** HIGH — cooperative scheduling prevents concurrent strip writes.
|
||
|
||
### Risk 2: Heap Exhaustion During Animation Render
|
||
**What goes wrong:** Each call to `render_animation()` that creates a new list of 300 tuples allocates ~3 KB on the heap. At 60 fps this triggers GC frequently. GC pauses on ESP32 can be 10–50 ms, causing visible LED flicker.
|
||
**Mitigation:** Pre-allocate pixel buffers. Use `bytearray` for heat arrays. Reuse output lists by writing directly into a pre-allocated buffer. For the simple animations (solid, chase, strobe), this is straightforward. Fire needs a pre-allocated `_heat` bytearray.
|
||
**Confidence:** MEDIUM — may need profiling, but pre-allocation is standard MicroPython optimization.
|
||
|
||
### Risk 3: colorsys Unavailability Breaks Rainbow
|
||
**What goes wrong:** `from colorsys import hsv_to_rgb` raises ImportError on MicroPython.
|
||
**Mitigation:** Inline the HSV→RGB conversion (see code above). This is a definitive fix, not a workaround.
|
||
**Confidence:** HIGH — confirmed via MicroPython stdlib reference.
|
||
|
||
### Risk 4: WS2801 SPI Initialization Varies by Platform
|
||
**What goes wrong:** `machine.SPI(1)` constructor arguments differ between ESP32, Pico, and RPi. ESP32 uses `SPI(1, baudrate=..., sck=Pin(x), mosi=Pin(y))`, Pico uses `SPI(0, ...)` or `SPI(1, ...)`.
|
||
**Mitigation:** Make SPI pin configuration part of `config.json` (`spi_id`, `spi_clk_pin`, `spi_dat_pin`). Provide working defaults for ESP32 and Pico separately.
|
||
**Confidence:** HIGH — standard MicroPython practice.
|
||
|
||
### Risk 5: UDP Packet Loss at High Frame Rate
|
||
**What goes wrong:** At 60 fps, the server sends 60 DRGB packets/sec. If the microcontroller's WiFi stack drops packets during heavy NeoPixel write load, LEDs freeze briefly.
|
||
**Mitigation:** Frame mode is best-effort — missing a frame means the previous frame stays lit (visually acceptable). Animation command mode is much more resilient — one packet starts a locally-rendered animation that runs indefinitely without further network traffic.
|
||
**Confidence:** MEDIUM — depends on hardware, WiFi quality.
|
||
|
||
### Risk 6: Float32 Endianness in Animation Command Packet
|
||
**What goes wrong:** `struct.pack('>f', speed)` on the Python server produces big-endian float. `struct.unpack('>f', data[4:8])` must be used on the firmware side. If the wrong endianness format is used, speed will decode to garbage.
|
||
**Mitigation:** Explicitly use `'>f'` format in the MicroPython decoder. This is documented in the protocol spec (`animation_cmd.py` line 76: `struct.pack(">BBBBf", ...)`).
|
||
**Confidence:** HIGH — deterministic, testable.
|
||
|
||
---
|
||
|
||
## Recommended Approach
|
||
|
||
### Plan 07-01: Firmware Scaffold
|
||
|
||
1. `config.json` + `load_config()` on flash
|
||
2. WiFi connect in `boot.py` using `network.WLAN(network.STA_IF)`
|
||
3. Non-blocking UDP socket wrapped in `asyncio` task using `select.poll(0)` + `await asyncio.sleep_ms(5)`
|
||
4. Packet discriminator (check `data[0]` against protocol constants)
|
||
5. Strip abstraction: `LEDStrip` class with `write(pixels)` method, two subclasses: `NeoPixelStrip` (SK6812, bpp=4) and `WS2801Strip` (SPI)
|
||
6. Target platform: ESP32 as primary. Structure code so it also runs on Pico W with minor pin config changes.
|
||
|
||
### Plan 07-02: Animation Command Renderer
|
||
|
||
1. Port all 7 animations from `lightsync/animations/*.py` into a single `animations.py`
|
||
2. Key porting changes:
|
||
- Remove `from lightsync.animations.base import AnimationBase` (no ABCs in firmware)
|
||
- Replace `colorsys.hsv_to_rgb` with inline `_hsv_to_rgb()` for rainbow
|
||
- Replace `list[tuple]` type hints with plain comments (MicroPython handles fine but no type system needed)
|
||
- Pre-allocate `_heat = bytearray(led_count)` in fire animation
|
||
3. Animation state: `current_anim_name`, `current_params`, `anim_start_ms = time.ticks_ms()`
|
||
4. Render loop: `t = time.ticks_diff(time.ticks_ms(), anim_start_ms) / 1000.0`
|
||
5. On new animation command packet: update state, reset `anim_start_ms`
|
||
|
||
### Plan 07-03: Frame Mode Renderer
|
||
|
||
1. DRGB/DRGBW parser — extract pixel tuples from raw bytes
|
||
2. Direct write to strip: no intermediate Python list needed — index directly into `data` bytes
|
||
3. DNRGB reassembly: maintain `frame_buf = {}` dict indexed by LED position; write to strip when all expected LEDs received or timeout (200 ms)
|
||
4. Frame mode priority: raw frames override active animation; when frames stop arriving (timeout 2s), revert to last animation command
|
||
5. Performance optimization: write directly from `memoryview(data)` slice to strip buffer to avoid tuple allocation for 300 pixels
|
||
|
||
### Verification Strategy (no test runner on device)
|
||
|
||
- Use `mpremote` or Thonny REPL to run manual checks
|
||
- Protocol parser tests can be run on the host with regular Python (same code, no platform dependencies)
|
||
- Physical verification: send known test patterns from Python server using existing `UDPSender` → observe on real strip
|
||
|
||
---
|
||
|
||
## Sources
|
||
|
||
### Primary (HIGH confidence)
|
||
- [MicroPython neopixel docs](https://docs.micropython.org/en/latest/library/neopixel.html) — bpp=4, timing, write() API
|
||
- [MicroPython asyncio docs](https://docs.micropython.org/en/latest/library/asyncio.html) — confirmed no UDP support
|
||
- `lightsync/protocol/drgb.py` — exact byte layout for DRGB/DRGBW/DNRGB (source of truth)
|
||
- `lightsync/protocol/animation_cmd.py` — exact 16-byte animation command format (source of truth)
|
||
- `lightsync/animations/*.py` — all 7 animation implementations to port (source of truth)
|
||
|
||
### Secondary (MEDIUM confidence)
|
||
- [MicroPython asyncio UDP issue #13382](https://github.com/micropython/micropython/issues/13382) — confirmed no native asyncio UDP
|
||
- [ESP32 vs ESP32-S3 heap discussion](https://github.com/orgs/micropython/discussions/10528) — ~106 KB heap for ESP32 with WiFi
|
||
- [Pico W UDP recv buffer issue #13214](https://github.com/orgs/micropython/discussions/13214) — CYW43 threading caveat
|
||
- [HeMan/micropython-ws2801](https://github.com/HeMan/micropython-ws2801) — WS2801 SPI library for MicroPython
|
||
|
||
### Tertiary (LOW confidence)
|
||
- Various community forum posts on non-blocking socket patterns — cross-verified with official socket docs
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Confidence breakdown:**
|
||
- Protocol parsing: HIGH — byte formats taken directly from implemented source code
|
||
- LED driver APIs: HIGH — verified from official MicroPython documentation
|
||
- asyncio UDP workaround: MEDIUM — confirmed no native support, pattern from community discussions cross-verified with issue tracker
|
||
- Platform comparison: MEDIUM — heap numbers from one GitHub discussion thread, not official benchmarks
|
||
- Animation ports: HIGH — direct Python-to-MicroPython translation of existing code
|
||
|
||
**Research date:** 2026-04-07
|
||
**Valid until:** 2026-10-07 (MicroPython releases ~2x/year; asyncio UDP gap is stable)
|