feat(07-02): add firmware animations.py with all 7 animation classes

- Port all 7 animations from lightsync/animations/*.py to MicroPython
- Inline _hsv_to_rgb (no colorsys in MicroPython)
- Use bytearray for FireAnimation heat buffer (avoids GC pressure)
- create_animation() factory maps parsed command dicts to instances
- No lightsync imports, no abc, no type hints
This commit is contained in:
Claude
2026-04-07 15:40:02 +00:00
parent 9a3370e71c
commit 053e1abd60

225
firmware/animations.py Normal file
View File

@@ -0,0 +1,225 @@
"""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