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