Compare commits

4 Commits

Author SHA1 Message Date
fbebe9f4f9 fix(patterns): correct non-blocking timing and blink off phase
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:52 +12:00
a79c6f4dd3 fix(patterns): remove blocking sleeps from pattern loops
Replace sleep-based timing in pattern generators with non-blocking tick checks so long delays do not block the main loop and risk watchdog resets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:37:33 +12:00
pi
2fcaf2f064 fix(driver): persist brightness when message includes save and b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:23 +12:00
pi
3b38264b70 chore(wifi): log connecting while waiting for STA
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:29 +12:00
22 changed files with 145 additions and 55 deletions

View File

@@ -46,6 +46,8 @@ def process_data(payload, settings, presets, controller_ip=None):
presets.save() presets.save()
if "save" in data and "clear_presets" in data: if "save" in data and "clear_presets" in data:
presets.save() presets.save()
if "save" in data and "b" in data:
settings.save()
def apply_brightness(data, settings, presets): def apply_brightness(data, settings, presets):

View File

@@ -54,6 +54,7 @@ sta_if.active(True)
sta_if.config(pm=network.WLAN.PM_NONE) sta_if.config(pm=network.WLAN.PM_NONE)
sta_if.connect(settings["ssid"], settings["password"]) sta_if.connect(settings["ssid"], settings["password"])
while not sta_if.isconnected(): while not sta_if.isconnected():
print("Connecting")
utime.sleep(1) utime.sleep(1)
wdt.feed() wdt.feed()

View File

@@ -7,15 +7,23 @@ class BarGraph:
def run(self, preset): def run(self, preset):
colors = preset.c if preset.c else [(0, 255, 0), (255, 80, 0)] colors = preset.c if preset.c else [(0, 255, 0), (255, 80, 0)]
level = max(0, min(100, int(preset.n1) if int(preset.n1) >= 0 else 50)) last_update = utime.ticks_ms()
target = (self.driver.num_leds * level) // 100
lit = self.driver.apply_brightness(colors[0], preset.b)
unlit = self.driver.apply_brightness(colors[1] if len(colors) > 1 else (0, 0, 0), preset.b)
while True: while True:
for i in range(self.driver.num_leds): delay_ms = max(1, int(preset.d))
self.driver.n[i] = lit if i < target else unlit now = utime.ticks_ms()
self.driver.n.write() if utime.ticks_diff(now, last_update) >= delay_ms:
level = max(0, min(100, int(preset.n1) if int(preset.n1) >= 0 else 50))
target = (self.driver.num_leds * level) // 100
lit = self.driver.apply_brightness(colors[0], preset.b)
unlit = self.driver.apply_brightness(
colors[-1],
preset.b,
)
for i in range(self.driver.num_leds):
self.driver.n[i] = lit if i < target else unlit
self.driver.n.write()
last_update = utime.ticks_add(last_update, delay_ms)
if not preset.a:
yield
return
yield yield
if not preset.a:
return
utime.sleep_ms(max(1, int(preset.d)))

View File

@@ -25,7 +25,7 @@ class Blink:
# Advance to next color for the next "on" phase # Advance to next color for the next "on" phase
color_index += 1 color_index += 1
else: else:
# "Off" phase: turn all LEDs off # "Off" phase should actually be off.
self.driver.fill((0, 0, 0)) self.driver.fill((0, 0, 0))
state = not state state = not state
last_update = utime.ticks_add(last_update, delay_ms) last_update = utime.ticks_add(last_update, delay_ms)

View File

@@ -26,6 +26,7 @@ class Chase:
color0 = self.driver.apply_brightness(color0, preset.b) color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b) color1 = self.driver.apply_brightness(color1, preset.b)
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0 n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1 n2 = max(1, int(preset.n2)) # LEDs of color 1
@@ -53,7 +54,7 @@ class Chase:
# If auto is False, run a single step and then stop # If auto is False, run a single step and then stop
if not preset.a: if not preset.a:
# Clear all LEDs # Clear all LEDs
self.driver.n.fill((0, 0, 0)) self.driver.n.fill(bg_color)
# Draw repeating pattern starting at position # Draw repeating pattern starting at position
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
@@ -98,7 +99,7 @@ class Chase:
position += max_pos position += max_pos
# Clear all LEDs # Clear all LEDs
self.driver.n.fill((0, 0, 0)) self.driver.n.fill(bg_color)
# Draw repeating pattern starting at position # Draw repeating pattern starting at position
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):

View File

@@ -31,10 +31,10 @@ class Circle:
base0 = base1 = (255, 255, 255) base0 = base1 = (255, 255, 255)
elif len(colors) == 1: elif len(colors) == 1:
base0 = colors[0] base0 = colors[0]
base1 = (0, 0, 0) base1 = colors[-1]
else: else:
base0 = colors[0] base0 = colors[0]
base1 = colors[1] base1 = colors[-1]
color0 = self.driver.apply_brightness(base0, preset.b) color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b) color1 = self.driver.apply_brightness(base1, preset.b)
@@ -46,7 +46,7 @@ class Circle:
if phase == "off": if phase == "off":
self.driver.n.fill(color1) self.driver.n.fill(color1)
else: else:
self.driver.n.fill((0, 0, 0)) self.driver.n.fill(color1)
# Calculate segment length # Calculate segment length
segment_length = (head - tail) % self.driver.num_leds segment_length = (head - tail) % self.driver.num_leds

View File

@@ -15,7 +15,7 @@ class ClockSweep:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg = self.driver.apply_brightness(colors[1] if len(colors) > 1 else (0, 0, 0), preset.b) bg = self.driver.apply_brightness(colors[-1], preset.b)
fg = self.driver.apply_brightness(colors[0], preset.b) fg = self.driver.apply_brightness(colors[0], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = bg self.driver.n[i] = bg

View File

@@ -17,8 +17,9 @@ class CometDual:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b) 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) c2 = self.driver.apply_brightness(colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b)
for t in range(tail): for t in range(tail):

View File

@@ -16,8 +16,9 @@ class Fireflies:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
for b in bugs: for b in bugs:
idx, ph = b idx, ph = b
tri = 255 - abs(128 - ph) * 2 tri = 255 - abs(128 - ph) * 2

View File

@@ -7,19 +7,30 @@ class Heartbeat:
def run(self, preset): def run(self, preset):
colors = preset.c if preset.c else [(255, 0, 40)] colors = preset.c if preset.c else [(255, 0, 40)]
p1 = max(20, int(preset.n1) if int(preset.n1) > 0 else 120) phase = 0
p2 = max(20, int(preset.n2) if int(preset.n2) > 0 else 80) phase_start = utime.ticks_ms()
pause = max(20, int(preset.n3) if int(preset.n3) > 0 else 500) did_manual_pulse = False
while True: while True:
c = self.driver.apply_brightness(colors[0], preset.b) p1 = max(20, int(preset.n1) if int(preset.n1) > 0 else 120)
self.driver.fill(c) p2 = max(20, int(preset.n2) if int(preset.n2) > 0 else 80)
utime.sleep_ms(p1) pause = max(20, int(preset.n3) if int(preset.n3) > 0 else 500)
self.driver.fill((0, 0, 0)) beat_gap = max(20, int(preset.d))
utime.sleep_ms(max(20, int(preset.d))) colors = preset.c if preset.c else [(255, 0, 40)]
self.driver.fill(c) lit_color = self.driver.apply_brightness(colors[0], preset.b)
utime.sleep_ms(p2) bg_color = self.driver.apply_brightness(colors[-1], preset.b)
self.driver.fill((0, 0, 0)) phase_durations = (p1, beat_gap, p2, pause)
utime.sleep_ms(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 yield
if not preset.a: if not preset.a:
return if did_manual_pulse or phase == 0:
self.driver.fill(bg_color)
yield
return
did_manual_pulse = True

View File

@@ -17,9 +17,10 @@ class Marquee:
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
c = self.driver.apply_brightness(colors[0], preset.b) c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
m = (i + phase) % (on_len + off_len) m = (i + phase) % (on_len + off_len)
self.driver.n[i] = c if m < on_len else (0, 0, 0) self.driver.n[i] = c if m < on_len else bg_color
self.driver.n.write() self.driver.n.write()
phase = (phase + step) % (on_len + off_len) phase = (phase + step) % (on_len + off_len)
self.driver.step = phase self.driver.step = phase

View File

@@ -15,8 +15,9 @@ class Orbit:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
for k in range(orbits): for k in range(orbits):
idx = ((phase * (k + 1)) // 8 + (k * self.driver.num_leds // max(1, orbits))) % max(1, self.driver.num_leds) idx = ((phase * (k + 1)) // 8 + (k * self.driver.num_leds // max(1, orbits))) % max(1, self.driver.num_leds)
self.driver.n[idx] = self.driver.apply_brightness(colors[k % len(colors)], preset.b) self.driver.n[idx] = self.driver.apply_brightness(colors[k % len(colors)], preset.b)

View File

@@ -34,9 +34,15 @@ class PaletteMorph:
base_idx = 0 base_idx = 0
start = utime.ticks_ms() start = utime.ticks_ms()
phase = self.driver.step % 256 phase = self.driver.step % 256
last_update = start
while True: while True:
now = utime.ticks_ms() now = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = utime.ticks_add(last_update, delay_ms)
age = utime.ticks_diff(now, start) age = utime.ticks_diff(now, start)
if age < morph: if age < morph:
t = (age * 255) // morph t = (age * 255) // morph
@@ -72,5 +78,4 @@ class PaletteMorph:
return return
phase = (phase + warp_rate) & 255 phase = (phase + warp_rate) & 255
self.driver.step = phase self.driver.step = phase
utime.sleep_ms(max(1, int(preset.d)))
yield yield

View File

@@ -18,6 +18,7 @@ class Pulse:
# State machine based pulse using a single generator loop # State machine based pulse using a single generator loop
while True: while True:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
# Read current timing parameters from preset # Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms hold_ms = max(0, int(preset.n2)) # Hold time in ms
@@ -49,7 +50,7 @@ class Pulse:
self.driver.fill(self.driver.apply_brightness(color, preset.b)) self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < total_ms: elif elapsed < total_ms:
# Delay phase: LEDs off between pulses # Delay phase: LEDs off between pulses
self.driver.fill((0, 0, 0)) self.driver.fill(bg_color)
else: else:
# End of cycle, move to next color and restart timing # End of cycle, move to next color and restart timing
color_index += 1 color_index += 1

View File

@@ -17,7 +17,7 @@ class Radiate:
""" """
colors = preset.c if preset.c else [(255, 255, 255)] colors = preset.c if preset.c else [(255, 255, 255)]
base_on = colors[0] base_on = colors[0]
base_off = colors[1] if len(colors) > 1 else (0, 0, 0) base_off = colors[-1]
spacing = max(1, int(preset.n1)) spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2)) outward_ms = max(1, int(preset.n2))

View File

@@ -16,8 +16,9 @@ class RainDrops:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
if random.randint(0, 255) < rate: if random.randint(0, 255) < rate:
drops.append([random.randint(0, max(0, self.driver.num_leds - 1)), 0]) drops.append([random.randint(0, max(0, self.driver.num_leds - 1)), 0])
nd = [] nd = []

View File

@@ -27,12 +27,13 @@ class Scanner:
if utime.ticks_diff(now, last_update) >= delay_ms: if utime.ticks_diff(now, last_update) >= delay_ms:
base = colors[color_index % len(colors)] base = colors[color_index % len(colors)]
base = self.driver.apply_brightness(base, preset.b) base = self.driver.apply_brightness(base, preset.b)
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
dist = i - center dist = i - center
if dist < 0: if dist < 0:
dist = -dist dist = -dist
if dist > width: if dist > width:
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
else: else:
scale = ((width - dist) * 255) // max(1, width) scale = ((width - dist) * 255) // max(1, width)
self.driver.n[i] = ( self.driver.n[i] = (

View File

@@ -24,13 +24,14 @@ class SegmentChase:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
seg_idx = i // seg seg_idx = i // seg
in_seg = i % seg in_seg = i % seg
local_phase = (phase + seg_idx * seg_offset) % seg local_phase = (phase + seg_idx * seg_offset) % seg
lit_idx = (in_seg + local_phase) % seg lit_idx = (in_seg + local_phase) % seg
if gap > 0 and lit_idx >= max(1, seg - gap): if gap > 0 and lit_idx >= max(1, seg - gap):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
else: else:
color_idx = seg_idx % len(colors) color_idx = seg_idx % len(colors)
self.driver.n[i] = self.driver.apply_brightness(colors[color_idx], preset.b) self.driver.n[i] = self.driver.apply_brightness(colors[color_idx], preset.b)

View File

@@ -16,10 +16,11 @@ class Snowfall:
d = max(1, int(preset.d)) d = max(1, int(preset.d))
now = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d: if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
if random.randint(0, 255) < density: if random.randint(0, 255) < density:
flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors)-1)]) flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors)-1)])
for i in range(self.driver.num_leds): for i in range(self.driver.num_leds):
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
nf = [] nf = []
for pos, ci in flakes: for pos, ci in flakes:
if 0 <= pos < self.driver.num_leds: if 0 <= pos < self.driver.num_leds:

