24 KiB
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 withsocket.setblocking(False)combined withselect.poll()inside anasynciotask that yields viaawait asyncio.sleep_ms(N)— this keeps the event loop alive while polling for packets. - SK6812 (RGBW) works with the built-in
neopixelmodule usingbpp=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_ws281xfor WS281x/SK6812 andspidev+Adafruit_WS2801for WS2801. UDP is trivial with the stdlibsocketmodule. - 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/*.pydirectly —math.sin,mathmodule available.colorsysis 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
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:
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:
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)
Key points:
bpp=4for RGBW,bpp=3(default) for RGBtiming=1for 800 kHz (SK6812 default),timing=0for 400 kHznp.fill((r, g, b, w))sets all pixels uniformlynp.write()flushes to strip (blocks briefly — ~300 µs for 300 LEDs at 800 kHz)
WS2812B (RGB, reference) — Same Module, bpp=3
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)
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)
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 |
# 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:
- WiFi is integrated and battle-tested in MicroPython (unlike Pico W's CYW43 threading quirks)
- Hardware RMT peripheral handles neopixel timing precisely without blocking the CPU
- ~106 KB heap comfortably holds 300-LED frame buffers (1200 bytes = 1% of heap)
- 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
_threadfor 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)
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
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)
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)
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:
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
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.
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:
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:
_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
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
config.json+load_config()on flash- WiFi connect in
boot.pyusingnetwork.WLAN(network.STA_IF) - Non-blocking UDP socket wrapped in
asynciotask usingselect.poll(0)+await asyncio.sleep_ms(5) - Packet discriminator (check
data[0]against protocol constants) - Strip abstraction:
LEDStripclass withwrite(pixels)method, two subclasses:NeoPixelStrip(SK6812, bpp=4) andWS2801Strip(SPI) - 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
- Port all 7 animations from
lightsync/animations/*.pyinto a singleanimations.py - Key porting changes:
- Remove
from lightsync.animations.base import AnimationBase(no ABCs in firmware) - Replace
colorsys.hsv_to_rgbwith 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
- Remove
- Animation state:
current_anim_name,current_params,anim_start_ms = time.ticks_ms() - Render loop:
t = time.ticks_diff(time.ticks_ms(), anim_start_ms) / 1000.0 - On new animation command packet: update state, reset
anim_start_ms
Plan 07-03: Frame Mode Renderer
- DRGB/DRGBW parser — extract pixel tuples from raw bytes
- Direct write to strip: no intermediate Python list needed — index directly into
databytes - DNRGB reassembly: maintain
frame_buf = {}dict indexed by LED position; write to strip when all expected LEDs received or timeout (200 ms) - Frame mode priority: raw frames override active animation; when frames stop arriving (timeout 2s), revert to last animation command
- 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
mpremoteor 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 — bpp=4, timing, write() API
- MicroPython asyncio docs — 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 — confirmed no native asyncio UDP
- ESP32 vs ESP32-S3 heap discussion — ~106 KB heap for ESP32 with WiFi
- Pico W UDP recv buffer issue #13214 — CYW43 threading caveat
- 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)