From a34218763577cc3e54c7cc3e9a36dde7358002fe Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 21 Apr 2026 21:48:42 +1200 Subject: [PATCH] feat(patterns): add twinkle pattern defaults Made-with: Cursor --- presets.json | 1 + src/patterns/twinkle.py | 227 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 presets.json create mode 100644 src/patterns/twinkle.py diff --git a/presets.json b/presets.json new file mode 100644 index 0000000..2fe76af --- /dev/null +++ b/presets.json @@ -0,0 +1 @@ +{"15": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 500}, "40": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 0]], "b": 255, "n2": 2600, "n1": 35, "p": "flame", "n3": 0, "d": 50}, "41": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[120, 200, 255], [80, 140, 255], [180, 120, 255], [100, 220, 232], [160, 200, 255]], "b": 255, "n2": 10, "n1": 72, "p": "twinkle", "n3": 5, "d": 500}, "42": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[166, 0, 255], [0, 10, 10]], "b": 255, "n2": 900, "n1": 30, "p": "radiate", "n3": 4000, "d": 5000}, "6": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 255, 0]], "b": 255, "n2": 500, "n1": 1000, "p": "pulse", "n3": 1000, "d": 500}, "10": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[230, 242, 255]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "13": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 1, "p": "rainbow", "n3": 0, "d": 150}, "3": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 2, "p": "rainbow", "n3": 0, "d": 100}, "2": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 0, "n2": 0, "n1": 0, "p": "off", "n3": 0, "d": 100}, "38": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 1, "p": "colour_cycle", "n3": 0, "d": 100}, "11": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "12": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "1": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "9": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 245, 230]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "8": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 1000}, "39": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 184, 77]], "b": 255, "n2": 0, "n1": 30, "p": "flicker", "n3": 0, "d": 80}, "14": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 102, 0]], "b": 255, "n2": 1000, "n1": 2000, "p": "pulse", "n3": 2000, "d": 800}, "5": {"n5": 0, "n4": 1, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 5, "n1": 5, "p": "chase", "n3": 1, "d": 200}, "4": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 5000}, "7": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[255, 165, 0], [128, 0, 128]], "b": 255, "n2": 10, "n1": 2, "p": "circle", "n3": 2, "d": 200}} \ No newline at end of file diff --git a/src/patterns/twinkle.py b/src/patterns/twinkle.py new file mode 100644 index 0000000..3741980 --- /dev/null +++ b/src/patterns/twinkle.py @@ -0,0 +1,227 @@ +import random +import utime + +# Default cool palette (icy blues, violet, mint) when preset has no colours. +# When `driver.debug` is True, print stats every N twinkle ticks (serial can be slow). +_TWINKLE_DBG_INTERVAL = 40 + +_DEFAULT_COOL = ( + (120, 200, 255), + (80, 140, 255), + (180, 120, 255), + (100, 220, 240), + (160, 200, 255), + (90, 180, 220), +) + + +class Twinkle: + def __init__(self, driver): + self.driver = driver + + def _palette(self, preset): + colors = preset.c + if not colors: + return list(_DEFAULT_COOL) + out = [] + for c in colors: + if isinstance(c, (list, tuple)) and len(c) == 3: + out.append( + ( + max(0, min(255, int(c[0]))), + max(0, min(255, int(c[1]))), + max(0, min(255, int(c[2]))), + ) + ) + return out if out else list(_DEFAULT_COOL) + + def run(self, preset): + """Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs.""" + palette = self._palette(preset) + num = self.driver.num_leds + if num <= 0: + while True: + yield + return + + def activity_rate(): + r = int(preset.n1) + if r <= 0: + r = 48 + return max(1, min(255, r)) + + def density255(): + """Higher → more LEDs lit on average when a twinkle step fires (0 = default mid).""" + d = int(preset.n2) + if d <= 0: + d = 128 + return max(0, min(255, d)) + + def cluster_len_bounds(): + """n3 = min adjacent LEDs per twinkle, n4 = max (both 0 → 1..4).""" + lo = int(preset.n3) + hi = int(preset.n4) + if lo <= 0 and hi <= 0: + lo, hi = 1, min(4, num) + else: + if lo <= 0: + lo = 1 + if hi <= 0: + hi = lo + if hi < lo: + lo, hi = hi, lo + lo = max(1, min(lo, num)) + hi = max(lo, min(hi, num)) + return lo, hi + + def random_cluster_len(): + lo, hi = cluster_len_bounds() + # When min and max match, every lit/dim run is exactly that many LEDs (still capped by strip length). + if lo == hi: + return lo + return random.randint(lo, hi) + + def cluster_base_index(start, k): + """Shift run left so a length-k segment fits; keeps full k when num >= k.""" + k = min(max(0, int(k)), num) + if k <= 0: + return 0 + return max(0, min(int(start), num - k)) + + dens = density255() + on = [random.randint(0, 255) < dens for _ in range(num)] + colour_i = [random.randint(0, len(palette) - 1) for _ in range(num)] + last_update = utime.ticks_ms() + dbg_tick = 0 + dbg_banner = False + + def on_run_min_max(bits): + """Smallest and largest contiguous run of True in bits (0,0 if all off).""" + best_min = num + 1 + best_max = 0 + cur = 0 + for j in range(num): + if bits[j]: + cur += 1 + else: + if cur: + if cur < best_min: + best_min = cur + if cur > best_max: + best_max = cur + cur = 0 + if cur: + if cur < best_min: + best_min = cur + if cur > best_max: + best_max = cur + if best_min == num + 1: + return 0, 0 + return best_min, best_max + + if not preset.a: + for i in range(num): + if on[i]: + base = palette[colour_i[i] % len(palette)] + self.driver.n[i] = self.driver.apply_brightness(base, preset.b) + else: + self.driver.n[i] = (0, 0, 0) + self.driver.n.write() + yield + return + + while True: + now = utime.ticks_ms() + delay_ms = max(1, int(preset.d)) + if utime.ticks_diff(now, last_update) >= delay_ms: + rate = activity_rate() + dens = density255() + dbg = bool(getattr(self.driver, "debug", False)) + dbg_tick += 1 + # Snapshot for decisions; apply all darks then all lights so + # overlaps in the same tick favour lit runs (lights win). + prev_on = on[:] + prev_ci = colour_i[:] + next_on = list(prev_on) + next_ci = list(prev_ci) + dbg_ops = {"L": 0, "D": 0} + + light_i = [] + dark_i = [] + for i in range(num): + if random.randint(0, 255) < rate: + r = random.randint(0, 255) + if not prev_on[i]: + if r < dens: + light_i.append(i) + else: + if r < (255 - dens): + dark_i.append(i) + + def light_adjacent(start): + dbg_ops["L"] += 1 + k = random_cluster_len() + b = cluster_base_index(start, k) + for dj in range(k): + idx = b + dj + next_on[idx] = True + next_ci[idx] = random.randint(0, len(palette) - 1) + + def dark_adjacent(start): + dbg_ops["D"] += 1 + k = random_cluster_len() + b = cluster_base_index(start, k) + for dj in range(k): + idx = b + dj + next_on[idx] = False + + for i in dark_i: + dark_adjacent(i) + for i in light_i: + light_adjacent(i) + + for i in range(num): + if next_on[i]: + base = palette[next_ci[i] % len(palette)] + self.driver.n[i] = self.driver.apply_brightness(base, preset.b) + else: + self.driver.n[i] = (0, 0, 0) + self.driver.n.write() + on = next_on + colour_i = next_ci + last_update = utime.ticks_add(last_update, delay_ms) + + if dbg: + lo, hi = cluster_len_bounds() + if not dbg_banner: + dbg_banner = True + print( + "[twinkle] debug on: n1=%s n2=%s n3=%s n4=%s d=%s -> lo=%d hi=%d num=%d" + % ( + preset.n1, + preset.n2, + preset.n3, + preset.n4, + preset.d, + lo, + hi, + num, + ) + ) + rmin, rmax = on_run_min_max(on) + bad = lo > 0 and rmin > 0 and rmin < lo and num >= lo + if bad or (dbg_tick % _TWINKLE_DBG_INTERVAL == 0): + print( + "[twinkle] tick=%d rate=%d dens=%d L=%d D=%d on_runs min=%d max=%d%s" + % ( + dbg_tick, + rate, + dens, + dbg_ops["L"], + dbg_ops["D"], + rmin, + rmax, + " **run