2 Commits

Author SHA1 Message Date
794f1a2841 feat(patterns): add northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:32 +12:00
8f8bc894a9 feat(patterns): add icicles blizzard and rime winter effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:09:59 +12:00
14 changed files with 731 additions and 0 deletions

65
src/patterns/blizzard.py Normal file
View File

@@ -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 (0255 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

View File

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

View File

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

62
src/patterns/icicles.py Normal file
View File

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

View File

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

72
src/patterns/rime.py Normal file
View File

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

65
src/patterns/starfall.py Normal file
View File

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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

43
tests/patterns/icicles.py Normal file
View File

@@ -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()

View File

@@ -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()

43
tests/patterns/rime.py Normal file
View File

@@ -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()

View File

@@ -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()