feat(patterns): merge pattern styles and add mode support

Consolidate legacy pattern ids into meteor, particles, sparkle, chase,
and colour_cycle with n6/mode style selection; add pattern_modes helper,
self-contained tests/all.py, and preset mode alias on wire.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-16 21:14:54 +12:00
parent 794f1a2841
commit 55a97ac51c
44 changed files with 998 additions and 1539 deletions

View File

@@ -1,8 +1,8 @@
import asyncio
import gc
import utime
from hello import broadcast_hello_udp
from mem_stats import print_mem
from wifi_sta import try_reconnect
_UDP_HELLO_ATTEMPT = 0
@@ -16,8 +16,7 @@ async def presets_loop(presets, wdt):
if bool(getattr(presets, "debug", False)):
now = utime.ticks_ms()
if utime.ticks_diff(now, last_mem_log) >= 5000:
gc.collect()
print("mem runtime:", {"free": gc.mem_free(), "alloc": gc.mem_alloc()})
print_mem("runtime")
last_mem_log = now
# tick() does not await; yield so UDP hello and HTTP/WebSocket can run.
await asyncio.sleep(0)

View File

@@ -1,6 +1,6 @@
import print_timestamp # noqa: F401 — prefixes every print with [ticks_ms]
from settings import Settings
import machine
import network
import utime
import asyncio
import json
@@ -10,8 +10,9 @@ from microdot.websocket import WebSocketError, with_websocket
from presets import Presets
from controller_messages import apply_startup_pattern, process_data
from runtime_state import RuntimeState
from background_tasks import udp_hello_loop_after_http_ready
from wifi_sta import connect_until_up
from background_tasks import presets_loop, udp_hello_loop_after_http_ready
from mem_stats import print_mem
from wifi_sta import boot_sta
try:
import uos as os
except ImportError:
@@ -25,9 +26,8 @@ machine.freq(160000000)
settings = Settings()
gc.collect()
sta_if = boot_sta(settings, wdt)
presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
@@ -37,21 +37,6 @@ gc.collect()
apply_startup_pattern(settings, presets)
# On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated.
# Reset both interfaces and collect before bringing STA up.
ap_if = network.WLAN(network.AP_IF)
ap_if.active(False)
sta_if = network.WLAN(network.STA_IF)
if sta_if.active():
sta_if.active(False)
utime.sleep_ms(100)
gc.collect()
sta_if.active(True)
sta_if.config(pm=network.WLAN.PM_NONE)
_boot_ssid = settings.get("ssid") or ""
if _boot_ssid:
connect_until_up(sta_if, _boot_ssid, settings.get("password") or "", wdt)
def _print_network_ips(controller_ip=None):
"""Always log STA address and led-controller (WS client) address when known."""
@@ -64,6 +49,7 @@ def _print_network_ips(controller_ip=None):
_print_network_ips()
print_mem("startup")
runtime_state = RuntimeState()
@@ -94,6 +80,7 @@ async def ws_handler(request, ws):
except Exception:
controller_ip = None
_print_network_ips(controller_ip)
print_mem("ws connect")
try:
while True:
data = await ws.receive()
@@ -167,16 +154,8 @@ async def upload_pattern(request):
}), 201, {"Content-Type": "application/json"}
async def presets_loop():
last_mem_log = utime.ticks_ms()
while True:
presets.tick()
wdt.feed()
await asyncio.sleep(0)
async def main(port=80):
asyncio.create_task(presets_loop())
asyncio.create_task(presets_loop(presets, wdt))
asyncio.create_task(
udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state)
)

34
src/mem_stats.py Normal file
View File

@@ -0,0 +1,34 @@
"""GC / heap snapshot helpers for debug logging."""
import gc
def snapshot():
"""Return a dict of memory stats after ``gc.collect()``."""
gc.collect()
out = {
"free": gc.mem_free(),
"alloc": gc.mem_alloc(),
}
try:
import esp32
blocks = esp32.idf_heap_info(esp32.HEAP_DATA)
if blocks:
block = blocks[0]
if isinstance(block, dict):
if "total_free_bytes" in block:
out["idf_free"] = block["total_free_bytes"]
largest = block.get("largest_free_block")
if largest is None:
largest = block.get("largest_free_block_in_bytes")
if largest is not None:
out["idf_largest"] = largest
except Exception:
pass
return out
def print_mem(label):
"""Print one timestamped memory line (via ``print_timestamp`` when installed)."""
print("mem %s:" % label, snapshot())

