From c47725e31a7dce33d62daadafcf7889cb4507429 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 19 Apr 2026 23:27:19 +1200 Subject: [PATCH] feat(patterns): add colour cycle, flicker, and flame Made-with: Cursor --- src/patterns/__init__.py | 11 +- src/patterns/colour_cycle.py | 56 ++++++++++ src/patterns/flame.py | 210 +++++++++++++++++++++++++++++++++++ src/patterns/flicker.py | 40 +++++++ 4 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 src/patterns/colour_cycle.py create mode 100644 src/patterns/flame.py create mode 100644 src/patterns/flicker.py diff --git a/src/patterns/__init__.py b/src/patterns/__init__.py index 83b9dac..8eb36d8 100644 --- a/src/patterns/__init__.py +++ b/src/patterns/__init__.py @@ -1,6 +1,5 @@ -from .blink import Blink -from .rainbow import Rainbow -from .pulse import Pulse -from .transition import Transition -from .chase import Chase -from .circle import Circle +"""Pattern modules are registered only via Presets._load_dynamic_patterns(). + +This file is ignored as a pattern (see presets.py). Keep it free of imports so +adding a pattern does not require editing this package. +""" diff --git a/src/patterns/colour_cycle.py b/src/patterns/colour_cycle.py new file mode 100644 index 0000000..8ede6d8 --- /dev/null +++ b/src/patterns/colour_cycle.py @@ -0,0 +1,56 @@ +import utime + + +class ColourCycle: + def __init__(self, driver): + self.driver = driver + + def _render(self, colors, phase, brightness): + num_leds = self.driver.num_leds + color_count = len(colors) + if num_leds <= 0 or color_count <= 0: + return + if color_count == 1: + self.driver.fill(self.driver.apply_brightness(colors[0], brightness)) + return + + full_span = color_count * 256 + # Match rainbow behaviour: phase is 0..255 and maps to one full-strip shift. + phase_shift = (phase * full_span) // 256 + for i in range(num_leds): + # Position around the colour loop, shifted by phase. + pos = ((i * full_span) // num_leds + phase_shift) % full_span + idx = pos // 256 + frac = pos & 255 + + c1 = colors[idx] + c2 = colors[(idx + 1) % color_count] + blended = ( + c1[0] + ((c2[0] - c1[0]) * frac) // 256, + c1[1] + ((c2[1] - c1[1]) * frac) // 256, + c1[2] + ((c2[2] - c1[2]) * frac) // 256, + ) + self.driver.n[i] = self.driver.apply_brightness(blended, brightness) + self.driver.n.write() + + def run(self, preset): + colors = preset.c if preset.c else [(255, 255, 255)] + phase = self.driver.step % 256 + step_amount = max(1, int(preset.n1)) + + if not preset.a: + self._render(colors, phase, preset.b) + self.driver.step = (phase + step_amount) % 256 + yield + return + + last_update = utime.ticks_ms() + while True: + current_time = utime.ticks_ms() + delay_ms = max(1, int(preset.d)) + if utime.ticks_diff(current_time, last_update) >= delay_ms: + self._render(colors, phase, preset.b) + phase = (phase + step_amount) % 256 + self.driver.step = phase + last_update = utime.ticks_add(last_update, delay_ms) + yield diff --git a/src/patterns/flame.py b/src/patterns/flame.py new file mode 100644 index 0000000..e383de7 --- /dev/null +++ b/src/patterns/flame.py @@ -0,0 +1,210 @@ +import random +import utime + +# Default warm palette: ember → orange → yellow → pale hot (RGB) +_DEFAULT_PALETTE = ( + (90, 8, 8), + (200, 40, 12), + (255, 120, 30), + (255, 220, 140), +) + + +def _clamp(x, lo, hi): + if x < lo: + return lo + if x > hi: + return hi + return x + + +def _lerp_chan(a, b, t): + return a + ((b - a) * t >> 8) + + +def _lerp_rgb(c0, c1, t): + return ( + _lerp_chan(c0[0], c1[0], t), + _lerp_chan(c0[1], c1[1], t), + _lerp_chan(c0[2], c1[2], t), + ) + + +def _palette_sample(palette, pos256): + n = len(palette) + if n == 0: + return (255, 160, 60) + if n == 1: + return palette[0] + span = (n - 1) * pos256 + seg = span >> 8 + if seg >= n - 1: + return palette[n - 1] + frac = span & 0xFF + return _lerp_rgb(palette[seg], palette[seg + 1], frac) + + +def _triangle_255(elapsed_ms, period_ms): + period_ms = max(period_ms, 400) + p = elapsed_ms % period_ms + half = period_ms >> 1 + if half <= 0: + return 128 + if p < half: + return (p * 255) // half + return ((period_ms - p) * 255) // (period_ms - half) + + +class Flame: + def __init__(self, driver): + self.driver = driver + + def _build_palette(self, preset): + colors = preset.c + if not colors: + return list(_DEFAULT_PALETTE) + out = [] + for c in colors: + if isinstance(c, (list, tuple)) and len(c) == 3: + out.append( + ( + _clamp(int(c[0]), 0, 255), + _clamp(int(c[1]), 0, 255), + _clamp(int(c[2]), 0, 255), + ) + ) + return out if out else list(_DEFAULT_PALETTE) + + def _draw_frame(self, preset, palette, ticks_now, breath_el_ms, rise, cluster_jit, breath_ms, lo, hi, spark_state): + """spark_state: (active: bool, start_ticks, duration_ms). ticks_now for sparks; breath_el_ms for slow wave.""" + num = self.driver.num_leds + denom = num - 1 if num > 1 else 1 + + breathe = _triangle_255(breath_el_ms, breath_ms) + base_level = lo + (((hi - lo) * breathe) >> 8) + micro = 232 + random.randint(0, 35) + level = (base_level * micro) >> 8 + level = _clamp(level, lo, hi) + + spark_boost = 0 + spark_white = (0, 0, 0) + active, s0, dur = spark_state + if active and dur > 0: + el = utime.ticks_diff(ticks_now, s0) + if el < 0: + el = 0 + if el >= dur: + spark_boost = 0 + else: + env = 255 - ((el * 255) // dur) + spark_boost = (env * 90) >> 8 + spark_white = ((env * 55) >> 8, (env * 50) >> 8, (env * 40) >> 8) + + for i in range(num): + h = (i * 256) // denom + flow = (h + rise + ((i // max(1, num >> 3)) * 17)) & 255 + pos = (flow + cluster_jit[(i >> 2) & 7]) & 255 + rgb = _palette_sample(palette, pos) + if spark_boost: + rgb = ( + _clamp(rgb[0] + spark_white[0] + (spark_boost * 3 >> 2), 0, 255), + _clamp(rgb[1] + spark_white[1] + (spark_boost >> 1), 0, 255), + _clamp(rgb[2] + spark_white[2] + (spark_boost >> 2), 0, 255), + ) + self.driver.n[i] = self.driver.apply_brightness(rgb, level) + + self.driver.n.write() + + def run(self, preset): + """Salt-lamp / hearth-style flame: warm gradient, breathing, jitter, drift, rare sparks.""" + palette = self._build_palette(preset) + lo = max(0, min(255, int(preset.n1))) + hi = max(0, min(255, int(preset.b))) + if lo > hi: + lo, hi = hi, lo + + bp = int(preset.n2) + breath_ms = max(800, bp if bp > 0 else 2500) + + gap_lo = int(preset.n3) + gap_hi = int(preset.n4) + # n3 < 0 disables sparks; n3=n4=0 uses ~10–30 s gaps (hearth pops). + if gap_lo < 0: + sparks_on = False + else: + sparks_on = True + if gap_lo == 0 and gap_hi == 0: + gap_lo, gap_hi = 10000, 30000 + else: + gap_lo = max(gap_lo, 500) + if gap_hi < gap_lo: + gap_hi = gap_lo + + delay_ms = max(16, int(preset.d)) + rise = random.randint(0, 255) + cluster_jit = [random.randint(-18, 18) for _ in range(8)] + last_draw = utime.ticks_ms() + breath_origin = last_draw + last_cluster = last_draw + spark_active = False + spark_start = 0 + spark_dur = 0 + next_spark = utime.ticks_add(last_draw, random.randint(gap_lo, gap_hi)) if sparks_on else 0 + + if not preset.a: + now = utime.ticks_ms() + self._draw_frame( + preset, + palette, + now, + utime.ticks_diff(now, breath_origin), + rise, + cluster_jit, + breath_ms, + lo, + hi, + (False, 0, 0), + ) + yield + return + + while True: + now = utime.ticks_ms() + if utime.ticks_diff(now, last_draw) < delay_ms: + yield + continue + last_draw = utime.ticks_add(last_draw, delay_ms) + + rise = (rise + random.randint(-10, 12)) & 255 + + if utime.ticks_diff(now, last_cluster) >= (delay_ms * 4): + last_cluster = now + cluster_jit = [random.randint(-18, 18) for _ in range(8)] + + spark_state = (spark_active, spark_start, spark_dur) + if sparks_on: + if spark_active: + if utime.ticks_diff(now, spark_start) >= spark_dur: + spark_active = False + next_spark = utime.ticks_add( + now, + random.randint(gap_lo, gap_hi), + ) + elif utime.ticks_diff(now, next_spark) >= 0: + spark_active = True + spark_start = now + spark_dur = random.randint(180, 360) + + self._draw_frame( + preset, + palette, + now, + utime.ticks_diff(now, breath_origin), + rise, + cluster_jit, + breath_ms, + lo, + hi, + (spark_active, spark_start, spark_dur), + ) + yield diff --git a/src/patterns/flicker.py b/src/patterns/flicker.py new file mode 100644 index 0000000..b17fd6d --- /dev/null +++ b/src/patterns/flicker.py @@ -0,0 +1,40 @@ +import random +import utime + + +class Flicker: + def __init__(self, driver): + self.driver = driver + + def run(self, preset): + """Random brightness between n1 (min) and b (max); delay d ms between updates.""" + colors = preset.c if preset.c else [(255, 255, 255)] + color_index = 0 + last_update = utime.ticks_ms() + + def brightness_bounds(): + lo = max(0, min(255, int(preset.n1))) + hi = max(0, min(255, int(preset.b))) + if lo > hi: + lo, hi = hi, lo + return lo, hi + + if not preset.a: + lo, hi = brightness_bounds() + level = random.randint(lo, hi) + base = colors[color_index % len(colors)] + self.driver.fill(self.driver.apply_brightness(base, level)) + yield + return + + while True: + current_time = utime.ticks_ms() + delay_ms = max(1, int(preset.d)) + lo, hi = brightness_bounds() + if utime.ticks_diff(current_time, last_update) >= delay_ms: + level = random.randint(lo, hi) + base = colors[color_index % len(colors)] + self.driver.fill(self.driver.apply_brightness(base, level)) + color_index += 1 + last_update = utime.ticks_add(last_update, delay_ms) + yield