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