View File

@@ -1,12 +1,16 @@
import math
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"northern_wave": 1}
class Aurora:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(40, 200, 140), (80, 120, 255), (160, 80, 220)]
def _run_bands(self, preset, colors):
bands = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
shimmer = max(0, min(255, int(preset.n2) if int(preset.n2) > 0 else 40))
phase = self.driver.step % 256
@@ -16,11 +20,17 @@ class Aurora:
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
for i in range(self.driver.num_leds):
idx = ((i * bands) // max(1, self.driver.num_leds) + (phase // 32)) % len(colors)
idx = (
(i * bands) // max(1, self.driver.num_leds) + (phase // 32)
) % len(colors)
c = self.driver.apply_brightness(colors[idx], preset.b)
w = (255 - abs(128 - ((i * 8 + phase) & 255)) * 2)
w = 255 - abs(128 - ((i * 8 + phase) & 255)) * 2
w = max(0, min(255, w + shimmer))
self.driver.n[i] = ((c[0]*w)//255, (c[1]*w)//255, (c[2]*w)//255)
self.driver.n[i] = (
(c[0] * w) // 255,
(c[1] * w) // 255,
(c[2] * w) // 255,
)
self.driver.n.write()
phase = (phase + 1) & 255
self.driver.step = phase
@@ -29,3 +39,57 @@ class Aurora:
yield
return
yield
def _run_northern(self, preset, colors):
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
def run(self, preset):
"""Aurora bands (n6=0) or sine northern wave (n6=1, legacy northern_wave)."""
colors = preset.c if preset.c else [(40, 200, 140), (80, 120, 255), (160, 80, 220)]
if style_mode(preset, 0, _LEGACY) == 1:
colors = preset.c if preset.c else [(20, 55, 120), (60, 140, 220), (180, 220, 255)]
yield from self._run_northern(preset, colors)
return
yield from self._run_bands(preset, colors)

View File

@@ -1,40 +0,0 @@
import utime
class BreathingDual:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 0, 140), (0, 120, 255)]
phase_offset = max(0, min(255, int(preset.n1)))
ease = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
phase = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
p1 = phase
p2 = (phase + phase_offset) & 255
t1 = 255 - abs(128 - p1) * 2
t2 = 255 - abs(128 - p2) * 2
if ease > 1:
t1 = (t1 * t1) // 255
t2 = (t2 * t2) // 255
c1 = self.driver.apply_brightness(colors[0], preset.b)
c2 = self.driver.apply_brightness(colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b)
half = self.driver.num_leds // 2
for i in range(self.driver.num_leds):
if i < half:
self.driver.n[i] = ((c1[0]*t1)//255, (c1[1]*t1)//255, (c1[2]*t1)//255)
else:
self.driver.n[i] = ((c2[0]*t2)//255, (c2[1]*t2)//255, (c2[2]*t2)//255)
self.driver.n.write()
phase = (phase + 2) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -1,13 +1,49 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"marquee": 1}
class Chase:
def __init__(self, driver):
self.driver = driver
def _run_marquee(self, preset, colors):
on_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
off_len = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
phase = self.driver.step % (on_len + off_len)
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
m = (i + phase) % (on_len + off_len)
self.driver.n[i] = c if m < on_len else bg_color
self.driver.n.write()
phase = (phase + step) % (on_len + off_len)
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield
def run(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
"""Chase (n6=0) or marquee dashes (n6=1, legacy marquee).
Chase: n1/n2 segment lengths, n3/n4 step on even/odd beats.
Marquee: n1 on length, n2 off length, n3 scroll step.
"""
if style_mode(preset, 0, _LEGACY) == 1:
colors = preset.c if preset.c else [(255, 255, 255)]
yield from self._run_marquee(preset, colors)
return
colors = preset.c
if len(colors) < 1:
# Need at least 1 color

View File

@@ -1,11 +1,24 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"rainbow": 1, "gradient_scroll": 0}
class ColourCycle:
def __init__(self, driver):
self.driver = driver
def _render(self, colors, phase, brightness):
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
if pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def _render_gradient(self, colors, phase, brightness):
num_leds = self.driver.num_leds
color_count = len(colors)
if num_leds <= 0 or color_count <= 0:
@@ -15,14 +28,11 @@ class ColourCycle:
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 = (
@@ -33,23 +43,55 @@ class ColourCycle:
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))
def _render_rainbow(self, phase, brightness):
num_leds = self.driver.num_leds
for i in range(num_leds):
rc_index = (i * 256 // max(1, num_leds)) + phase
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255), brightness
)
self.driver.n.write()
def run(self, preset):
"""Scroll gradient (n6=0) or fixed spectrum wheel (n6=1, legacy rainbow).
n1: step rate
n6: 0 gradient scroll, 1 rainbow wheel
"""
mode = style_mode(preset, 0, _LEGACY)
step_amount = max(1, int(preset.n1) if int(preset.n1) > 0 else 1)
phase = self.driver.step % 256
if mode == 1:
if not preset.a:
self._render_rainbow(phase, preset.b)
self.driver.step = (phase + step_amount) % 256
yield
return
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_rainbow(phase, preset.b)
phase = (phase + step_amount) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
if not preset.a:
self._render(colors, phase, preset.b)
self._render_gradient(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)
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_gradient(colors, phase, preset.b)
phase = (phase + step_amount) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)

