feat(patterns): add colour cycle, flicker, and flame
Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
from .blink import Blink
|
"""Pattern modules are registered only via Presets._load_dynamic_patterns().
|
||||||
from .rainbow import Rainbow
|
|
||||||
from .pulse import Pulse
|
This file is ignored as a pattern (see presets.py). Keep it free of imports so
|
||||||
from .transition import Transition
|
adding a pattern does not require editing this package.
|
||||||
from .chase import Chase
|
"""
|
||||||
from .circle import Circle
|
|
||||||
|
|||||||
56
src/patterns/colour_cycle.py
Normal file
56
src/patterns/colour_cycle.py
Normal 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
210
src/patterns/flame.py
Normal 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 ~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
|
||||||
40
src/patterns/flicker.py
Normal file
40
src/patterns/flicker.py
Normal 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
|
||||||
Reference in New Issue
Block a user