diff --git a/firmware/animations.py b/firmware/animations.py new file mode 100644 index 0000000..806ad0f --- /dev/null +++ b/firmware/animations.py @@ -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