View File

@@ -1,44 +0,0 @@
import utime
class CometDual:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255)]
tail = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
gap = max(0, int(preset.n3))
p1 = 0
p2 = self.driver.num_leds - 1 - gap
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b)
c2 = self.driver.apply_brightness(colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b)
for t in range(tail):
i1 = p1 - t
if 0 <= i1 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i1] = ((c1[0]*s)//255, (c1[1]*s)//255, (c1[2]*s)//255)
i2 = p2 + t
if 0 <= i2 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i2] = ((c2[0]*s)//255, (c2[1]*s)//255, (c2[2]*s)//255)
self.driver.n.write()
p1 += speed
p2 -= speed
if p1 - tail > self.driver.num_leds and p2 + tail < 0:
p1 = 0
p2 = self.driver.num_leds - 1 - gap
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -1,35 +0,0 @@
import random
import utime
class Fireflies:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 210, 80), (120, 255, 120)]
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 8)
bugs = [[random.randint(0, max(0, self.driver.num_leds - 1)), random.randint(0, 255)] for _ in range(count)]
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
for b in bugs:
idx, ph = b
tri = 255 - abs(128 - ph) * 2
c = self.driver.apply_brightness(colors[idx % len(colors)], preset.b)
self.driver.n[idx] = ((c[0]*tri)//255, (c[1]*tri)//255, (c[2]*tri)//255)
b[1] = (ph + speed) & 255
if random.randint(0, 31) == 0:
b[0] = random.randint(0, max(0, self.driver.num_leds - 1))
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -1,57 +0,0 @@
import utime
class GradientScroll:
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
phase_shift = (phase * full_span) // 256
for i in range(num_leds):
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):
"""Scrolling blended gradient.
n1: phase step amount (default 1)
"""
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
phase = self.driver.step % 256
step_amount = max(1, int(preset.n1) if int(preset.n1) > 0 else 1)
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, 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)
if not preset.a:
yield
return
yield

View File

@@ -1,36 +0,0 @@
import utime
class Heartbeat:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 0, 40)]
phase = 0
phase_start = utime.ticks_ms()
did_manual_pulse = False
while True:
p1 = max(20, int(preset.n1) if int(preset.n1) > 0 else 120)
p2 = max(20, int(preset.n2) if int(preset.n2) > 0 else 80)
pause = max(20, int(preset.n3) if int(preset.n3) > 0 else 500)
beat_gap = max(20, int(preset.d))
colors = preset.c if preset.c else [(255, 0, 40)]
lit_color = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
phase_durations = (p1, beat_gap, p2, pause)
phase_colors = (lit_color, bg_color, lit_color, bg_color)
now = utime.ticks_ms()
while utime.ticks_diff(now, phase_start) >= phase_durations[phase]:
phase_start = utime.ticks_add(phase_start, phase_durations[phase])
phase = (phase + 1) % 4
self.driver.fill(phase_colors[phase])
yield
if not preset.a:
if did_manual_pulse or phase == 0:
self.driver.fill(bg_color)
yield
return
did_manual_pulse = True

