From 794f1a28411b7725bc517e2a0224c24339ec46b3 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 16 May 2026 15:11:32 +1200 Subject: [PATCH] feat(patterns): add northern wave, candle glow, starfall, ice sparkle Co-authored-by: Cursor --- src/patterns/candle_glow.py | 56 ++++++++++++++++++++++++++ src/patterns/ice_sparkle.py | 69 +++++++++++++++++++++++++++++++++ src/patterns/northern_wave.py | 53 +++++++++++++++++++++++++ src/patterns/starfall.py | 65 +++++++++++++++++++++++++++++++ tests/patterns/candle_glow.py | 40 +++++++++++++++++++ tests/patterns/ice_sparkle.py | 40 +++++++++++++++++++ tests/patterns/northern_wave.py | 40 +++++++++++++++++++ tests/patterns/starfall.py | 40 +++++++++++++++++++ 8 files changed, 403 insertions(+) create mode 100644 src/patterns/candle_glow.py create mode 100644 src/patterns/ice_sparkle.py create mode 100644 src/patterns/northern_wave.py create mode 100644 src/patterns/starfall.py create mode 100644 tests/patterns/candle_glow.py create mode 100644 tests/patterns/ice_sparkle.py create mode 100644 tests/patterns/northern_wave.py create mode 100644 tests/patterns/starfall.py diff --git a/src/patterns/candle_glow.py b/src/patterns/candle_glow.py new file mode 100644 index 0000000..876dceb --- /dev/null +++ b/src/patterns/candle_glow.py @@ -0,0 +1,56 @@ +import random +import utime + + +class CandleGlow: + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(255, 140, 40), (255, 200, 120), (255, 90, 20)] + n_candles = max(1, min(self.driver.num_leds, int(preset.n1) if int(preset.n1) > 0 else 4)) + width = max(1, int(preset.n2) if int(preset.n2) > 0 else 3) + flicker = max(1, min(255, int(preset.n3) if int(preset.n3) > 0 else 90)) + n_led = self.driver.num_leds + centers = tuple(random.randint(0, max(0, n_led - 1)) for _ in range(n_candles)) + last = utime.ticks_ms() + + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(n_led): + self.driver.n[i] = bg + base_lo = 180 - flicker // 2 + if base_lo < 40: + base_lo = 40 + for ci, c in enumerate(centers): + warmth = colors[ci % len(colors)] + pulse = base_lo + random.randint(0, flicker) + if pulse > 255: + pulse = 255 + for off in range(-width, width + 1): + idx = c + off + if 0 <= idx < n_led: + dist = abs(off) + fall = ((width - dist + 1) * 256) // (width + 1) + fac = (fall * pulse) // 256 + px = ( + (warmth[0] * fac) // 255, + (warmth[1] * fac) // 255, + (warmth[2] * fac) // 255, + ) + lit = self.driver.apply_brightness(px, preset.b) + o = self.driver.n[idx] + self.driver.n[idx] = ( + max(o[0], lit[0]), + max(o[1], lit[1]), + max(o[2], lit[2]), + ) + self.driver.n.write() + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield diff --git a/src/patterns/ice_sparkle.py b/src/patterns/ice_sparkle.py new file mode 100644 index 0000000..5e3735b --- /dev/null +++ b/src/patterns/ice_sparkle.py @@ -0,0 +1,69 @@ +import random +import utime + + +class IceSparkle: + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(240, 248, 255), (200, 235, 255), (255, 255, 255)] + rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 55)) + decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 140)) + halo = max(0, min(3, int(preset.n3))) + sparks = [] + cap = 28 + last = utime.ticks_ms() + + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg + ns = [] + for s in sparks: + lv = s["lv"] - decay + if lv > 0: + s["lv"] = lv + ns.append(s) + sparks = ns + if len(sparks) < cap and random.randint(0, 255) < rate: + sparks.append( + { + "p": random.randint(0, max(0, self.driver.num_leds - 1)), + "lv": 255, + "ci": random.randint(0, len(colors) - 1), + } + ) + for s in sparks: + p = s["p"] + lv = s["lv"] + ci = s["ci"] + base = colors[ci] + for off in range(-halo, halo + 1): + idx = p + off + if 0 <= idx < self.driver.num_leds: + dist = abs(off) + fac = lv if dist == 0 else (lv * (halo - dist + 1)) // (halo + 1) + lit = self.driver.apply_brightness( + ( + (base[0] * fac) // 255, + (base[1] * fac) // 255, + (base[2] * fac) // 255, + ), + preset.b, + ) + o = self.driver.n[idx] + self.driver.n[idx] = ( + min(255, o[0] + lit[0]), + min(255, o[1] + lit[1]), + min(255, o[2] + lit[2]), + ) + self.driver.n.write() + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield diff --git a/src/patterns/northern_wave.py b/src/patterns/northern_wave.py new file mode 100644 index 0000000..11de78f --- /dev/null +++ b/src/patterns/northern_wave.py @@ -0,0 +1,53 @@ +import math +import utime + + +class NorthernWave: + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(20, 55, 120), (60, 140, 220), (180, 220, 255)] + period = max(4, int(preset.n1) if int(preset.n1) > 0 else 20) + contrast = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 200)) + drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 2) + phase = 0 + last = utime.ticks_ms() + ncols = len(colors) + if ncols < 2: + colors = list(colors) + [(120, 180, 255)] + ncols = len(colors) + twopi = 6.2831853 + + def lerp3(a, b, f): + return ( + a[0] + ((b[0] - a[0]) * f) // 255, + a[1] + ((b[1] - a[1]) * f) // 255, + a[2] + ((b[2] - a[2]) * f) // 255, + ) + + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + t = (i * twopi / period) + (phase * twopi / 256.0) + w = (math.sin(t) + 1.0) * 0.5 + u = w * (ncols - 1) * 256.0 + fi = int(u) >> 8 + frac = int(u) & 255 + if fi >= ncols - 1: + fi = ncols - 2 + frac = 255 + peak = lerp3(colors[fi], colors[fi + 1], frac) + peak = self.driver.apply_brightness(peak, preset.b) + mixf = min(255, int(w * contrast * 2) >> 1) + self.driver.n[i] = lerp3(bg, peak, mixf) + self.driver.n.write() + phase = (phase + drift) % 256 + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield diff --git a/src/patterns/starfall.py b/src/patterns/starfall.py new file mode 100644 index 0000000..28a41f5 --- /dev/null +++ b/src/patterns/starfall.py @@ -0,0 +1,65 @@ +import random +import utime + + +class Starfall: + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(255, 255, 255), (200, 230, 255), (255, 248, 220)] + rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 14)) + speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2) + tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10) + stars = [] + max_stars = 4 + last = utime.ticks_ms() + + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg + if len(stars) < max_stars and random.randint(0, 255) < rate: + top = self.driver.num_leds - 1 + random.randint(0, min(8, self.driver.num_leds // 2)) + stars.append( + { + "h": float(top), + "ci": random.randint(0, len(colors) - 1), + } + ) + ns = [] + for s in stars: + h = s["h"] + ci = s["ci"] + ih = int(h) + for t in range(tail): + idx = ih + t + if 0 <= idx < self.driver.num_leds: + fade = 255 - (t * 255 // max(1, tail - 1)) + base = colors[ci] + lit = ( + (base[0] * fade) // 255, + (base[1] * fade) // 255, + (base[2] * fade) // 255, + ) + lit = self.driver.apply_brightness(lit, preset.b) + o = self.driver.n[idx] + self.driver.n[idx] = ( + max(o[0], lit[0]), + max(o[1], lit[1]), + max(o[2], lit[2]), + ) + h -= speed + if h >= -tail: + s["h"] = h + ns.append(s) + stars = ns + self.driver.n.write() + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield diff --git a/tests/patterns/candle_glow.py b/tests/patterns/candle_glow.py new file mode 100644 index 0000000..59f7c7e --- /dev/null +++ b/tests/patterns/candle_glow.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets, run_tick + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + run_tick(p) + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) + wdt = WDT(timeout=10000) + + p.edit("test_candle_glow", { + "p": "candle_glow", + "b": 200, + "d": 60, + "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], + "n1": 4, + "n2": 2, + "n3": 120, + "a": True, + }) + p.select("test_candle_glow") + run_for(p, wdt, 3000) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 100) + + +if __name__ == "__main__": + main() diff --git a/tests/patterns/ice_sparkle.py b/tests/patterns/ice_sparkle.py new file mode 100644 index 0000000..41a5b7c --- /dev/null +++ b/tests/patterns/ice_sparkle.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets, run_tick + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + run_tick(p) + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) + wdt = WDT(timeout=10000) + + p.edit("test_ice_sparkle", { + "p": "ice_sparkle", + "b": 200, + "d": 60, + "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], + "n1": 4, + "n2": 2, + "n3": 120, + "a": True, + }) + p.select("test_ice_sparkle") + run_for(p, wdt, 3000) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 100) + + +if __name__ == "__main__": + main() diff --git a/tests/patterns/northern_wave.py b/tests/patterns/northern_wave.py new file mode 100644 index 0000000..b55b9b8 --- /dev/null +++ b/tests/patterns/northern_wave.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets, run_tick + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + run_tick(p) + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) + wdt = WDT(timeout=10000) + + p.edit("test_northern_wave", { + "p": "northern_wave", + "b": 200, + "d": 60, + "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], + "n1": 4, + "n2": 2, + "n3": 120, + "a": True, + }) + p.select("test_northern_wave") + run_for(p, wdt, 3000) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 100) + + +if __name__ == "__main__": + main() diff --git a/tests/patterns/starfall.py b/tests/patterns/starfall.py new file mode 100644 index 0000000..2a01ee8 --- /dev/null +++ b/tests/patterns/starfall.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets, run_tick + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + run_tick(p) + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) + wdt = WDT(timeout=10000) + + p.edit("test_starfall", { + "p": "starfall", + "b": 200, + "d": 60, + "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], + "n1": 4, + "n2": 2, + "n3": 120, + "a": True, + }) + p.select("test_starfall") + run_for(p, wdt, 3000) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 100) + + +if __name__ == "__main__": + main()