Files

561 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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` |
```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 ~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:
```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 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.
---
## 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)