feat(patterns): add colour cycle, flicker, and flame

Made-with: Cursor
This commit is contained in:
2026-04-19 23:27:19 +12:00
parent 22b1a8a6d6
commit c47725e31a
4 changed files with 311 additions and 6 deletions

View File

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

View File

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

210
src/patterns/flame.py Normal file
View File

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

40
src/patterns/flicker.py Normal file
View File

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