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

22 KiB

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

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

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

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