View File

@@ -1,69 +0,0 @@
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

View File

@@ -1,31 +0,0 @@
import utime
class Marquee:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255)]
on_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
off_len = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
phase = self.driver.step % (on_len + off_len)
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
m = (i + phase) % (on_len + off_len)
self.driver.n[i] = c if m < on_len else bg_color
self.driver.n.write()
phase = (phase + step) % (on_len + off_len)
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

156
src/patterns/meteor.py Normal file
View File

@@ -0,0 +1,156 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"comet_dual": 1, "scanner": 2}
class Meteor:
def __init__(self, driver):
self.driver = driver
def _fade(self, color, fade_amount):
return (
(color[0] * fade_amount) // 255,
(color[1] * fade_amount) // 255,
(color[2] * fade_amount) // 255,
)
def _run_meteor(self, preset, colors, color_index, head, direction, last_update):
tail_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 8)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
fade_amount = int(preset.n3) if int(preset.n3) > 0 else 192
fade_amount = max(1, min(255, fade_amount))
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
return color_index, head, direction, last_update, False
for i in range(self.driver.num_leds):
self.driver.n[i] = self._fade(self.driver.n[i], fade_amount)
base = colors[color_index % len(colors)]
lit = self.driver.apply_brightness(base, preset.b)
if 0 <= head < self.driver.num_leds:
self.driver.n[head] = lit
self.driver.n.write()
head += direction * speed
if head >= self.driver.num_leds + tail_len:
head = self.driver.num_leds - 1
direction = -1
color_index += 1
elif head < -tail_len:
head = 0
direction = 1
color_index += 1
return color_index, head, direction, utime.ticks_add(last_update, delay_ms), True
def _run_comet_dual(self, preset, colors, p1, p2, last):
tail = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
gap = max(0, int(preset.n3))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return p1, p2, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b)
c2 = self.driver.apply_brightness(
colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b
)
for t in range(tail):
i1 = p1 - t
if 0 <= i1 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i1] = ((c1[0] * s) // 255, (c1[1] * s) // 255, (c1[2] * s) // 255)
i2 = p2 + t
if 0 <= i2 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[i2] = ((c2[0] * s) // 255, (c2[1] * s) // 255, (c2[2] * s) // 255)
self.driver.n.write()
p1 += speed
p2 -= speed
if p1 - tail > self.driver.num_leds and p2 + tail < 0:
p1 = 0
p2 = self.driver.num_leds - 1 - gap
return p1, p2, utime.ticks_add(last, d), True
def _run_scanner(self, preset, colors, color_index, center, direction, pause_frames, last_update):
width = max(1, int(preset.n1) if int(preset.n1) > 0 else 4)
end_pause = max(0, int(preset.n2))
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
return color_index, center, direction, pause_frames, last_update, False
base = self.driver.apply_brightness(colors[color_index % len(colors)], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
dist = i - center
if dist < 0:
dist = -dist
if dist > width:
self.driver.n[i] = bg_color
else:
scale = ((width - dist) * 255) // max(1, width)
self.driver.n[i] = (
(base[0] * scale) // 255,
(base[1] * scale) // 255,
(base[2] * scale) // 255,
)
self.driver.n.write()
if pause_frames > 0:
pause_frames -= 1
else:
center += direction
if center >= self.driver.num_leds - 1:
center = self.driver.num_leds - 1
direction = -1
pause_frames = end_pause
color_index += 1
elif center <= 0:
center = 0
direction = 1
pause_frames = end_pause
color_index += 1
return color_index, center, direction, pause_frames, utime.ticks_add(last_update, delay_ms), True
def run(self, preset):
"""Moving lights: n6 style 0 meteor, 1 dual comet, 2 scanner (legacy ids still work)."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(255, 255, 255)]
if mode == 1:
gap = max(0, int(preset.n3))
p1, p2 = 0, self.driver.num_leds - 1 - gap
last = utime.ticks_ms()
while True:
p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last)
if stepped and not preset.a:
yield
return
yield
if mode == 2:
color_index, center, direction, pause_frames = 0, 0, 1, 0
last_update = utime.ticks_ms()
while True:
color_index, center, direction, pause_frames, last_update, stepped = (
self._run_scanner(
preset, colors, color_index, center, direction, pause_frames, last_update
)
)
if stepped and not preset.a:
yield
return
yield
color_index, head, direction = 0, 0, 1
last_update = utime.ticks_ms()
while True:
color_index, head, direction, last_update, stepped = self._run_meteor(
preset, colors, color_index, head, direction, last_update
)
if stepped and not preset.a:
yield
return
yield

View File

@@ -1,62 +0,0 @@
import utime
class MeteorRain:
def __init__(self, driver):
self.driver = driver
def _fade(self, color, fade_amount):
return (
(color[0] * fade_amount) // 255,
(color[1] * fade_amount) // 255,
(color[2] * fade_amount) // 255,
)
def run(self, preset):
"""Single meteor with a fading tail.
n1: tail length (default 8)
n2: speed in LEDs per frame (default 1)
n3: fade amount per frame, 1..255 (default 192)
"""
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
head = 0
direction = 1
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
tail_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 8)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
fade_amount = int(preset.n3) if int(preset.n3) > 0 else 192
fade_amount = max(1, min(255, fade_amount))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
for i in range(self.driver.num_leds):
self.driver.n[i] = self._fade(self.driver.n[i], fade_amount)
base = colors[color_index % len(colors)]
lit = self.driver.apply_brightness(base, preset.b)
if 0 <= head < self.driver.num_leds:
self.driver.n[head] = lit
self.driver.n.write()
head += direction * speed
if head >= self.driver.num_leds + tail_len:
head = self.driver.num_leds - 1
direction = -1
color_index += 1
elif head < -tail_len:
head = 0
direction = 1
color_index += 1
last_update = utime.ticks_add(last_update, delay_ms)
if not preset.a:
yield
return
yield

View File

@@ -1,53 +0,0 @@
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

108
src/patterns/particles.py Normal file
View File

@@ -0,0 +1,108 @@
import random
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"starfall": 1}
class Particles:
def __init__(self, driver):
self.driver = driver
def _run_snowfall(self, preset, colors, flakes, last):
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 20)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return flakes, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
if random.randint(0, 255) < density:
flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors) - 1)])
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
nf = []
for pos, ci in flakes:
if 0 <= pos < self.driver.num_leds:
self.driver.n[pos] = self.driver.apply_brightness(colors[ci], preset.b)
pos -= speed
if pos >= -1:
nf.append([pos, ci])
self.driver.n.write()
return nf, utime.ticks_add(last, d), True
def _run_starfall(self, preset, colors, stars, last):
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)
max_stars = 4
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return stars, last, False
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()
return stars, utime.ticks_add(last, d), True
def run(self, preset):
"""Falling particles: n6 0 snowfall flakes, 1 starfall streaks."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(255, 255, 255), (180, 220, 255)]
last = utime.ticks_ms()
if mode == 1:
colors = preset.c if preset.c else [
(255, 255, 255),
(200, 230, 255),
(255, 248, 220),
]
stars = []
while True:
stars, last, stepped = self._run_starfall(preset, colors, stars, last)
if stepped and not preset.a:
yield
return
yield
flakes = []
while True:
flakes, last, stepped = self._run_snowfall(preset, colors, flakes, last)
if stepped and not preset.a:
yield
return
yield

View File

@@ -0,0 +1,18 @@
"""Resolve pattern style from n6 or legacy preset pattern id (p)."""
def style_mode(preset, default=0, legacy=None):
legacy = legacy or {}
p = getattr(preset, "p", "") or ""
if p in legacy:
return legacy[p]
mode = getattr(preset, "mode", None)
if mode is None and isinstance(preset, dict):
mode = preset.get("mode")
if mode is not None:
try:
return int(mode)
except (TypeError, ValueError):
pass
n6 = int(getattr(preset, "n6", 0) or 0)
return n6 if n6 > 0 else default

View File

@@ -1,7 +1,7 @@
import utime
# When ``driver.debug`` is True (``settings["debug"]``), log at most this often (ms).
_RADIATE_DBG_INTERVAL_MS = 800
_RADIATE_DBG_INTERVAL_MS = 2500
class Radiate:
@@ -37,6 +37,7 @@ class Radiate:
if not preset.a:
# Manual mode: one-shot pulse using the same ms-based timing as auto.
cycle_start = utime.ticks_ms()
last_dbg = cycle_start
while True:
dbg = bool(getattr(self.driver, "debug", False))
spacing = max(1, int(preset.n1))
@@ -78,10 +79,12 @@ class Radiate:
"[radiate] debug on n1=%s n2=%s n3=%s d=%s auto=%s num_leds=%d"
% (preset.n1, preset.n2, preset.n3, preset.d, preset.a, self.driver.num_leds)
)
print(
"[radiate] manual frame age=%d/%d front=%d lit=%d"
% (age, pulse_lifetime, front, lit_count)
)
if utime.ticks_diff(now, last_dbg) >= _RADIATE_DBG_INTERVAL_MS:
print(
"[radiate] manual frame age=%d/%d front=%d lit=%d"
% (age, pulse_lifetime, front, lit_count)
)
last_dbg = now
yield
if age >= pulse_lifetime:

View File

@@ -1,51 +0,0 @@
import utime
class Rainbow:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not preset.a:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
self.driver.n.write()
# Increment step by n1 for next manual call
self.driver.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
yield
return
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.d)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255),
preset.b,
)
self.driver.n.write()
step = (step + step_amount) % 256
self.driver.step = step
last_update = utime.ticks_add(last_update, sleep_ms)
# Yield once per tick so other logic can run
yield

