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>
609 lines
22 KiB
Markdown
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.
|