View File

@@ -7,18 +7,39 @@ class StrobeBurst:
def run(self, preset): def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255)] colors = preset.c if preset.c else [(255, 255, 255)]
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 3) state = "flash_on"
gap = max(1, int(preset.n2) if int(preset.n2) > 0 else 60) flash_idx = 0
cooldown = max(1, int(preset.n3) if int(preset.n3) > 0 else 400) state_start = utime.ticks_ms()
c = self.driver.apply_brightness(colors[0], preset.b)
while True: while True:
for _ in range(count): count = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
gap = max(1, int(preset.n2) if int(preset.n2) > 0 else 60)
cooldown = max(1, int(preset.n3) if int(preset.n3) > 0 else 400)
on_ms = max(1, int(preset.d) // 2)
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(colors[-1], preset.b)
now = utime.ticks_ms()
if state == "flash_on":
self.driver.fill(c) self.driver.fill(c)
utime.sleep_ms(max(1, int(preset.d)//2)) if utime.ticks_diff(now, state_start) >= on_ms:
self.driver.fill((0, 0, 0)) state = "flash_off"
utime.sleep_ms(gap) state_start = utime.ticks_add(state_start, on_ms)
yield elif state == "flash_off":
utime.sleep_ms(cooldown) self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= gap:
flash_idx += 1
if flash_idx >= count:
if not preset.a:
return
state = "cooldown"
flash_idx = 0
state_start = utime.ticks_add(state_start, gap)
else:
state = "flash_on"
state_start = utime.ticks_add(state_start, gap)
else:
self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= cooldown:
state = "flash_on"
state_start = utime.ticks_add(state_start, cooldown)
yield yield
if not preset.a:
return

View File

@@ -39,6 +39,7 @@ class Twinkle:
"""Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs.""" """Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs."""
palette = self._palette(preset) palette = self._palette(preset)
num = self.driver.num_leds num = self.driver.num_leds
bg_color = self.driver.apply_brightness(palette[-1], preset.b)
if num <= 0: if num <= 0:
while True: while True:
yield yield
@@ -125,7 +126,7 @@ class Twinkle:
base = palette[colour_i[i] % len(palette)] base = palette[colour_i[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b) self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else: else:
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
self.driver.n.write() self.driver.n.write()
yield yield
return return
@@ -185,7 +186,7 @@ class Twinkle:
base = palette[next_ci[i] % len(palette)] base = palette[next_ci[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b) self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else: else:
self.driver.n[i] = (0, 0, 0) self.driver.n[i] = bg_color
self.driver.n.write() self.driver.n.write()
on = next_on on = next_on
colour_i = next_ci colour_i = next_ci

View File

@@ -184,6 +184,36 @@ def test_pattern_smoke():
ctx.tick_for_ms(120) ctx.tick_for_ms(120)
def test_patterns_do_not_use_blocking_sleep():
pattern_dir = "patterns"
offenders = []
try:
files = os.listdir(pattern_dir)
except OSError:
raise AssertionError("patterns directory is missing")
for filename in files:
if not filename.endswith(".py") or filename in ("__init__.py", "main.py"):
continue
path = pattern_dir + "/" + filename
try:
with open(path, "r") as f:
src = f.read()
except OSError:
offenders.append(filename + " (unreadable)")
continue
if (
"utime.sleep(" in src
or "utime.sleep_ms(" in src
or "time.sleep(" in src
or "time.sleep_ms(" in src
):
offenders.append(filename)
assert not offenders, "blocking sleep found in patterns: %s" % ", ".join(offenders)
def test_default_requires_existing_preset(): def test_default_requires_existing_preset():
ctx = _TestContext() ctx = _TestContext()
_process_message(ctx, {"v": "1", "default": "missing"}) _process_message(ctx, {"v": "1", "default": "missing"})
@@ -242,6 +272,7 @@ def run_all():
test_preset_edit_sanitization, test_preset_edit_sanitization,
test_colour_conversion_and_transition, test_colour_conversion_and_transition,
test_pattern_smoke, test_pattern_smoke,
test_patterns_do_not_use_blocking_sleep,
test_default_requires_existing_preset, test_default_requires_existing_preset,
test_default_targets_gate_by_device_name, test_default_targets_gate_by_device_name,
test_save_and_load_roundtrip, test_save_and_load_roundtrip,