View File

@@ -1,67 +0,0 @@
import utime
class Scanner:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Classic scanner eye with soft falloff.
n1: eye width (default 4)
n2: end pause in frames (default 0)
"""
colors = preset.c if preset.c else [(255, 0, 0)]
color_index = 0
center = 0
direction = 1
pause_frames = 0
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
width = max(1, int(preset.n1) if int(preset.n1) > 0 else 4)
end_pause = max(0, int(preset.n2))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
base = colors[color_index % len(colors)]
base = self.driver.apply_brightness(base, preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
dist = i - center
if dist < 0:
dist = -dist
if dist > width:
self.driver.n[i] = bg_color
else:
scale = ((width - dist) * 255) // max(1, width)
self.driver.n[i] = (
(base[0] * scale) // 255,
(base[1] * scale) // 255,
(base[2] * scale) // 255,
)
self.driver.n.write()
if pause_frames > 0:
pause_frames -= 1
else:
center += direction
if center >= self.driver.num_leds - 1:
center = self.driver.num_leds - 1
direction = -1
pause_frames = end_pause
color_index += 1
elif center <= 0:
center = 0
direction = 1
pause_frames = end_pause
color_index += 1
last_update = utime.ticks_add(last_update, delay_ms)
if not preset.a:
yield
return
yield

View File

@@ -1,45 +0,0 @@
import utime
class SegmentChase:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Independent moving segments (distinct from classic two-color chase).
n1: segment size (LEDs per segment)
n2: step size (phase increment each frame)
n3: per-segment phase offset
n4: gap spacing inside segment (0 = solid segment)
"""
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
seg = max(1, int(preset.n1) if int(preset.n1) > 0 else 4)
phase_step = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
seg_offset = max(0, int(preset.n3))
gap = max(0, int(preset.n4))
phase = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
seg_idx = i // seg
in_seg = i % seg
local_phase = (phase + seg_idx * seg_offset) % seg
lit_idx = (in_seg + local_phase) % seg
if gap > 0 and lit_idx >= max(1, seg - gap):
self.driver.n[i] = bg_color
else:
color_idx = seg_idx % len(colors)
self.driver.n[i] = self.driver.apply_brightness(colors[color_idx], preset.b)
self.driver.n.write()
phase = (phase + phase_step) % seg
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -1,37 +0,0 @@
import random
import utime
class Snowfall:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (180, 220, 255)]
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 20)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
flakes = []
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
if random.randint(0, 255) < density:
flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors)-1)])
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
nf = []
for pos, ci in flakes:
if 0 <= pos < self.driver.num_leds:
self.driver.n[pos] = self.driver.apply_brightness(colors[ci], preset.b)
pos -= speed
if pos >= -1:
nf.append([pos, ci])
flakes = nf
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

