Files
led2/.planning/phases/07-microcontroller-firmware/07-02-PLAN.md
Claude 9a3370e71c docs(07): create phase 7 microcontroller firmware plans
Three sequential plans for MicroPython ESP32 firmware:
- 07-01: Scaffold (UDP listener, packet discriminator, LED drivers, config)
- 07-02: Animation renderer (all 7 types ported from lightsync)
- 07-03: Frame mode (DRGB/DRGBW/DNRGB parsing, reassembly, timeout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:22:49 +00:00

609 lines
22 KiB
Markdown

# Plan 07-02: Animation Command Renderer
**Phase**: 7 — Microcontroller Firmware
**Goal**: The firmware receives animation command packets (0xAC) and renders all 7 animation types locally on the LED strip at ~60fps.
## Prompt
This plan builds on 07-01 (complete). The firmware scaffold (`firmware/main.py`, `firmware/protocol.py`, `firmware/led_driver.py`) already exists with a working UDP listener and packet discriminator. Now add the animation rendering engine.
### File 1: `firmware/animations.py`
Create a single file containing all 7 animations ported from `lightsync/animations/*.py` to MicroPython. Key porting changes from the CPython originals:
1. **No imports from `lightsync`** — this is standalone MicroPython code
2. **No `abc` module** — use plain classes, no `AnimationBase` ABC
3. **No `colorsys`** — inline HSV-to-RGB for rainbow (MicroPython does not have `colorsys`)
4. **No type hints** — MicroPython handles them but they add no value on device
5. **Pre-allocate buffers** for fire animation to avoid GC pressure
6. **`time.ticks_ms()`** instead of `time.time()` for animation timing
Here is the exact code to create. Each animation is a direct port of the corresponding file in `lightsync/animations/`:
```python
"""All 7 LightSync animations for MicroPython firmware.
Ported from lightsync/animations/*.py — same math, MicroPython-compatible.
Each animation has render(t, led_count) -> list of (R,G,B) tuples.
"""
import math
import random
def _hsv_to_rgb(h, s, v):
"""HSV to RGB conversion — replaces colorsys.hsv_to_rgb (not in MicroPython)."""
if s == 0.0:
c = int(v * 255)
return c, c, c
i = int(h * 6.0)
f = (h * 6.0) - i
p = v * (1.0 - s)
q = v * (1.0 - s * f)
t = v * (1.0 - s * (1.0 - f))
i = 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)
class SolidColorAnimation:
def __init__(self, color, white=0):
self.color = tuple(color)
self.white = white
def render(self, t, led_count):
return [self.color] * led_count
class ChaseAnimation:
def __init__(self, color, bg_color, speed, size, spacing, reverse, white):
self.color = tuple(color)
self.bg_color = tuple(bg_color)
self.speed = speed
self.size = size
self.spacing = spacing
self.reverse = reverse
self.white = white
def render(self, t, led_count):
period = self.size + self.spacing
offset = int(t * self.speed * 60)
pixels = []
for i in range(led_count):
if self.reverse:
pos = (i + offset) % period
else:
pos = (i - offset) % period
if pos < self.size:
pixels.append(self.color)
else:
pixels.append(self.bg_color)
return pixels
class PulseAnimation:
def __init__(self, color, speed, min_brightness, max_brightness, white):
self.color = tuple(color)
self.speed = speed
self.min_brightness = min_brightness
self.max_brightness = max_brightness
self.white = white
def render(self, t, led_count):
period = 0.1 + (1.0 - self.speed) * 4.9
brightness = (
self.min_brightness
+ (self.max_brightness - self.min_brightness)
* (0.5 + 0.5 * math.sin(2 * math.pi * t / period))
)
scale = brightness / 255.0
r = min(255, max(0, int(self.color[0] * scale)))
g = min(255, max(0, int(self.color[1] * scale)))
b = min(255, max(0, int(self.color[2] * scale)))
pixel = (r, g, b)
return [pixel] * led_count
class RainbowAnimation:
def __init__(self, speed, period):
self.speed = speed
self.period = period
def render(self, t, led_count):
pixels = []
for i in range(led_count):
hue = (i / led_count / self.period + t * self.speed) % 1.0
r, g, b = _hsv_to_rgb(hue, 1.0, 1.0)
pixels.append((r, g, b))
return pixels
class StrobeAnimation:
def __init__(self, color, speed, duty_cycle, white):
self.color = tuple(color)
self.speed = speed
self.duty_cycle = duty_cycle
self.white = white
def render(self, t, led_count):
freq = 1.0 + self.speed * 19.0
phase = (t * freq) % 1.0
if phase < self.duty_cycle:
pixel = self.color
else:
pixel = (0, 0, 0)
return [pixel] * led_count
class ColorWipeAnimation:
def __init__(self, color, speed, reverse, white):
self.color = tuple(color)
self.speed = speed
self.reverse = reverse
self.white = white
def render(self, t, led_count):
fill_count = min(led_count, int(t * self.speed * 60))
black = (0, 0, 0)
if not self.reverse:
pixels = [self.color] * fill_count + [black] * (led_count - fill_count)
else:
pixels = [black] * (led_count - fill_count) + [self.color] * fill_count
return pixels
class FireAnimation:
def __init__(self, cooling, sparking, speed):
self.cooling = cooling
self.sparking = sparking
self.speed = speed
self._heat = None # bytearray, lazy-init
self._led_count = 0
def render(self, t, led_count):
# Lazy-init or resize heat array (bytearray to avoid GC pressure)
if self._heat is None or self._led_count != led_count:
self._heat = bytearray(led_count)
self._led_count = led_count
steps = max(1, int(self.speed * 3))
heat = self._heat
for _ in range(steps):
# Step 1: Cool every cell
for i in range(led_count):
cooldown = random.randint(0, (self.cooling * 10 // led_count) + 2)
v = heat[i] - cooldown
heat[i] = v if v > 0 else 0
# Step 2: Heat drifts upward
for i in range(led_count - 1, 1, -1):
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) // 3
# Step 3: Randomly ignite sparks near bottom
if random.randint(0, 255) < self.sparking:
y = random.randint(0, min(6, led_count - 1))
v = heat[y] + random.randint(160, 255)
heat[y] = v if v < 255 else 255
# Map heat to color
pixels = []
for i in range(led_count):
h = heat[i]
if h < 85:
pixels.append((h * 3, 0, 0))
elif h < 170:
h2 = h - 85
pixels.append((255, h2 * 3, 0))
else:
h2 = h - 170
c = h2 * 3
pixels.append((255, 255, c if c < 255 else 255))
return pixels
# Animation factory — maps names to constructors with default params
def create_animation(cmd):
"""Create animation instance from parsed animation command dict.
Args:
cmd: dict from parse_animation_cmd() with keys:
animation, speed, color, bg_color, white, density, reverse
Returns:
Animation instance with render(t, led_count) method.
"""
name = cmd['animation']
color = cmd['color'] # (R, G, B) tuple
bg_color = cmd['bg_color'] # (R, G, B) tuple
speed = cmd['speed']
white = cmd['white']
density = cmd['density']
reverse = cmd['reverse']
if name == 'solid_color':
return SolidColorAnimation(color, white)
elif name == 'chase':
return ChaseAnimation(color, bg_color, speed, density or 3, 7, reverse, white)
elif name == 'pulse':
return PulseAnimation(color, speed, 0, 255, white)
elif name == 'rainbow':
return RainbowAnimation(speed, 1.0)
elif name == 'strobe':
return StrobeAnimation(color, speed, 0.1, white)
elif name == 'color_wipe':
return ColorWipeAnimation(color, speed, reverse, white)
elif name == 'fire':
return FireAnimation(density or 55, 120, speed)
else:
print('[anim] Unknown animation:', name)
return SolidColorAnimation(color, white) # fallback
```
**IMPORTANT**: The `chase` animation's `density` field maps to `size` (per the animation_cmd.py protocol where byte 15 is "density/size/cooling"). For `fire`, `density` maps to `cooling`. For `chase`, if density is 0 (default), use 3 as the default size.
### File 2: Update `firmware/main.py`
Modify `main.py` to wire the animation rendering into the render loop. Changes:
1. Add import: `from animations import create_animation`
2. Add a global: `current_anim_instance = None` (the animation object, not just the raw cmd dict)
3. In the `udp_listener_task`, when an animation command is received, create the animation instance:
```python
current_animation = cmd
current_anim_instance = create_animation(cmd)
current_mode = 'animation'
animation_start_ms = time.ticks_ms()
```
4. In `render_task`, fill in the animation branch:
```python
if current_mode == 'animation' and current_anim_instance:
t = time.ticks_diff(time.ticks_ms(), animation_start_ms) / 1000.0
pixels = current_anim_instance.render(t, config['led_count'])
strip.write_rgb(pixels)
```
The complete updated `main.py` should look like:
```python
import asyncio
import socket
import select
import time
import json
from protocol import classify_packet, parse_animation_cmd
from led_driver import create_strip
from animations import create_animation
# --- Global state ---
config = None
strip = None
current_mode = 'idle' # 'idle', 'animation', 'frame'
current_animation = None # dict from parse_animation_cmd
current_anim_instance = None # animation object with render()
animation_start_ms = 0
frame_pixels = None # set by frame packets
def load_config():
try:
with open('config.json') as f:
return json.load(f)
except OSError:
return {'led_count': 300, 'strip_type': 'sk6812', 'led_pin': 5,
'spi_id': 1, 'udp_port': 21324}
async def udp_listener_task(port):
"""Non-blocking UDP listener using select.poll(0) inside asyncio."""
global current_mode, current_animation, current_anim_instance
global animation_start_ms, frame_pixels
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)
print('[udp] Listening on port', port)
while True:
events = poller.poll(0)
if events:
try:
data, addr = sock.recvfrom(1500)
ptype = classify_packet(data)
if ptype == 'anim_cmd':
cmd = parse_animation_cmd(data)
if cmd:
current_animation = cmd
current_anim_instance = create_animation(cmd)
current_mode = 'animation'
animation_start_ms = time.ticks_ms()
print('[udp] Animation:', cmd['animation'])
elif ptype in ('drgb', 'drgbw', 'dnrgb'):
# Frame handling added by Plan 07-03
current_mode = 'frame'
print('[udp] Frame packet:', ptype, len(data), 'bytes')
else:
print('[udp] Unknown packet type:', data[0] if data else '?')
except OSError:
pass
await asyncio.sleep_ms(5)
async def render_task():
"""Animation render loop at ~60fps."""
global current_mode, frame_pixels
while True:
if current_mode == 'animation' and current_anim_instance:
t = time.ticks_diff(time.ticks_ms(), animation_start_ms) / 1000.0
pixels = current_anim_instance.render(t, config['led_count'])
strip.write_rgb(pixels)
elif current_mode == 'frame' and frame_pixels:
# Plan 07-03 adds frame write logic here
pass
await asyncio.sleep_ms(16) # ~60 fps
async def main():
global config, strip
config = load_config()
strip = create_strip(config)
strip.clear()
print('[main] LightSync firmware ready')
print('[main] Strip:', config['strip_type'], '/', config['led_count'], 'LEDs')
asyncio.create_task(udp_listener_task(config['udp_port']))
asyncio.create_task(render_task())
while True:
await asyncio.sleep(1)
asyncio.run(main())
```
### File 3: `tests/test_firmware_animations.py`
Create host-side tests that verify the MicroPython animation code produces correct output. These tests import `firmware/animations.py` directly (it uses only `math` and `random`, both available in CPython).
```python
"""Host-side tests for firmware animation renderers.
Verifies that the MicroPython animation ports produce correct output.
Run with: python -m pytest tests/test_firmware_animations.py -v
"""
import sys
import os
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'firmware'))
from animations import (
SolidColorAnimation, ChaseAnimation, PulseAnimation, RainbowAnimation,
StrobeAnimation, ColorWipeAnimation, FireAnimation, create_animation,
_hsv_to_rgb
)
class TestHSVtoRGB:
def test_red(self):
r, g, b = _hsv_to_rgb(0.0, 1.0, 1.0)
assert (r, g, b) == (255, 0, 0)
def test_green(self):
r, g, b = _hsv_to_rgb(1/3, 1.0, 1.0)
assert r == 0 and g == 255 and b == 0
def test_blue(self):
r, g, b = _hsv_to_rgb(2/3, 1.0, 1.0)
assert r == 0 and g == 0 and b == 255
def test_white(self):
r, g, b = _hsv_to_rgb(0.0, 0.0, 1.0)
assert (r, g, b) == (255, 255, 255)
def test_black(self):
r, g, b = _hsv_to_rgb(0.0, 0.0, 0.0)
assert (r, g, b) == (0, 0, 0)
class TestSolidColor:
def test_uniform(self):
anim = SolidColorAnimation((255, 0, 0))
pixels = anim.render(0.0, 10)
assert len(pixels) == 10
assert all(p == (255, 0, 0) for p in pixels)
def test_time_independent(self):
anim = SolidColorAnimation((0, 255, 0))
assert anim.render(0.0, 5) == anim.render(100.0, 5)
class TestChase:
def test_length(self):
anim = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0)
pixels = anim.render(0.0, 30)
assert len(pixels) == 30
def test_has_both_colors(self):
anim = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0)
pixels = anim.render(0.0, 30)
assert (255, 0, 0) in pixels
assert (0, 0, 0) in pixels
def test_reverse_differs(self):
fwd = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, False, 0)
rev = ChaseAnimation((255, 0, 0), (0, 0, 0), 0.5, 3, 7, True, 0)
# At t=0.3 (not a multiple of period), forward and reverse differ
assert fwd.render(0.3, 20) != rev.render(0.3, 20)
class TestPulse:
def test_length(self):
anim = PulseAnimation((255, 255, 255), 0.5, 0, 255, 0)
pixels = anim.render(0.0, 10)
assert len(pixels) == 10
def test_values_in_range(self):
anim = PulseAnimation((255, 128, 64), 0.5, 0, 255, 0)
for t in [0.0, 0.5, 1.0, 2.5]:
pixels = anim.render(t, 10)
for r, g, b in pixels:
assert 0 <= r <= 255
assert 0 <= g <= 255
assert 0 <= b <= 255
class TestRainbow:
def test_length(self):
anim = RainbowAnimation(0.5, 1.0)
pixels = anim.render(0.0, 10)
assert len(pixels) == 10
def test_distinct_hues(self):
anim = RainbowAnimation(0.5, 1.0)
pixels = anim.render(0.0, 10)
# All 10 pixels should have distinct colors
unique = set(pixels)
assert len(unique) == 10
def test_values_in_range(self):
anim = RainbowAnimation(0.5, 1.0)
pixels = anim.render(0.0, 300)
for r, g, b in pixels:
assert 0 <= r <= 255
assert 0 <= g <= 255
assert 0 <= b <= 255
class TestStrobe:
def test_length(self):
anim = StrobeAnimation((255, 255, 255), 0.5, 0.5, 0)
pixels = anim.render(0.0, 10)
assert len(pixels) == 10
def test_on_phase(self):
# At t=0.0, phase = 0.0 < duty_cycle=0.5, should be ON
anim = StrobeAnimation((255, 255, 255), 0.5, 0.5, 0)
pixels = anim.render(0.0, 5)
assert all(p == (255, 255, 255) for p in pixels)
class TestColorWipe:
def test_length(self):
anim = ColorWipeAnimation((255, 0, 0), 0.5, False, 0)
pixels = anim.render(0.0, 10)
assert len(pixels) == 10
def test_progressive(self):
anim = ColorWipeAnimation((255, 0, 0), 1.0, False, 0)
p1 = anim.render(0.5, 100)
p2 = anim.render(1.0, 100)
filled1 = sum(1 for p in p1 if p == (255, 0, 0))
filled2 = sum(1 for p in p2 if p == (255, 0, 0))
assert filled2 >= filled1
def test_at_zero(self):
anim = ColorWipeAnimation((255, 0, 0), 0.5, False, 0)
pixels = anim.render(0.0, 10)
assert all(p == (0, 0, 0) for p in pixels)
class TestFire:
def test_length(self):
anim = FireAnimation(55, 120, 0.5)
pixels = anim.render(0.0, 30)
assert len(pixels) == 30
def test_values_in_range(self):
anim = FireAnimation(55, 120, 0.5)
# Run several frames to build up heat
for i in range(10):
pixels = anim.render(i * 0.016, 30)
for r, g, b in pixels:
assert 0 <= r <= 255
assert 0 <= g <= 255
assert 0 <= b <= 255
def test_uses_bytearray(self):
anim = FireAnimation(55, 120, 0.5)
anim.render(0.0, 30)
assert isinstance(anim._heat, bytearray)
class TestCreateAnimation:
def test_all_types(self):
for name in ['solid_color', 'chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire']:
cmd = {
'animation': name, 'speed': 0.5,
'color': (255, 0, 0), 'bg_color': (0, 0, 0),
'white': 0, 'density': 0, 'reverse': False
}
anim = create_animation(cmd)
pixels = anim.render(0.5, 10)
assert len(pixels) == 10
def test_unknown_falls_back(self):
cmd = {
'animation': 'nonexistent', 'speed': 0.5,
'color': (255, 0, 0), 'bg_color': (0, 0, 0),
'white': 0, 'density': 0, 'reverse': False
}
anim = create_animation(cmd)
# Falls back to SolidColor
assert isinstance(anim, SolidColorAnimation)
class TestRoundTripWithServerEncoders:
"""Verify firmware animations can be created from server-encoded packets."""
def test_encode_decode_create_render(self):
"""Full round-trip: server encode -> firmware decode -> create animation -> render."""
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from lightsync.protocol.animation_cmd import encode_animation_cmd
# Also need firmware protocol parser
from protocol import parse_animation_cmd as fw_parse
for anim_name in ['solid_color', 'chase', 'pulse', 'rainbow', 'strobe', 'color_wipe', 'fire']:
params = {'color': [255, 128, 0], 'speed': 0.7, 'white': 32}
packet = encode_animation_cmd(anim_name, params)
cmd = fw_parse(packet)
assert cmd is not None
anim = create_animation(cmd)
pixels = anim.render(0.5, 10)
assert len(pixels) == 10
```
## Verification
- [ ] `firmware/animations.py` exists with all 7 animation classes + `_hsv_to_rgb` + `create_animation` factory
- [ ] `python -m pytest tests/test_firmware_animations.py -v` passes all tests
- [ ] `_hsv_to_rgb` produces correct values for primary colors (red=0.0, green=0.333, blue=0.667)
- [ ] Fire animation uses `bytearray` for heat buffer (not `list`)
- [ ] Rainbow animation uses inline `_hsv_to_rgb` (no `colorsys` import)
- [ ] `create_animation()` handles all 7 animation names from parsed command dicts
- [ ] Full round-trip test passes: server `encode_animation_cmd` -> firmware `parse_animation_cmd` -> `create_animation` -> `render` -> correct pixel output
- [ ] `firmware/main.py` is updated to call `create_animation()` on anim_cmd packets and `render()` in the render loop
- [ ] No `colorsys`, no `abc`, no `lightsync` imports in any `firmware/` file
## Notes
- The animation math is identical to `lightsync/animations/*.py` — same formulas, same defaults. The only changes are: removing type hints, removing ABC inheritance, inlining HSV conversion, and using `bytearray` for fire heat.
- The `density` field from the protocol packet (byte 15) maps to different params per animation: `size` for chase, `cooling` for fire, ignored for others. The `create_animation()` factory handles this mapping.
- All animations output `(R,G,B)` tuples even for SK6812 RGBW strips. The `NeoPixelStrip.write_rgb()` driver method appends W=0 automatically. This is correct — animations operate in RGB space. The `white` field from the command is stored but not used in rendering (it's available for future RGBW-native animations).
- `time.ticks_ms()` and `time.ticks_diff()` are MicroPython-specific but `time.ticks_ms` is not available in CPython. The host tests only test the animation `render()` method which takes `t` as a float parameter — no `ticks_ms` dependency in the tested code path.