From 8f8bc894a9fc3b4416b6eb92c292591030fb2f28 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 16 May 2026 15:09:59 +1200 Subject: [PATCH] feat(patterns): add icicles blizzard and rime winter effects Co-authored-by: Cursor --- src/patterns/blizzard.py | 65 ++++++++++++++++++++++++++++++++++ src/patterns/icicles.py | 62 ++++++++++++++++++++++++++++++++ src/patterns/rime.py | 72 ++++++++++++++++++++++++++++++++++++++ tests/patterns/blizzard.py | 43 +++++++++++++++++++++++ tests/patterns/icicles.py | 43 +++++++++++++++++++++++ tests/patterns/rime.py | 43 +++++++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 src/patterns/blizzard.py create mode 100644 src/patterns/icicles.py create mode 100644 src/patterns/rime.py create mode 100644 tests/patterns/blizzard.py create mode 100644 tests/patterns/icicles.py create mode 100644 tests/patterns/rime.py diff --git a/src/patterns/blizzard.py b/src/patterns/blizzard.py new file mode 100644 index 0000000..a04849a --- /dev/null +++ b/src/patterns/blizzard.py @@ -0,0 +1,65 @@ +import random +import utime + + +class Blizzard: + """Dense falling flakes with sideways drift (compare `snowfall` for gentler flakes).""" + + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(255, 255, 255), (200, 230, 255), (180, 210, 255)] + # Higher n1 → more spawns (0–255 threshold vs random) + density = max(1, int(preset.n1) if int(preset.n1) > 0 else 90) + speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2) + # n3: 128 = no bias; <128 drift one way, >128 the other (scaled to small steps) + wraw = int(preset.n3) + if wraw <= 0: + wind = 0 + else: + wind = max(-4, min(4, (wraw - 128) // 20)) + + flakes = [] + last = utime.ticks_ms() + + while True: + d_ms = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d_ms: + nled = self.driver.num_leds + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + + for i in range(nled): + self.driver.n[i] = bg + + if random.randint(0, 255) < density: + flakes.append( + [ + nled - 1, + random.randint(0, len(colors) - 1), + 0 if wind == 0 else random.randint(-1, 1), + ] + ) + + nf = [] + for pos, ci, wj in flakes: + p = pos + lateral = wind + (wj if wj else 0) + p -= speed + p += lateral + if p < -2 or p >= nled + 2: + continue + pi = max(0, min(nled - 1, int(p))) + self.driver.n[pi] = self.driver.apply_brightness(colors[ci], preset.b) + nf.append([p, ci, wj]) + flakes = nf + + self.driver.n.write() + last = utime.ticks_add(last, d_ms) + + if not preset.a: + yield + return + + yield diff --git a/src/patterns/icicles.py b/src/patterns/icicles.py new file mode 100644 index 0000000..23071c7 --- /dev/null +++ b/src/patterns/icicles.py @@ -0,0 +1,62 @@ +import utime + + +class Icicles: + """Icicles hanging from anchor points; tips brighten toward max length then shrink.""" + + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(240, 248, 255), (160, 210, 255), (255, 255, 255)] + spacing = max(1, int(preset.n1) if int(preset.n1) > 0 else 12) + nled = self.driver.num_leds + max_len = max( + 2, + min( + int(preset.n2) if int(preset.n2) > 0 else min(14, max(3, nled // 4)), + max(2, nled), + ), + ) + span = max_len * 2 + phase_step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1) + phase = 0 + last = utime.ticks_ms() + + while True: + d_ms = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d_ms: + bg_rgb = preset.background_or(colors) + bg = self.driver.apply_brightness(bg_rgb, preset.b) + + for i in range(nled): + self.driver.n[i] = bg + + aidx = 0 + for anchor in range(0, nled, spacing): + tri_i = (phase + aidx * 5) % span + ic_len = tri_i if tri_i <= max_len else span - tri_i + tip_c = colors[aidx % len(colors)] + tip = self.driver.apply_brightness(tip_c, preset.b) + for k in range(ic_len): + idx = anchor + k + if idx >= nled: + break + br = ((k + 1) * 255) // max(1, ic_len) + self.driver.n[idx] = ( + (tip[0] * br + bg[0] * (255 - br)) // 255, + (tip[1] * br + bg[1] * (255 - br)) // 255, + (tip[2] * br + bg[2] * (255 - br)) // 255, + ) + aidx += 1 + + self.driver.n.write() + phase = (phase + phase_step) % span + last = utime.ticks_add(last, d_ms) + + if not preset.a: + yield + return + + yield diff --git a/src/patterns/rime.py b/src/patterns/rime.py new file mode 100644 index 0000000..b3741e0 --- /dev/null +++ b/src/patterns/rime.py @@ -0,0 +1,72 @@ +import random +import utime + + +class Rime: + """Slow frost build-up on a chilly background — gentle random brightening then decay.""" + + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + colors = preset.c if preset.c else [(220, 235, 255), (255, 255, 255), (185, 220, 255)] + num = self.driver.num_leds + if num <= 0: + while True: + yield + return + + # n1: spawn tendency (like twinkle upper range) + chill = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 36)) + # n2: decay per refresh (subtract from glow buffer) + melt = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 12)) + # n3: how many LEDs can flash brighter per refresh (cap) + spark_cap = max(1, min(num, int(preset.n3) if int(preset.n3) > 0 else 3)) + + glow = [0] * num + last = utime.ticks_ms() + + while True: + d_ms = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d_ms: + base_bg = preset.background_or(colors) + bg = self.driver.apply_brightness(base_bg, preset.b) + + for i in range(num): + if glow[i] > melt: + glow[i] -= melt + else: + glow[i] = 0 + + spawned = 0 + tries = spark_cap + num // 8 + for _ in range(tries): + if spawned >= spark_cap: + break + if random.randint(0, 255) >= chill: + continue + j = random.randint(0, num - 1) + glow[j] = min(255, glow[j] + random.randint(80, 200)) + spawned += 1 + + palette = colors + for i in range(num): + g = glow[i] + fg = palette[i % len(palette)] + hi = self.driver.apply_brightness(fg, preset.b) + mix = max(0, min(255, g)) + self.driver.n[i] = ( + (hi[0] * mix + bg[0] * (255 - mix)) // 255, + (hi[1] * mix + bg[1] * (255 - mix)) // 255, + (hi[2] * mix + bg[2] * (255 - mix)) // 255, + ) + + self.driver.n.write() + last = utime.ticks_add(last, d_ms) + + if not preset.a: + yield + return + + yield diff --git a/tests/patterns/blizzard.py b/tests/patterns/blizzard.py new file mode 100644 index 0000000..fb94007 --- /dev/null +++ b/tests/patterns/blizzard.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + p.tick() + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 48)) + wdt = WDT(timeout=10000) + + p.edit( + "test_blizzard", + { + "p": "blizzard", + "b": 220, + "d": 40, + "c": [(255, 255, 255), (200, 220, 255)], + "n1": 100, + "n2": 2, + "n3": 150, + "a": True, + }, + ) + p.select("test_blizzard") + run_for(p, wdt, 2500) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 80) + + +if __name__ == "__main__": + main() diff --git a/tests/patterns/icicles.py b/tests/patterns/icicles.py new file mode 100644 index 0000000..d7fbe63 --- /dev/null +++ b/tests/patterns/icicles.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + p.tick() + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 40)) + wdt = WDT(timeout=10000) + + p.edit( + "test_icicles", + { + "p": "icicles", + "b": 220, + "d": 50, + "c": [(200, 230, 255), (255, 255, 255)], + "n1": 10, + "n2": 8, + "n3": 1, + "a": True, + }, + ) + p.select("test_icicles") + run_for(p, wdt, 2500) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 80) + + +if __name__ == "__main__": + main() diff --git a/tests/patterns/rime.py b/tests/patterns/rime.py new file mode 100644 index 0000000..fe07aae --- /dev/null +++ b/tests/patterns/rime.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import utime +from machine import WDT +from settings import Settings +from presets import Presets + + +def run_for(p, wdt, ms): + start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), start) < ms: + wdt.feed() + p.tick() + utime.sleep_ms(10) + + +def main(): + s = Settings() + p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 40)) + wdt = WDT(timeout=10000) + + p.edit( + "test_rime", + { + "p": "rime", + "b": 200, + "d": 80, + "c": [(240, 248, 255), (255, 255, 255)], + "n1": 48, + "n2": 14, + "n3": 4, + "a": True, + }, + ) + p.select("test_rime") + run_for(p, wdt, 2800) + + p.edit("cleanup_off", {"p": "off"}) + p.select("cleanup_off") + run_for(p, wdt, 80) + + +if __name__ == "__main__": + main()