147
src/patterns/sparkle.py Normal file
View File

@@ -0,0 +1,147 @@
import random
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"sparkle_trail": 0, "ice_sparkle": 1, "fireflies": 2}
class Sparkle:
def __init__(self, driver):
self.driver = driver
def _run_trail(self, preset, colors, last):
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 24)
decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 210))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return last, False
for i in range(self.driver.num_leds):
r, g, b = self.driver.n[i]
self.driver.n[i] = ((r * decay) // 255, (g * decay) // 255, (b * decay) // 255)
sparks = max(1, self.driver.num_leds * density // 255)
for _ in range(sparks):
idx = random.randint(0, max(0, self.driver.num_leds - 1))
c = self.driver.apply_brightness(
colors[random.randint(0, len(colors) - 1)], preset.b
)
self.driver.n[idx] = c
self.driver.n.write()
return utime.ticks_add(last, d), True
def _run_ice(self, preset, colors, sparks, last):
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)))
cap = 28
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return sparks, last, False
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()
return sparks, utime.ticks_add(last, d), True
def _run_fireflies(self, preset, colors, bugs, last):
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 8)
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return bugs, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
for b in bugs:
idx, ph = b
tri = 255 - abs(128 - ph) * 2
c = self.driver.apply_brightness(colors[idx % len(colors)], preset.b)
self.driver.n[idx] = ((c[0] * tri) // 255, (c[1] * tri) // 255, (c[2] * tri) // 255)
b[1] = (ph + speed) & 255
if random.randint(0, 31) == 0:
b[0] = random.randint(0, max(0, self.driver.num_leds - 1))
self.driver.n.write()
return bugs, utime.ticks_add(last, d), True
def run(self, preset):
"""Sparkles: n6 0 trail decay, 1 ice burst+halo, 2 fireflies."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(120, 120, 255)]
last = utime.ticks_ms()
if mode == 2:
colors = preset.c if preset.c else [(255, 210, 80), (120, 255, 120)]
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
bugs = [
[random.randint(0, max(0, self.driver.num_leds - 1)), random.randint(0, 255)]
for _ in range(count)
]
while True:
bugs, last, stepped = self._run_fireflies(preset, colors, bugs, last)
if stepped and not preset.a:
yield
return
yield
if mode == 1:
colors = preset.c if preset.c else [
(240, 248, 255),
(200, 235, 255),
(255, 255, 255),
]
sparks = []
while True:
sparks, last, stepped = self._run_ice(preset, colors, sparks, last)
if stepped and not preset.a:
yield
return
yield
while True:
last, stepped = self._run_trail(preset, colors, last)
if stepped and not preset.a:
yield
return
yield

View File

@@ -1,31 +0,0 @@
import random
import utime
class SparkleTrail:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(120, 120, 255)]
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 24)
decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 210))
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
for i in range(self.driver.num_leds):
r,g,b = self.driver.n[i]
self.driver.n[i] = ((r*decay)//255, (g*decay)//255, (b*decay)//255)
sparks = max(1, self.driver.num_leds * density // 255)
for _ in range(sparks):
idx = random.randint(0, max(0, self.driver.num_leds - 1))
c = self.driver.apply_brightness(colors[random.randint(0, len(colors)-1)], preset.b)
self.driver.n[idx] = c
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -1,65 +0,0 @@
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

@@ -1,32 +0,0 @@
import utime
class Wave:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(0, 180, 255)]
wavelength = max(2, int(preset.n1) if int(preset.n1) > 0 else 12)
amp = max(0, min(255, int(preset.n2) if int(preset.n2) > 0 else 180))
drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
phase = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
base = self.driver.apply_brightness(colors[0], preset.b)
for i in range(self.driver.num_leds):
x = (i * 256 // wavelength + phase) & 255
tri = 255 - abs(128 - x) * 2
s = (tri * amp) // 255
self.driver.n[i] = ((base[0]*s)//255, (base[1]*s)//255, (base[2]*s)//255)
self.driver.n.write()
phase = (phase + drift) % 256
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -27,6 +27,7 @@ class Preset:
"brightness": "b",
"auto": "a",
"background": "bg",
"mode": "n6",
}
int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"}
allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"}

View File

@@ -70,8 +70,29 @@ class Presets:
except Exception as e:
print("Pattern init failed:", module_name, e)
self._apply_pattern_aliases(loaded)
return loaded
def _apply_pattern_aliases(self, loaded):
"""Legacy pattern ids -> merged implementations (same generator)."""
aliases = (
("rainbow", "colour_cycle"),
("gradient_scroll", "colour_cycle"),
("meteor_rain", "meteor"),
("comet_dual", "meteor"),
("scanner", "meteor"),
("snowfall", "particles"),
("starfall", "particles"),
("sparkle_trail", "sparkle"),
("ice_sparkle", "sparkle"),
("fireflies", "sparkle"),
("marquee", "chase"),
("northern_wave", "aurora"),
)
for old, new in aliases:
if new in loaded and old not in loaded:
loaded[old] = loaded[new]
def save(self):
"""Save the presets to a file."""
with open("presets.json", "w") as f:
@@ -112,16 +133,24 @@ class Presets:
"""Create or update a preset with the given name."""
if name in self.presets:
# Update existing preset
was_auto = self.presets[name].a
self.presets[name].edit(data)
# Editing the live preset (e.g. toggling auto/manual) must reset runtime
# state; re-select alone keeps step because preset name is unchanged.
# Editing the live preset: auto still re-selects (one tick) so the strip
# restarts without a separate select message (controller often sends both).
# Manual must NOT call select() here — presets-only pushes (e.g. zone sequence
# arming the first step) would otherwise run select's first tick and consume a
# beat/step. Manual advances only on explicit select from the controller.
if self.selected == name:
self.step = 0
self.generator = None
self.fill((0, 0, 0))
# Re-start pattern so manual/auto and other edits apply without a
# separate select message (controller usually sends both).
self.select(name)
preset = self.presets[name]
if preset.a:
self.step = 0
self.generator = None
self.fill((0, 0, 0))
self.select(name)
elif was_auto:
self.step = 0
self.generator = None
self.fill((0, 0, 0))
else:
if len(self.presets) >= MAX_PRESETS and name not in ("on", "off"):
print("Preset limit reached:", MAX_PRESETS)

17
src/print_timestamp.py Normal file
View File

@@ -0,0 +1,17 @@
"""Install a builtins.print wrapper that prefixes each line with uptime (ms).
Import this module before other led-driver imports that print (e.g. first in main).
"""
import builtins
import utime
_original_print = builtins.print
def _timestamped_print(*args, **kwargs):
ts = utime.ticks_ms()
return _original_print("[%d]" % ts, *args, **kwargs)
builtins.print = _timestamped_print

View File

@@ -1,5 +1,7 @@
"""STA connect helpers aligned with tests/test_wifi.py (status polling, fatal codes)."""
import gc
import machine
import utime
import network
@@ -57,6 +59,40 @@ def _one_association_campaign(sta_if, ssid, password, wdt):
return True
def boot_sta(settings, wdt):
"""Tear down and bring up STA. Call before large heap users (NeoPixel, patterns).
On ESP32-C3, soft reboots can leave the Wi-Fi driver allocated; init while the
heap is still free. If re-init fails after a soft reboot, hard-reset once.
"""
sta_if = network.WLAN(network.STA_IF)
try:
if sta_if.active():
try:
sta_if.disconnect()
except Exception:
pass
sta_if.active(False)
except Exception:
pass
utime.sleep_ms(100)
gc.collect()
try:
sta_if.active(True)
except OSError as e:
err = str(e)
if "Out of Memory" in err or "WiFi" in err:
if machine.reset_cause() == machine.SOFT_RESET:
print("wifi_sta: init failed after soft reboot, hard reset:", err)
machine.reset()
raise
sta_if.config(pm=network.WLAN.PM_NONE)
ssid = settings.get("ssid") or ""
if ssid:
connect_until_up(sta_if, ssid, settings.get("password") or "", wdt)
return sta_if
def connect_until_up(sta_if, ssid, password, wdt):
"""Boot: repeat campaigns until STA has a route (same strategy as tests/test_wifi.py)."""
if not ssid: