Rework roll pattern to use gradient palette and add preset-based tests

Made-with: Cursor
This commit is contained in:
2026-03-06 01:39:40 +13:00
parent a0687cff57
commit 292c5bde01
7 changed files with 442 additions and 309 deletions

View File

@@ -1,4 +1,5 @@
import utime
import math
class Roll:
@@ -6,149 +7,105 @@ class Roll:
self.driver = driver
def run(self, preset):
"""Roll: moving band with gradient from color1 to color2 over the strips.
"""Roll: all strips show a shared color gradient palette, cycling out of phase.
- n1: offset from start of strip (effective start = start + n1)
- n2: offset from end of strip (effective end = end - n2, inclusive)
- n3: number of full rotations before stopping (0 = infinite)
- All strips participate; each frame shows N discrete colors
(one per strip), from color1 to color2.
- Over time, each strip cycles through all colors, out of phase with the
others, creating a smooth rolling band around the hoop.
- n4: direction (0 = clockwise, 1 = anti-clockwise)
- c[0]: color1 at the head strip
- c[1]: color2 at the tail strip
- c[0]: head color (full intensity)
- c[1]: tail color (usually darker or off)
"""
colors = preset.c
color1_raw = colors[0] if colors else (255, 255, 255)
color2_raw = colors[1] if len(colors) > 1 else (0, 0, 0)
color1 = self.driver.apply_brightness(color1_raw, preset.b)
color2 = self.driver.apply_brightness(color2_raw, preset.b)
base1 = colors[0] if colors else (255, 255, 255)
base2 = colors[1] if len(colors) > 1 else (0, 0, 0)
color1 = self.driver.apply_brightness(base1, preset.b)
color2 = self.driver.apply_brightness(base2, preset.b)
n_segments = self.driver.n.num_strips if hasattr(self.driver.n, "num_strips") else 1
# Margins from the start and end of each strip
start_margin = max(0, int(getattr(preset, "n1", 0)))
end_margin = max(0, int(getattr(preset, "n2", 0)))
# Debug info to see why roll might be black
try:
print(
"ROLL preset",
"p=", getattr(preset, "p", None),
"b=", getattr(preset, "b", None),
"colors_raw=", color1_raw, color2_raw,
"colors_bright=", color1, color2,
)
print(
"ROLL n1..n4",
getattr(preset, "n1", None),
getattr(preset, "n2", None),
getattr(preset, "n3", None),
getattr(preset, "n4", None),
"n_segments=", n_segments,
"start_margin=", start_margin,
"end_margin=", end_margin,
)
except Exception:
pass
# n3: number of rotations (0 = infinite)
max_rotations = int(getattr(preset, "n3", 0)) or 0
# n3: number of full rotations before stopping (0 = continuous)
max_rotations = int(getattr(preset, "n3", 0) or 0)
# n4: direction (0=cw, 1=ccw); default clockwise if missing
clockwise = int(getattr(preset, "n4", 0)) == 0
step = self.driver.step
delay_ms = max(1, int(preset.d) or 1)
last_update = utime.ticks_ms()
rotations_done = 0
def scale_color(c, f):
return tuple(int(x * f) for x in c)
# Precompute one shared buffer per brightness level (one per strip),
# using the longest strip length so any strip can DMA from it safely.
strips_list = self.driver.strips
if not strips_list or n_segments <= 0:
while True:
yield
def lerp_color(c1, c2, t):
"""Linear gradient between two colors."""
if t <= 0:
return c1
if t >= 1:
return c2
return (
int(c1[0] + (c2[0] - c1[0]) * t),
int(c1[1] + (c2[1] - c1[1]) * t),
int(c1[2] + (c2[2] - c1[2]) * t),
)
max_leds = max(s.num_leds for s in strips_list)
def draw(head):
# Remember head strip for flare
# Build N discrete color buffers forming a gradient from color1 to color2.
buffers = []
for j in range(n_segments):
if n_segments > 1:
t = j / (n_segments - 1)
else:
t = 0.0
# Linear interpolation between color1 and color2
r = int(color1[0] + (color2[0] - color1[0]) * t)
g = int(color1[1] + (color2[1] - color1[1]) * t)
b = int(color1[2] + (color2[2] - color1[2]) * t)
buf = bytearray(max_leds * 3)
for i in range(max_leds):
o = i * 3
buf[o] = g & 0xFF
buf[o + 1] = r & 0xFF
buf[o + 2] = b & 0xFF
buffers.append(buf)
def draw(step_index):
# Each strip picks a buffer index offset by its strip index so that:
# - all brightness levels are visible simultaneously (one per strip)
# - over time, each strip cycles through all brightness levels
try:
self.driver.last_roll_head = head
self.driver.last_roll_head = step_index % n_segments
except AttributeError:
pass
strips_list = self.driver.strips
for strip_idx, strip in enumerate(strips_list):
if strip_idx < 0 or strip_idx >= n_segments:
continue
# Distance from head along direction, 0..n_segments-1
if clockwise:
dist = (head - strip_idx) % n_segments
buf_index = (step_index + strip_idx) % n_segments
else:
dist = (strip_idx - head) % n_segments
buf_index = (step_index - strip_idx) % n_segments
buf = buffers[buf_index]
# Color gradient from color1 at the head strip to color2 at the tail strip
if n_segments > 1:
t = dist / (n_segments - 1)
else:
t = 0.0
c_strip = lerp_color(color1, color2, t)
n = strip.num_leds
# Effective segment per strip:
# start = 0 + start_margin
# end = (n - 1) - end_margin (inclusive)
width = n - start_margin - end_margin
if width <= 0:
# If margins are too large, fall back to full strip
seg_s = 0
seg_e = n
else:
seg_s = max(0, min(n, start_margin))
seg_e = min(n, n - end_margin)
# Debug for first strip/head to see segment
try:
if strip_idx == 0 and head == 0:
print("ROLL seg strip0 n=", n, "seg_s=", seg_s, "seg_e=", seg_e)
except Exception:
pass
for i in range(n):
if seg_s <= i < seg_e:
strip.set(i, c_strip)
else:
strip.set(i, (0, 0, 0))
strip.show()
# Show the shared buffer; WS2812B will read num_leds*3 bytes.
strip.show(buf, 0)
if not preset.a:
head = step % n_segments if n_segments > 0 else 0
draw(head)
draw(step)
self.driver.step = step + 1
yield
return
# Auto mode: advance based on preset.d (ms) for smooth, controllable speed
delay_ms = max(10, int(getattr(preset, "d", 60)) or 10)
last_update = utime.ticks_ms() - delay_ms
rotations_done = 0
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= delay_ms:
head = step % n_segments if n_segments > 0 else 0
if not clockwise and n_segments > 0:
head = (n_segments - 1 - head)
draw(head)
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
draw(step)
step += 1
self.driver.step = step
last_update = now
# Count full rotations if requested: one rotation per n_segments steps
if max_rotations > 0 and n_segments > 0 and (step % n_segments) == 0:
rotations_done += 1
if rotations_done >= max_rotations:
self.driver.step = step
last_update = current_time
return
self.driver.step = step
last_update = current_time
# Hold the final frame and stop advancing; keep yielding so
# the generator stays alive without changing the LEDs.
while True:
yield
yield

114
pico/test/roll.py Normal file
View File

@@ -0,0 +1,114 @@
"""
On-device visual test for the Roll pattern via Presets.
This exercises src/patterns/roll.py (gradient from color1 to color2 across strips),
not the low-level WS2812 driver.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/roll.py :
mpremote connect <device> run roll.py
"""
import utime
from presets import Presets, Preset
def make_roll_preset(name, color1, color2, delay_ms=60, brightness=255, direction=0):
"""
Helper to build a Preset for the 'roll' pattern.
- color1: head color (full intensity)
- color2: tail color (end of gradient)
- direction: 0 = clockwise, 1 = anti-clockwise
"""
data = {
"p": "roll",
"c": [color1, color2],
"b": brightness,
"d": delay_ms,
"n4": direction,
"a": True, # animated
}
return name, Preset(data)
def run_for(presets, duration_ms):
"""Tick the current pattern for duration_ms."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
presets.tick()
utime.sleep_ms(10)
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting roll test.")
return
print("Starting roll pattern gradient tests via Presets...")
# A few different roll presets to compare:
roll_presets = []
# 1. White → off, clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_white_off_cw",
color1=(255, 255, 255),
color2=(0, 0, 0),
delay_ms=120,
brightness=128,
direction=0,
)
)
# 2. Warm white → cool blue, clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_warm_cool_cw",
color1=(255, 200, 100),
color2=(0, 0, 255),
delay_ms=130,
brightness=128,
direction=0,
)
)
# 3. Red → green, counter-clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_red_green_ccw",
color1=(255, 0, 0),
color2=(0, 255, 0),
delay_ms=110,
brightness=128,
direction=1,
)
)
# Register presets and run them one after another
for name, preset_obj in roll_presets:
presets.presets[name] = preset_obj
for name, _preset in roll_presets:
print("Running roll preset:", name)
presets.select(name)
run_for(presets, duration_ms=8000)
print("Roll pattern Presets test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

View File

@@ -1,81 +0,0 @@
import math
import sys
if "lib" not in sys.path:
sys.path.insert(0, "lib")
if "../lib" not in sys.path:
sys.path.insert(0, "../lib")
from ws2812 import WS2812B
import time
# --- Roll: N buffers (length = max strip), gradient full -> off; sequence through strips ---
N_BUFFERS = 32 # more buffers = smoother transition
STRIP_CONFIG = (
(2, 291),
(3, 290),
(4, 283),
(7, 278),
(0, 275),
(28, 278),
(29, 283),
(6, 290),
)
strips = []
sm = 0
for pin, num_leds in STRIP_CONFIG:
print(pin, num_leds)
ws = WS2812B(num_leds, pin, sm, brightness=1.0)
strips.append(ws)
sm += 1
num_strips = len(strips)
max_leds = max(ws.num_leds for ws in strips)
# Color when "on" (R, G, B); GRB order in buffer
ROLL_COLOR = (0, 255, 120) # cyan-green
def make_gradient_buffers(n_buffers, max_leds, color):
"""Create n_buffers buffers, each max_leds long. Buffer 0 = full brightness, last = off.
Gradient is logarithmic (perceptually smoother: more steps near full, fewer near off). GRB order."""
out = []
for j in range(n_buffers):
# log gradient: scale = 255 * log(1 + (n - 1 - j)) / log(n) so 255 at j=0, 0 at j=n-1
if n_buffers <= 1:
scale = 255
elif j >= n_buffers - 1:
scale = 0
else:
# 1 + (n_buffers - 1 - j) runs from n_buffers down to 1
scale = int(255 * math.log(1 + (n_buffers - 1 - j)) / math.log(n_buffers))
scale = min(255, scale)
buf = bytearray(max_leds * 3)
r = (color[0] * scale) // 255
g = (color[1] * scale) // 255
b = (color[2] * scale) // 255
for i in range(max_leds):
o = i * 3
buf[o] = g & 0xFF
buf[o + 1] = r & 0xFF
buf[o + 2] = b & 0xFF
out.append(buf)
return out
# N buffers: first full, last off, gradient between
buffers = make_gradient_buffers(N_BUFFERS, max_leds, ROLL_COLOR)
step = 0
delay_ms = 50
# Deadline-based loop: no extra pause at rotation wrap, smooth continuous roll
next_ms = time.ticks_ms()
while True:
for i, strip in enumerate(strips):
buf_index = (step + i) % N_BUFFERS
strip.show(buffers[buf_index], 0)
step += 1 # unbounded; wrap only in index so no hitch at cycle end
next_ms += delay_ms
# Sleep until next frame time (handles drift, no pause at wrap)
while time.ticks_diff(next_ms, time.ticks_ms()) > 0:
time.sleep_ms(1)

6
pico/test/test_all_on.py Normal file
View File

@@ -0,0 +1,6 @@
from neopixel import NeoPixel
from machine import Pin
p = NeoPixel(Pin(6), 291)
p.fill((255, 255, 255))
p.write()

View File

@@ -0,0 +1,71 @@
"""
On-device test that turns all LEDs on via Presets and verifies strip 0 (pin 6).
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_all_on_presets.py :
mpremote connect <device> run test_all_on_presets.py
"""
from presets import Presets, Preset
def verify_strip0_on(presets, expected_color):
"""Check that every LED on strip 0 matches expected_color."""
if not presets.strips:
print("No strips; skipping strip-0 on test.")
return
strip = presets.strips[0]
r_exp, g_exp, b_exp = expected_color
for i in range(strip.num_leds):
o = i * 3
g = strip.ar[o]
r = strip.ar[o + 1]
b = strip.ar[o + 2]
if (r, g, b) != (r_exp, g_exp, b_exp):
raise AssertionError(
"Strip 0 LED %d: got (%d,%d,%d), expected (%d,%d,%d)"
% (i, r, g, b, r_exp, g_exp, b_exp)
)
def main():
presets = Presets()
if not presets.strips:
print("No strips; skipping all-on-presets test.")
return
# Full-brightness white via the built-in 'on' pattern.
base_color = (255, 255, 255)
brightness = 255
data = {
"p": "on",
"c": [base_color],
"b": brightness,
}
name = "test_all_on_presets"
preset = Preset(data)
presets.presets[name] = preset
presets.select(name)
presets.tick()
# Compute the color actually written by the pattern after brightness scaling.
expected_color = presets.apply_brightness(base_color, brightness)
verify_strip0_on(presets, expected_color)
print("test_all_on_presets: OK")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,184 @@
"""
On-device test that exercises the Chase pattern via Presets.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_chase_via_presets.py :
mpremote connect <device> run test_chase_via_presets.py
"""
import utime
from presets import Presets, Preset
def snapshot_strip_colors(presets, strip_idx=0, max_leds=32):
"""Return a list of (r,g,b) tuples for the first max_leds of the given strip."""
strip = presets.strips[strip_idx]
num = min(strip.num_leds, max_leds)
out = []
for i in range(num):
o = i * 3
g = strip.ar[o]
r = strip.ar[o + 1]
b = strip.ar[o + 2]
out.append((r, g, b))
return out
def expected_chase_color(i, num_leds, step_count, color0, color1, n1, n2, n3, n4):
"""Mirror the position logic from patterns/chase.py for a single logical LED."""
segment_length = n1 + n2
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
max_pos = num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
return color0 if relative_pos < n1 else color1
def test_chase_single_step_via_presets():
presets = Presets()
num_leds = presets.num_leds
if num_leds <= 0:
print("No strips; skipping chase test.")
return
# Simple alternating colors with known lengths.
base_color0 = (10, 0, 0)
base_color1 = (0, 0, 20)
# Use full brightness so apply_brightness is identity.
brightness = 255
n1 = 2
n2 = 3
# Same step size on even/odd for easier reasoning.
n3 = 1
n4 = 1
data = {
"p": "chase",
"c": [base_color0, base_color1],
"b": brightness,
"d": 0,
"a": False, # single-step mode
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
}
name = "test_chase_pattern"
preset = Preset(data)
presets.presets[name] = preset
# Select and run one tick; this should render exactly one chase frame for step 0.
presets.select(name, step=0)
presets.tick()
# Colors after brightness scaling (driver.apply_brightness is used in the pattern).
color0 = presets.apply_brightness(base_color0, brightness)
color1 = presets.apply_brightness(base_color1, brightness)
# Snapshot first few LEDs of strip 0 and compare against expected pattern for step 0.
colors = snapshot_strip_colors(presets, strip_idx=0, max_leds=16)
step_count = 0
for i, actual in enumerate(colors):
expected = expected_chase_color(
i, num_leds, step_count, color0, color1, n1, n2, n3, n4
)
assert (
actual == expected
), "LED %d: got %r, expected %r" % (i, actual, expected)
print("test_chase_single_step_via_presets: OK")
def test_chase_multiple_steps_via_presets():
"""Render several steps and verify pattern advances correctly."""
presets = Presets()
num_leds = presets.num_leds
if num_leds <= 0:
print("No strips; skipping chase multi-step test.")
return
base_color0 = (10, 0, 0)
base_color1 = (0, 0, 20)
brightness = 255
n1 = 2
n2 = 3
n3 = 1
n4 = 1
data = {
"p": "chase",
"c": [base_color0, base_color1],
"b": brightness,
"d": 0,
"a": False,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
}
name = "test_chase_pattern_multi"
preset = Preset(data)
presets.presets[name] = preset
color0 = presets.apply_brightness(base_color0, brightness)
color1 = presets.apply_brightness(base_color1, brightness)
# In non-auto mode (a=False), the Chase pattern advances one step per
# invocation of the generator, and Presets is expected to call select()
# again for each beat. Emulate that here by re-selecting with an
# explicit step value for each frame we want to test.
for step_count in range(4):
presets.select(name, step=step_count)
presets.tick()
colors = snapshot_strip_colors(presets, strip_idx=0, max_leds=16)
for i, actual in enumerate(colors):
expected = expected_chase_color(
i, num_leds, step_count, color0, color1, n1, n2, n3, n4
)
assert (
actual == expected
), "step %d, LED %d: got %r, expected %r" % (
step_count,
i,
actual,
expected,
)
print("test_chase_multiple_steps_via_presets: OK")
def main():
test_chase_single_step_via_presets()
test_chase_multiple_steps_via_presets()
# Give a brief pause so message is visible if run interactively.
utime.sleep_ms(100)
if __name__ == "__main__":
main()

View File

@@ -1,118 +0,0 @@
"""
On-device test for the Roll pattern using mpremote.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_roll.py :
mpremote connect <device> run test_roll.py
This script:
- Instantiates Presets
- Creates a few in-memory roll presets with different parameters
- Runs each one for a short time so you can visually compare behaviour
"""
import utime
from presets import Presets, Preset
def make_roll_preset(name, color1, color2, n1=0, n2=0, n3=0, n4=0, delay_ms=50, brightness=255):
"""Helper to build a Preset dict for the roll pattern."""
data = {
"p": "roll",
"c": [color1, color2],
"b": brightness,
"d": delay_ms,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"a": True, # animated
}
return name, Preset(data)
def run_preset(presets, name, preset_obj, duration_ms):
"""Run a given roll preset for duration_ms using the existing tick loop."""
presets.presets[name] = preset_obj
presets.select(name)
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
presets.tick()
def main():
presets = Presets()
presets.load()
print("Starting roll pattern test...")
ref_len = presets.strip_length(0)
# Use some margins based on strip length to show different bands.
quarter = ref_len // 4
# Define a few different roll presets:
roll_presets = []
# 1. Full-strip, white -> off, clockwise, medium speed
roll_presets.append(
make_roll_preset(
"roll_full_cw",
color1=(255, 255, 255),
color2=(0, 0, 0),
n1=0,
n2=0,
n3=2, # 2 rotations then stop
n4=0, # clockwise
delay_ms=40,
brightness=255,
)
)
# 2. Inner band only, red -> blue, clockwise, slower
roll_presets.append(
make_roll_preset(
"roll_inner_band",
color1=(255, 0, 0),
color2=(0, 0, 255),
n1=quarter, # start margin
n2=quarter, # end margin
n3=2,
n4=0,
delay_ms=60,
brightness=255,
)
)
# 3. Full-strip, green -> off, counter-clockwise, faster
roll_presets.append(
make_roll_preset(
"roll_full_ccw",
color1=(0, 255, 0),
color2=(0, 0, 0),
n1=0,
n2=0,
n3=2,
n4=1, # counter-clockwise
delay_ms=30,
brightness=255,
)
)
# Run each roll preset for about 5 seconds
for name, preset_obj in roll_presets:
print("Running roll preset:", name)
run_preset(presets, name, preset_obj, duration_ms=5000)
print("Roll pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()