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:
225
firmware/animations.py
Normal file
225
firmware/animations.py
Normal 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
|
||||
Reference in New Issue
Block a user