From b7b0673b43f6da8b2ac533b8c8e0a7ae3733cfab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Apr 2026 12:16:29 +0000 Subject: [PATCH] docs(07): research microcontroller firmware phase --- .../07-RESEARCH.md | 560 ++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 .planning/phases/07-microcontroller-firmware/07-RESEARCH.md diff --git a/.planning/phases/07-microcontroller-firmware/07-RESEARCH.md b/.planning/phases/07-microcontroller-firmware/07-RESEARCH.md new file mode 100644 index 0000000..1e82b76 --- /dev/null +++ b/.planning/phases/07-microcontroller-firmware/07-RESEARCH.md @@ -0,0 +1,560 @@ +# 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)