docs(07): research microcontroller firmware phase

This commit is contained in:
Claude
2026-04-07 12:16:29 +00:00
parent 875be14a8d
commit b7b0673b43

View File

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