diff --git a/src/patterns/bar_graph.py b/src/patterns/bar_graph.py index bb6db52..9b28485 100644 --- a/src/patterns/bar_graph.py +++ b/src/patterns/bar_graph.py @@ -16,7 +16,7 @@ class BarGraph: 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), + colors[-1], preset.b, ) for i in range(self.driver.num_leds): diff --git a/src/patterns/blink.py b/src/patterns/blink.py index 4137754..5d03742 100644 --- a/src/patterns/blink.py +++ b/src/patterns/blink.py @@ -25,7 +25,7 @@ class Blink: # Advance to next color for the next "on" phase color_index += 1 else: - # "Off" phase: turn all LEDs off + # "Off" phase should actually be off. self.driver.fill((0, 0, 0)) state = not state last_update = utime.ticks_add(last_update, delay_ms) diff --git a/src/patterns/chase.py b/src/patterns/chase.py index 6edb4c8..490b1ae 100644 --- a/src/patterns/chase.py +++ b/src/patterns/chase.py @@ -26,6 +26,7 @@ class Chase: color0 = self.driver.apply_brightness(color0, 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 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 not preset.a: # Clear all LEDs - self.driver.n.fill((0, 0, 0)) + self.driver.n.fill(bg_color) # Draw repeating pattern starting at position for i in range(self.driver.num_leds): @@ -98,7 +99,7 @@ class Chase: position += max_pos # Clear all LEDs - self.driver.n.fill((0, 0, 0)) + self.driver.n.fill(bg_color) # Draw repeating pattern starting at position for i in range(self.driver.num_leds): diff --git a/src/patterns/circle.py b/src/patterns/circle.py index f31d699..c7c55f7 100644 --- a/src/patterns/circle.py +++ b/src/patterns/circle.py @@ -31,10 +31,10 @@ class Circle: base0 = base1 = (255, 255, 255) elif len(colors) == 1: base0 = colors[0] - base1 = (0, 0, 0) + base1 = colors[-1] else: base0 = colors[0] - base1 = colors[1] + base1 = colors[-1] color0 = self.driver.apply_brightness(base0, preset.b) color1 = self.driver.apply_brightness(base1, preset.b) @@ -46,7 +46,7 @@ class Circle: if phase == "off": self.driver.n.fill(color1) else: - self.driver.n.fill((0, 0, 0)) + self.driver.n.fill(color1) # Calculate segment length segment_length = (head - tail) % self.driver.num_leds diff --git a/src/patterns/clock_sweep.py b/src/patterns/clock_sweep.py index 17b4dd5..546895d 100644 --- a/src/patterns/clock_sweep.py +++ b/src/patterns/clock_sweep.py @@ -15,7 +15,7 @@ class ClockSweep: d = max(1, int(preset.d)) now = utime.ticks_ms() 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) for i in range(self.driver.num_leds): self.driver.n[i] = bg diff --git a/src/patterns/comet_dual.py b/src/patterns/comet_dual.py index 0d62d57..4614eed 100644 --- a/src/patterns/comet_dual.py +++ b/src/patterns/comet_dual.py @@ -17,8 +17,9 @@ class CometDual: d = max(1, int(preset.d)) now = utime.ticks_ms() 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): - self.driver.n[i] = (0, 0, 0) + 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): diff --git a/src/patterns/fireflies.py b/src/patterns/fireflies.py index 20a7ad9..2896233 100644 --- a/src/patterns/fireflies.py +++ b/src/patterns/fireflies.py @@ -16,8 +16,9 @@ class Fireflies: d = max(1, int(preset.d)) now = utime.ticks_ms() 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): - self.driver.n[i] = (0, 0, 0) + self.driver.n[i] = bg_color for b in bugs: idx, ph = b tri = 255 - abs(128 - ph) * 2 diff --git a/src/patterns/heartbeat.py b/src/patterns/heartbeat.py index c14b4e4..f7c1845 100644 --- a/src/patterns/heartbeat.py +++ b/src/patterns/heartbeat.py @@ -9,22 +9,28 @@ class Heartbeat: colors = preset.c if preset.c else [(255, 0, 40)] phase = 0 phase_start = utime.ticks_ms() - lit_color = self.driver.apply_brightness(colors[0], preset.b) + 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(colors[-1], preset.b) phase_durations = (p1, beat_gap, p2, pause) - phase_colors = (lit_color, (0, 0, 0), lit_color, (0, 0, 0)) + phase_colors = (lit_color, bg_color, lit_color, bg_color) now = utime.ticks_ms() - if utime.ticks_diff(now, phase_start) >= phase_durations[phase]: + while utime.ticks_diff(now, phase_start) >= phase_durations[phase]: + phase_start = utime.ticks_add(phase_start, phase_durations[phase]) phase = (phase + 1) % 4 - phase_start = utime.ticks_add(phase_start, phase_durations[(phase - 1) % 4]) self.driver.fill(phase_colors[phase]) yield - if not preset.a and phase == 0: - return + if not preset.a: + if did_manual_pulse or phase == 0: + self.driver.fill(bg_color) + yield + return + did_manual_pulse = True diff --git a/src/patterns/marquee.py b/src/patterns/marquee.py index 0bfab28..1c27cb7 100644 --- a/src/patterns/marquee.py +++ b/src/patterns/marquee.py @@ -17,9 +17,10 @@ class Marquee: 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(colors[-1], 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 (0, 0, 0) + 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 diff --git a/src/patterns/orbit.py b/src/patterns/orbit.py index 5ffb139..426a6f9 100644 --- a/src/patterns/orbit.py +++ b/src/patterns/orbit.py @@ -15,8 +15,9 @@ class Orbit: d = max(1, int(preset.d)) now = utime.ticks_ms() 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): - self.driver.n[i] = (0, 0, 0) + self.driver.n[i] = bg_color for k in range(orbits): 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) diff --git a/src/patterns/pulse.py b/src/patterns/pulse.py index 1faf020..e40384e 100644 --- a/src/patterns/pulse.py +++ b/src/patterns/pulse.py @@ -18,6 +18,7 @@ class Pulse: # State machine based pulse using a single generator loop while True: + bg_color = self.driver.apply_brightness(colors[-1], preset.b) # Read current timing parameters from preset attack_ms = max(0, int(preset.n1)) # Attack 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)) elif elapsed < total_ms: # Delay phase: LEDs off between pulses - self.driver.fill((0, 0, 0)) + self.driver.fill(bg_color) else: # End of cycle, move to next color and restart timing color_index += 1 diff --git a/src/patterns/radiate.py b/src/patterns/radiate.py index c0214fd..4a4f92c 100644 --- a/src/patterns/radiate.py +++ b/src/patterns/radiate.py @@ -17,7 +17,7 @@ class Radiate: """ colors = preset.c if preset.c else [(255, 255, 255)] 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)) outward_ms = max(1, int(preset.n2)) diff --git a/src/patterns/rain_drops.py b/src/patterns/rain_drops.py index 96bc9d5..f072003 100644 --- a/src/patterns/rain_drops.py +++ b/src/patterns/rain_drops.py @@ -16,8 +16,9 @@ class RainDrops: d = max(1, int(preset.d)) now = utime.ticks_ms() 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): - self.driver.n[i] = (0, 0, 0) + self.driver.n[i] = bg_color if random.randint(0, 255) < rate: drops.append([random.randint(0, max(0, self.driver.num_leds - 1)), 0]) nd = [] diff --git a/src/patterns/scanner.py b/src/patterns/scanner.py index 8f58d20..db38789 100644 --- a/src/patterns/scanner.py +++ b/src/patterns/scanner.py @@ -27,12 +27,13 @@ class Scanner: 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(colors[-1], 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] = (0, 0, 0) + self.driver.n[i] = bg_color else: scale = ((width - dist) * 255) // max(1, width) self.driver.n[i] = ( diff --git a/src/patterns/segment_chase.py b/src/patterns/segment_chase.py index 6f14ef8..4ef69dd 100644 --- a/src/patterns/segment_chase.py +++ b/src/patterns/segment_chase.py @@ -24,13 +24,14 @@ class SegmentChase: d = max(1, int(preset.d)) now = utime.ticks_ms() 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): 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] = (0, 0, 0) + 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) diff --git a/src/patterns/snowfall.py b/src/patterns/snowfall.py index 03e9159..ac8c306 100644 --- a/src/patterns/snowfall.py +++ b/src/patterns/snowfall.py @@ -16,10 +16,11 @@ class Snowfall: d = max(1, int(preset.d)) now = utime.ticks_ms() if utime.ticks_diff(now, last) >= d: + bg_color = self.driver.apply_brightness(colors[-1], 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] = (0, 0, 0) + self.driver.n[i] = bg_color nf = [] for pos, ci in flakes: if 0 <= pos < self.driver.num_leds: diff --git a/src/patterns/strobe_burst.py b/src/patterns/strobe_burst.py index aaafc9c..731e61f 100644 --- a/src/patterns/strobe_burst.py +++ b/src/patterns/strobe_burst.py @@ -16,6 +16,7 @@ class StrobeBurst: 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": @@ -24,7 +25,7 @@ class StrobeBurst: state = "flash_off" state_start = utime.ticks_add(state_start, on_ms) elif state == "flash_off": - self.driver.fill((0, 0, 0)) + self.driver.fill(bg_color) if utime.ticks_diff(now, state_start) >= gap: flash_idx += 1 if flash_idx >= count: @@ -37,7 +38,7 @@ class StrobeBurst: state = "flash_on" state_start = utime.ticks_add(state_start, gap) else: - self.driver.fill((0, 0, 0)) + self.driver.fill(bg_color) if utime.ticks_diff(now, state_start) >= cooldown: state = "flash_on" state_start = utime.ticks_add(state_start, cooldown) diff --git a/src/patterns/twinkle.py b/src/patterns/twinkle.py index 3741980..3f17e6a 100644 --- a/src/patterns/twinkle.py +++ b/src/patterns/twinkle.py @@ -39,6 +39,7 @@ class Twinkle: """Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs.""" palette = self._palette(preset) num = self.driver.num_leds + bg_color = self.driver.apply_brightness(palette[-1], preset.b) if num <= 0: while True: yield @@ -125,7 +126,7 @@ class Twinkle: base = palette[colour_i[i] % len(palette)] self.driver.n[i] = self.driver.apply_brightness(base, preset.b) else: - self.driver.n[i] = (0, 0, 0) + self.driver.n[i] = bg_color self.driver.n.write() yield return @@ -185,7 +186,7 @@ class Twinkle: base = palette[next_ci[i] % len(palette)] self.driver.n[i] = self.driver.apply_brightness(base, preset.b) else: - self.driver.n[i] = (0, 0, 0) + self.driver.n[i] = bg_color self.driver.n.write() on = next_on colour_i = next_ci diff --git a/tests/all.py b/tests/all.py index a637f3e..b6e0549 100644 --- a/tests/all.py +++ b/tests/all.py @@ -184,6 +184,36 @@ def test_pattern_smoke(): 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(): ctx = _TestContext() _process_message(ctx, {"v": "1", "default": "missing"}) @@ -242,6 +272,7 @@ def run_all(): test_preset_edit_sanitization, test_colour_conversion_and_transition, test_pattern_smoke, + test_patterns_do_not_use_blocking_sleep, test_default_requires_existing_preset, test_default_targets_gate_by_device_name, test_save_and_load_roundtrip,