Files

24 KiB
Raw Permalink Blame History

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.

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=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

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 kHz2 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:

  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)

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 ~23 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

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 1050 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.


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

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)