"""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