# 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)