Compare commits

..

5 Commits

Author SHA1 Message Date
292c5bde01 Rework roll pattern to use gradient palette and add preset-based tests
Made-with: Cursor
2026-03-06 01:39:40 +13:00
a0687cff57 Switch chase to double-buffered ring chase
Made-with: Cursor
2026-03-06 01:00:25 +13:00
5f457b3ae7 Refine calibration, chase, and spin patterns and add spin test
Increase calibration brightness, update chase to use the new logical ring abstraction, and make spin start from a cleared frame with symmetric arm speed, alongside a dedicated on-device spin test script.

Made-with: Cursor
2026-03-05 23:41:25 +13:00
3e58f4e97e Add segments and double_circle patterns with shared presets
Introduce double_circle and segments-based patterns on the Pico, refactor the Presets engine to expose a logical ring over all strips, and migrate presets/test code from the old point pattern to segments while switching to a top-level presets.json.

Made-with: Cursor
2026-03-05 23:41:13 +13:00
47c19eecf1 Clean up ESP32 buttons and UART tests
Remove unused buttons.json and legacy UART UART test scripts, and update the web UI preset dropdown for new Pico patterns.

Made-with: Cursor
2026-03-05 23:40:28 +13:00
26 changed files with 1477 additions and 623 deletions

View File

@@ -1,58 +0,0 @@
{
"buttons": [
{"id": "start", "preset": "off"},
{"id": "grab", "preset": "grab"},
{"id": "spin1", "preset": "spin1"},
{"id": "lift", "preset": "lift"},
{"id": "flare", "preset": "flare"},
{"id": "hook", "preset": "hook"},
{"id": "roll1", "preset": "roll1"},
{"id": "invertsplit", "preset": "invertsplit"},
{"id": "pose1", "preset": "pose1"},
{"id": "pose1", "preset": "pose2"},
{"id": "roll2", "preset": "roll2"},
{"id": "backbalance1", "preset": "backbalance1"},
{"id": "beat1", "preset": "beat1"},
{"id": "pose3", "preset": "pose3"},
{"id": "roll3", "preset": "roll3"},
{"id": "crouch", "preset": "crouch"},
{"id": "pose4", "preset": "pose4"},
{"id": "roll4", "preset": "roll4"},
{"id": "backbendsplit", "preset": "backbendsplit"},
{"id": "backbalance2", "preset": "backbalance2"},
{"id": "backbalance3", "preset": "backbalance3"},
{"id": "beat2", "preset": "beat2"},
{"id": "straddle", "preset": "straddle"},
{"id": "beat3", "preset": "beat3"},
{"id": "frontbalance1", "preset": "frontbalance1"},
{"id": "pose5", "preset": "pose5"},
{"id": "pose6", "preset": "pose6"},
{"id": "elbowhang", "preset": "elbowhang"},
{"id": "elbowhangspin", "preset": "elbowhangspin"},
{"id": "spin2", "preset": "spin2"},
{"id": "dismount", "preset": "dismount"},
{"id": "spin3", "preset": "spin3"},
{"id": "fluff", "preset": "fluff"},
{"id": "spin4", "preset": "spin4"},
{"id": "flare2", "preset": "flare2"},
{"id": "elbowhang", "preset": "elbowhang"},
{"id": "elbowhangsplit2", "preset": "elbowhangsplit2"},
{"id": "invert", "preset": "invert"},
{"id": "roll5", "preset": "roll5"},
{"id": "backbend", "preset": "backbend"},
{"id": "pose7", "preset": "pose7"},
{"id": "roll6", "preset": "roll6"},
{"id": "seat", "preset": "seat"},
{"id": "kneehang", "preset": "kneehang"},
{"id": "legswoop", "preset": "legswoop"},
{"id": "split", "preset": "split"},
{"id": "foothang", "preset": "foothang"},
{"id": "end", "preset": "end"}
]
}

View File

@@ -65,7 +65,9 @@
<option value="legswoop">legswoop</option>
<option value="split">split</option>
<option value="foothang">foothang</option>
<option value="point">point</option>
<option value="segments">segments</option>
<option value="segments_transition">segments_transition</option>
<option value="double_circle">double_circle</option>
<option value="off">off</option>
<option value="on">on</option>
<option value="blink">blink</option>

View File

@@ -1,64 +0,0 @@
"""
ESP32-C6 test: send JSON messages to Pico over UART (GPIO17).
Settings use strips = [[pin, num_leds], ...]. Run with Pico connected on RX.
Run with mpremote (from repo root):
./esp32/run_test_uart_json.sh
# or
mpremote run esp32/test/test_uart_send_json.py
# or with port
mpremote connect /dev/ttyUSB0 run esp32/test/test_uart_send_json.py
"""
import machine
import time
import json
UART_TX_PIN = 17
UART_BAUD = 921600
LED_PIN = 15
def send_json(uart, obj):
line = json.dumps(obj) + "\n"
uart.write(line)
print("TX:", line.strip())
def main():
uart = machine.UART(1, baudrate=UART_BAUD, tx=UART_TX_PIN)
led = machine.Pin(LED_PIN, machine.Pin.OUT)
# 1) Settings: one strip, pin 2, 10 LEDs (list of lists)
send_json(uart, {
"v": 1,
"settings": {
"strips": [[2, 10]],
"brightness": 30,
},
})
led.value(1)
time.sleep(0.2)
led.value(0)
time.sleep(0.3)
# 2) led-controller format: light + settings.color (hex)
send_json(uart, {"light": "strip1", "settings": {"color": "#FF0000"}, "save": False})
time.sleep(0.5)
# 3) led-controller format: light + settings.r,g,b
send_json(uart, {"light": "strip1", "settings": {"r": 0, "g": 255, "b": 0}, "save": False})
time.sleep(0.5)
# 4) led-controller format: blue (hex)
send_json(uart, {"light": "strip1", "settings": {"color": "#0000FF"}, "save": False})
time.sleep(0.5)
# 5) Off (existing format)
send_json(uart, {"v": 1, "off": True})
time.sleep(0.3)
print("Done. Pico: settings -> red (hex) -> green (r,g,b) -> blue (hex) -> off.")
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
"""
ESP32-C6 UART TX + LED test. Sends a few commands on GPIO17, blinks LED on GPIO15.
Run on device: exec(open('test/test_uart_tx').read()) or import test.test_uart_tx
Does not require Pico connected.
"""
import machine
import time
UART_TX_PIN = 17
LED_PIN = 15
def main():
uart = machine.UART(1, baudrate=115200, tx=UART_TX_PIN)
led = machine.Pin(LED_PIN, machine.Pin.OUT)
def send(cmd):
uart.write(cmd + "\n")
print("TX:", cmd)
# Blink and send a short command sequence
commands = ["off", "fill 255 0 0", "fill 0 255 0", "fill 0 0 255", "off"]
for i, cmd in enumerate(commands):
led.value(1)
send(cmd)
time.sleep(0.3)
led.value(0)
time.sleep(0.2)
print("Done. Connect Pico to see strip follow commands.")
if __name__ == "__main__":
main()

View File

@@ -4,6 +4,7 @@ from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle
from .double_circle import DoubleCircle
from .roll import Roll
from .calibration import Calibration
from .test import Test
@@ -14,4 +15,5 @@ from .lift import Lift
from .flare import Flare
from .hook import Hook
from .pose import Pose
from .point import Point
from .segments import Segments
from .segments_transition import SegmentsTransition

View File

@@ -1,6 +1,6 @@
"""Calibration: strips 2 and 6 only. First 10 green, then alternating 10 blue / 10 red. 10% brightness."""
BRIGHTNESS = 0.10
BRIGHTNESS = 1
BLOCK = 10
STRIPS_ON = (2, 6) # 0-based: 3rd and 7th strip only
@@ -22,6 +22,7 @@ class Calibration:
green = _scale(GREEN, BRIGHTNESS)
red = _scale(RED, BRIGHTNESS)
blue = _scale(BLUE, BRIGHTNESS)
blue = (0,0,0)
on_set = set(STRIPS_ON)
for strip_idx, strip in enumerate(strips):
n = strip.num_leds

View File

@@ -1,123 +1,165 @@
import utime
def _make_chase_double(num_leds, cumulative_leds, total_ring_leds, color0, color1, n1, n2):
"""Pregenerate strip double buffer with repeating segments:
color0 for n1 pixels, then color1 for n2 pixels, around the full ring. GRB order."""
n = 2 * num_leds
buf = bytearray(n * 3)
pattern_len = n1 + n2
for b in range(n):
# Position of this pixel along the logical ring
pos = (2 * cumulative_leds - b) % total_ring_leds
seg_pos = pos % pattern_len
if seg_pos < n1:
r, g, b_ = color0
else:
r, g, b_ = color1
o = b * 3
buf[o] = g
buf[o + 1] = r
buf[o + 2] = b_
strip_len_bytes = num_leds * 3
return buf, strip_len_bytes
def _ensure_chase_buffers(driver, color0, color1, n1, n2, cache):
"""Build or refresh per-strip double buffers for the chase pattern."""
strips = driver.strips
key = (
color0,
color1,
int(n1),
int(n2),
tuple(s.num_leds for s in strips),
)
if cache.get("key") == key and cache.get("data") is not None:
return cache["data"], cache["cumulative_leds"], cache["total_ring_leds"]
if not strips:
cache["key"] = key
cache["data"] = []
cache["cumulative_leds"] = []
cache["total_ring_leds"] = 0
return cache["data"], cache["cumulative_leds"], cache["total_ring_leds"]
cumulative_leds = [0]
for s in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + s.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
chase_data = []
for idx, s in enumerate(strips):
buf, strip_len_bytes = _make_chase_double(
s.num_leds,
cumulative_leds[idx],
total_ring_leds,
color0,
color1,
n1,
n2,
)
chase_data.append((buf, strip_len_bytes))
cache["key"] = key
cache["data"] = chase_data
cache["cumulative_leds"] = cumulative_leds
cache["total_ring_leds"] = total_ring_leds
return chase_data, cumulative_leds, total_ring_leds
class Chase:
def __init__(self, driver):
self.driver = driver
self._buffers_cache = {}
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)"""
colors = preset.c
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating around the full ring.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)."""
colors = preset.c or []
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
base0 = colors[0]
base1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
base0 = colors[0]
base1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
# Apply preset/global brightness
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
n1 = max(1, int(getattr(preset, "n1", 1)) or 1) # LEDs of color 0
n2 = max(1, int(getattr(preset, "n2", 1)) or 1) # LEDs of color 1
n3 = int(getattr(preset, "n3", 0) or 0) # step on even steps
n4 = int(getattr(preset, "n4", 0) or 0) # step on odd steps
segment_length = n1 + n2
if n3 == 0 and n4 == 0:
# Nothing to move; default to simple forward motion
n3 = 1
n4 = 1
# Calculate position from step_count
step_count = self.driver.step
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
self.driver, color0, color1, n1, n2, self._buffers_cache
)
# Wrap position to keep it reasonable
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
strips = self.driver.strips
# If auto is False, run a single step and then stop
def show_frame(chase_pos):
for i, (strip, (buf, strip_len_bytes)) in enumerate(zip(strips, chase_data)):
# head in [0, strip_len_bytes) so DMA read head..head+strip_len_bytes stays in double buffer
head = ((chase_pos + cumulative_leds[i]) * 3) % strip_len_bytes
strip.show(buf, head)
# Helper to compute head position from current step_count
def head_from_step(step_count):
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
pos = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
pos = ((step_count + 1) // 2) * (n3 + n4)
if total_ring_leds <= 0:
return 0
pos %= total_ring_leds
if pos < 0:
pos += total_ring_leds
return pos
# Single-step mode: render one frame, then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
step_count = int(self.driver.step)
chase_pos = head_from_step(step_count)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
show_frame(chase_pos)
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
# Advance step for next trigger
self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
# Use transition_duration for timing and force the first update to happen immediately
transition_duration = max(10, int(preset.d))
# Auto mode: continuous loop driven by delay d
transition_duration = max(10, int(getattr(preset, "d", 50)) or 10)
last_update = utime.ticks_ms() - transition_duration
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Rebuild buffers if geometry/colors changed
chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
self.driver, color0, color1, n1, n2, self._buffers_cache
)
# Wrap position
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
step_count = int(self.driver.step)
chase_pos = head_from_step(step_count)
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
show_frame(chase_pos)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step
step_count += 1
self.driver.step = step_count
# Advance step for next frame
self.driver.step = step_count + 1
last_update = current_time
# Yield once per tick so other logic can run

View File

@@ -0,0 +1,84 @@
class DoubleCircle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
DoubleCircle: symmetric band around a center index on the logical ring.
- n1: center index on the logical ring (0-based, on reference strip 0)
- n2: radius of the band (max distance from center)
- n3: direction mode
0 → LEDs start ALL OFF and turn ON n4 LEDs at a time outward from n1 toward n1±n2
1 → LEDs start ALL ON within radius n2 and turn OFF n4 LEDs at a time inward toward n1
- n4: step size in LEDs per update
- c[0]: base color used for the band
"""
num_leds = self.driver.num_leds
if num_leds <= 0:
while True:
yield
colors = preset.c or []
base1 = colors[0] if len(colors) >= 1 else (255, 255, 255)
off = (0, 0, 0)
# Apply preset/global brightness
color_on = self.driver.apply_brightness(base1, preset.b)
color_off = off
# Center index and radius from preset; clamp center to ring length
center = int(getattr(preset, "n1", 0)) % num_leds
radius = max(1, int(getattr(preset, "n2", 0)) or 1)
mode = int(getattr(preset, "n3", 0) or 0) # 0 = grow band outward, 1 = shrink band inward
step_size = max(1, int(getattr(preset, "n4", 1)) or 1)
num_strips = len(self.driver.strips)
# Current "front" of the band, as a distance from center
# mode 0: grow band outward (0 → radius)
# mode 1: shrink band inward (radius → 0)
if mode == 0:
current = 0
else:
current = radius
while True:
# Draw current frame based on current radius
for i in range(num_leds):
# Shortest circular distance from i to center
forward = (i - center) % num_leds
backward = (center - i) % num_leds
dist = forward if forward < backward else backward
if dist > radius:
c = color_off
else:
if mode == 0:
# Grow outward: lit if within current radius
c = color_on if dist <= current else color_off
else:
# Shrink inward: lit if within current radius (band contracts toward center)
c = color_on if dist <= current else color_off
for strip_idx in range(num_strips):
self.driver.set(strip_idx, i, c)
self.driver.show_all()
# Update current radius for next frame
if mode == 0:
if current >= radius:
# Finished growing; hold final frame
while True:
yield
current = min(radius, current + step_size)
else:
if current <= 0:
# Finished shrinking; hold final frame
while True:
yield
current = max(0, current - step_size)
yield

View File

@@ -1,18 +0,0 @@
class Point:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
# Apply preset/global brightness once per color
c1 = self.driver.apply_brightness(preset.c[0], preset.b)
c2 = self.driver.apply_brightness(preset.c[1], preset.b)
c3 = self.driver.apply_brightness(preset.c[2], preset.b)
c4 = self.driver.apply_brightness(preset.c[3], preset.b)
# Helper to normalize and clamp a range
self.driver.fill_n(c1, preset.n1, preset.n2)
self.driver.fill_n(c2, preset.n3, preset.n4)
self.driver.fill_n(c3, preset.n5, preset.n6)
self.driver.fill_n(c4, preset.n7, preset.n8)
self.driver.show_all()

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

View File

@@ -0,0 +1,18 @@
class Segments:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
# Apply preset/global brightness once per color
ranges = [
(preset.n1, preset.n2),
(preset.n3, preset.n4),
(preset.n5, preset.n6),
(preset.n7, preset.n8),
]
for n, color in enumerate(preset.c):
self.driver.fill_n(color, ranges[n][0], ranges[n][1])
self.driver.show_all()

View File

@@ -0,0 +1,136 @@
import utime
class SegmentsTransition:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
SegmentsTransition: fade from whatever is currently on the strips
to a new static Segments layout defined by n1n8 and c[0..3].
- Uses the existing strip buffers as the starting state.
- Target state matches the Segments pattern: up to 4 colored bands
along the logical reference strip, mapped to all physical strips.
- Transition duration is taken from preset.d (ms), minimum 50ms.
"""
strips = self.driver.strips
if not strips:
while True:
yield
# Snapshot starting GRB buffers (already scaled by per-strip brightness)
start_bufs = [bytes(strip.ar) for strip in strips]
# Prepare target buffers (same length as each strip's ar)
target_bufs = [bytearray(len(strip.ar)) for strip in strips]
# Base colors (up to 4), missing ones default to black
colors = list(preset.c) if getattr(preset, "c", None) else []
while len(colors) < 4:
colors.append((0, 0, 0))
# Apply preset/global brightness once per color
bright_colors = [
self.driver.apply_brightness(colors[0], preset.b),
self.driver.apply_brightness(colors[1], preset.b),
self.driver.apply_brightness(colors[2], preset.b),
self.driver.apply_brightness(colors[3], preset.b),
]
# Logical reference length for all strips (from scale_map[0])
ref_len = len(self.driver.scale_map[0]) if self.driver.scale_map else 0
if ref_len <= 0:
# Fallback: nothing to do, just hold current state
while True:
yield
# Helper to clamp and normalize a logical range [a, b] (inclusive) over ref_len.
# Returns (start, end_exclusive) suitable for range(start, end_exclusive).
def norm_range(a, b):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= ref_len:
return None
a = max(0, a)
b = min(ref_len - 1, b)
if a > b:
return None
return a, b + 1
raw_ranges = [
(getattr(preset, "n1", 0), getattr(preset, "n2", -1), bright_colors[0]),
(getattr(preset, "n3", 0), getattr(preset, "n4", -1), bright_colors[1]),
(getattr(preset, "n5", 0), getattr(preset, "n6", -1), bright_colors[2]),
(getattr(preset, "n7", 0), getattr(preset, "n8", -1), bright_colors[3]),
]
# Build target buffers using the same logical indexing idea as Segments
for strip_idx, strip in enumerate(strips):
bright = strip.brightness
scale_map = self.driver.scale_map[strip_idx]
buf = target_bufs[strip_idx]
n_leds = strip.num_leds
# Start from black everywhere
for i in range(len(buf)):
buf[i] = 0
# Apply each logical range to this strip
for a, b, color in raw_ranges:
rng = norm_range(a, b)
if not rng:
continue
start, end = rng
r, g, bl = color
for logical_idx in range(start, end):
if logical_idx < 0 or logical_idx >= len(scale_map):
continue
phys_idx = scale_map[logical_idx]
if phys_idx < 0 or phys_idx >= n_leds:
continue
base = phys_idx * 3
if base + 2 >= len(buf):
continue
buf[base] = int(g * bright)
buf[base + 1] = int(r * bright)
buf[base + 2] = int(bl * bright)
# Duration in ms for the whole transition (slower by default)
# If preset.d is provided, use it; otherwise default to a slow 3000ms fade.
raw_d = int(getattr(preset, "d", 3000) or 3000)
duration = max(1000, raw_d) # enforce at least 1s for a clearly visible transition
start_time = utime.ticks_ms()
while True:
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# Final frame: commit target buffers and hold, then update all strips together
for strip, target in zip(strips, target_bufs):
ar = strip.ar
for i in range(len(ar)):
ar[i] = target[i]
self.driver.show_all()
while True:
yield
# Interpolation factor in [0,1]
factor = elapsed / duration
inv = 1.0 - factor
# Blend from start to target in GRB space per byte
for idx, strip in enumerate(strips):
start_buf = start_bufs[idx]
target_buf = target_bufs[idx]
ar = strip.ar
for i in range(len(ar)):
ar[i] = int(start_buf[i] * inv + target_buf[i] * factor)
self.driver.show_all()
yield

View File

@@ -2,7 +2,7 @@
import utime
SPAN = 10 # LEDs on each side of center (match Grab)
SPAN = 0 # LEDs on each side of center (match Grab)
LUT_SIZE = 256 # gradient lookup table entries
@@ -12,6 +12,9 @@ class Spin:
def run(self, preset):
strips = self.driver.strips
self.driver.fill((0, 0, 0))
self.driver.show_all()
active_indices = (0, 4)
c0 = preset.c[0]
c1 = preset.c[1]
@@ -58,8 +61,8 @@ class Spin:
n = strip.num_leds
mid = midpoints[idx]
# Expand arms: inside (strip 1, idx 0) moves slower, outside (strip 5, idx 4) faster
step = max(1, rate // 2) if idx == 0 else rate
# Expand arms at the same rate on both sides
step = max(1, rate)
new_left = max(margin, left[idx] - step)
new_right = min(n - margin, right[idx] + step)

View File

@@ -2,11 +2,57 @@ from machine import Pin
from ws2812 import WS2812B
from preset import Preset
from patterns import (
Blink, Rainbow, Pulse, Transition, Chase, Circle, Roll, Calibration, Test,
Grab, Spin, Lift, Flare, Hook, Pose, Point,
Blink,
Rainbow,
Pulse,
Transition,
Chase,
Circle,
DoubleCircle,
Roll,
Calibration,
Test,
Grab,
Spin,
Lift,
Flare,
Hook,
Pose,
Segments,
SegmentsTransition,
)
import json
class _LogicalRing:
"""
Lightweight logical ring over all strips.
Used by patterns that expect driver.n (e.g. Circle, Roll legacy API).
"""
def __init__(self, driver):
self._driver = driver
self.num_strips = len(driver.strips)
def __len__(self):
return self._driver.num_leds
def fill(self, color):
# Apply color to all logical positions across all strips
for i in range(self._driver.num_leds):
for strip_idx in range(self.num_strips):
self._driver.set(strip_idx, i, color)
def __setitem__(self, index, color):
if index < 0 or index >= self._driver.num_leds:
return
for strip_idx in range(self.num_strips):
self._driver.set(strip_idx, index, color)
def write(self):
self._driver.show_all()
# Order: strips[0]=physical 1 … strips[7]=physical 8. (pin, num_leds, midpoint_index).
STRIP_CONFIG = (
(6, 291, 291 // 2), # 1
@@ -34,8 +80,12 @@ class Presets:
state_machine += 1
self.scale_map.append(self.create_scale_map(num_leds))
# Single logical strip over all 8 strips for patterns (n[i], .fill(), .write())
# Single logical strip using strip 0 as reference for patterns (n[i], .fill(), .write())
# WS2812B with brightness=1.0 so Presets.apply_brightness() does all scaling (NeoPixel drop-in)
# Reference logical length for patterns that use driver.num_leds (Rainbow/Chase/Circle, etc.)
self.num_leds = self.strips[0].num_leds if self.strips else 0
# Legacy logical ring interface for patterns expecting driver.n
self.n = _LogicalRing(self)
self.step = 0
# Remember which strip was last used as the roll head (for flare, etc.)
self.last_roll_head = 0
@@ -56,6 +106,7 @@ class Presets:
"transition": Transition(self).run,
"chase": Chase(self).run,
"circle": Circle(self).run,
"double_circle": DoubleCircle(self).run,
"roll": Roll(self).run,
"calibration": Calibration(self).run,
"test": Test(self).run,
@@ -65,7 +116,9 @@ class Presets:
"flare": Flare(self).run,
"hook": Hook(self).run,
"pose": Pose(self).run,
"point": Point(self).run,
"segments": Segments(self).run,
"segments_transition": SegmentsTransition(self).run,
"point": Segments(self).run, # backwards-compatible alias
}
# --- Strip geometry utilities -------------------------------------------------
@@ -159,11 +212,13 @@ class Presets:
def off(self, preset=None):
self.fill((0, 0, 0))
self.show_all()
def on(self, preset):
colors = preset.c
color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.b))
self.show_all()
def fill(self, color):
for strip in self.strips:

View File

@@ -33,27 +33,28 @@ for ws in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + ws.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
# Chase: trail length (0 = single LED), color (R,G,B)
TRAIL_LEN = 8
CHASE_COLOR = (0, 255, 100) # cyan-green
# Chase: color1 n1 long, then color2 n2 long, stepping n3 pixels
COLOR1 = (255, 0, 0) # red
COLOR2 = (0, 0, 255) # blue
N1 = 24 # length of color1 segment
N2 = 24 # length of color2 segment
STEP = 1 # step size in pixels per frame
def make_chase_double(num_leds, cumulative_leds, total_ring_leds, color, trail_len=0):
"""Pregenerate strip double buffer: when head shows index b first, that pixel is at
distance (2*cumulative_leds - b) % total_ring_leds from chase head. GRB order."""
def make_chase_double(num_leds, cumulative_leds, total_ring_leds, color1, color2, n1, n2):
"""Pregenerate strip double buffer with repeating segments:
color1 for n1 pixels, then color2 for n2 pixels, around the full ring. GRB order."""
n = 2 * num_leds
buf = bytearray(n * 3)
pattern_len = n1 + n2
for b in range(n):
dist = (2 * cumulative_leds - b) % total_ring_leds
if dist == 0:
r, grn, b_ = color[0], color[1], color[2]
elif trail_len and 0 < dist <= trail_len:
fade = 1.0 - (dist / (trail_len + 1))
r = int(color[0] * fade)
grn = int(color[1] * fade)
b_ = int(color[2] * fade)
# Position of this pixel along the logical ring
pos = (2 * cumulative_leds - b) % total_ring_leds
seg_pos = pos % pattern_len
if seg_pos < n1:
r, grn, b_ = color1[0], color1[1], color1[2]
else:
r = grn = b_ = 0
r, grn, b_ = color2[0], color2[1], color2[2]
o = b * 3
buf[o] = grn
buf[o + 1] = r
@@ -63,7 +64,7 @@ def make_chase_double(num_leds, cumulative_leds, total_ring_leds, color, trail_l
# Pregenerate one double buffer per strip
chase_buffers = [
make_chase_double(ws.num_leds, cumulative_leds[i], total_ring_leds, CHASE_COLOR, TRAIL_LEN)
make_chase_double(ws.num_leds, cumulative_leds[i], total_ring_leds, COLOR1, COLOR2, N1, N2)
for i, ws in enumerate(strips)
]
@@ -74,5 +75,5 @@ while True:
strip_len = strip.num_leds * 3
head = (chase_pos + cumulative_leds[i]) * 3 % strip_len
strip.show(chase_buffers[i], head)
chase_pos = (chase_pos + 1) % total_ring_leds
time.sleep_ms(20)
chase_pos = (chase_pos + STEP) % total_ring_leds
time.sleep_ms(40)

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

@@ -0,0 +1,157 @@
"""
On-device test for the double_circle 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_double_circle.py :
mpremote connect <device> run test_double_circle.py
This script:
- Instantiates Presets
- Creates a few in-memory 'double_circle' presets with different centers, widths, and colors
- Selects each one so you can visually confirm the symmetric bands and color gradients
"""
from presets import Presets, Preset
def make_double_circle_preset(
name, center, half_width, colors, direction=0, step_size=1, brightness=255
):
"""
Helper to build a Preset for the 'double_circle' pattern.
center: logical index (0-based, on reference strip 0)
half_width: number of LEDs each side of center
colors: [color1, color2] where each color is (r,g,b)
"""
cs = list(colors)[:2]
while len(cs) < 2:
cs.append((0, 0, 0))
data = {
"p": "double_circle",
"c": cs,
"b": brightness,
"n1": center,
"n2": half_width,
"n3": direction,
"n4": step_size,
}
return name, Preset(data)
def show_and_wait(presets, name, preset_obj, wait_ms):
"""Select a static double_circle preset and hold it for wait_ms."""
presets.presets[name] = preset_obj
presets.select(name)
# DoubleCircle draws immediately in run(), then just yields; one tick is enough.
presets.tick()
import utime
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < wait_ms:
presets.tick()
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting double_circle test.")
return
print("Starting double_circle pattern test...")
quarter = num_leds // 4
half = num_leds // 2
dc_presets = []
# 1. Center at top (0), moderate width, color1 at center (n3=0)
dc_presets.append(
make_double_circle_preset(
"dc_top_red_to_blue",
center=0,
half_width=quarter,
colors=[(255, 0, 0), (0, 0, 255)],
direction=0,
)
)
# 2. Center at bottom (half), narrow band, color1 at endpoints (n3=1)
dc_presets.append(
make_double_circle_preset(
"dc_bottom_green_to_purple",
center=half,
half_width=quarter // 2,
colors=[(0, 255, 0), (128, 0, 128)],
direction=1,
)
)
# 3. Center at quarter, wide band, both directions for comparison
dc_presets.append(
make_double_circle_preset(
"dc_quarter_white_to_cyan_inward",
center=quarter,
half_width=half,
colors=[(255, 255, 255), (0, 255, 255)],
direction=0,
)
)
dc_presets.append(
make_double_circle_preset(
"dc_quarter_white_to_cyan_outward",
center=quarter,
half_width=half,
colors=[(255, 255, 255), (0, 255, 255)],
direction=1,
)
)
# 4. Explicit test: n1 = 50, n2 = 40 (half of 80) inward
dc_presets.append(
make_double_circle_preset(
"dc_n1_50_n2_40_inward",
center=50,
half_width=40,
colors=[(255, 100, 0), (0, 0, 0)],
direction=0,
)
)
# 5. Explicit test: n1 = num_leds//2, n2 = num_leds//4 outward, stepping as fast as possible
center_half = num_leds // 2
radius_quarter = max(1, num_leds // 4)
dc_presets.append(
make_double_circle_preset(
"dc_n1_half_n2_quarter_outward",
center=center_half,
half_width=radius_quarter,
colors=[(0, 150, 255), (0, 0, 0)],
direction=1,
step_size=radius_quarter, # jump to full radius in one step
)
)
# Show each for ~4 seconds
for name, preset_obj in dc_presets:
print("Showing double_circle preset:", name)
show_and_wait(presets, name, preset_obj, wait_ms=4000)
print("Double_circle pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,264 @@
"""
On-device test that exercises Segments and multiple SegmentsTransition presets 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_multi_patterns.py :
mpremote connect <device> run test_multi_patterns.py
"""
import utime
from presets import Presets, Preset
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 make_segments_preset(name, colors, n_values, brightness=255):
"""
Helper to build a Preset for the 'segments' pattern.
colors: list of up to 4 (r,g,b) tuples
n_values: list/tuple of 8 ints [n1..n8]
"""
cs = list(colors)[:4]
while len(cs) < 4:
cs.append((0, 0, 0))
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "segments",
"c": cs,
"b": brightness,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
}
return name, Preset(data)
def make_segments_transition_preset(name, colors, n_values, duration_ms=1000, brightness=255):
"""
Helper to build a Preset for the 'segments_transition' pattern.
Starts from whatever is currently displayed and fades to the
new segments layout over duration_ms.
"""
cs = list(colors)[:4]
while len(cs) < 4:
cs.append((0, 0, 0))
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "segments_transition",
"c": cs,
"b": brightness,
"d": duration_ms,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
}
return name, Preset(data)
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting multi-pattern test.")
return
print("Starting multi-pattern test with Presets...")
quarter = num_leds // 4
half = num_leds // 2
# 1. Static segments: simple R/G/B bands
name_static, preset_static = make_segments_preset(
"mp_segments_static",
colors=[(255, 0, 0), (0, 255, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red
quarter,
2 * quarter - 1, # green
2 * quarter,
3 * quarter - 1, # blue
0,
-1,
],
)
# 2a. Segments transition: fade from previous buffer to new colors (slow)
name_trans1, preset_trans1 = make_segments_transition_preset(
"mp_segments_transition_1",
colors=[(255, 255, 255), (255, 0, 255), (0, 255, 255)],
n_values=[
0,
half - 1, # white on first half
half,
num_leds - 1, # magenta on second half
0,
-1, # cyan unused in this example
0,
-1,
],
duration_ms=3000,
)
# 2b. Segments transition: fade between two different segment layouts
name_trans2, preset_trans2 = make_segments_transition_preset(
"mp_segments_transition_2",
colors=[(255, 0, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red first quarter
quarter,
2 * quarter - 1, # blue second quarter
0,
-1,
0,
-1,
],
duration_ms=4000,
)
# 2c. Segments transition: thin moving band (center quarter only)
band_start = quarter // 2
band_end = band_start + quarter
name_trans3, preset_trans3 = make_segments_transition_preset(
"mp_segments_transition_3",
colors=[(0, 255, 0)],
n_values=[
band_start,
band_end - 1, # green band in the middle
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=5000,
)
# 2d. Segments transition: full-ring warm white fade
name_trans4, preset_trans4 = make_segments_transition_preset(
"mp_segments_transition_4",
colors=[(255, 200, 100)],
n_values=[
0,
num_leds - 1, # entire strip
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=6000,
)
# 2e. Segments transition: alternating warm/cool halves
name_trans5, preset_trans5 = make_segments_transition_preset(
"mp_segments_transition_5",
colors=[(255, 180, 100), (100, 180, 255)],
n_values=[
0,
half - 1, # warm first half
half,
num_leds - 1, # cool second half
0,
-1,
0,
-1,
],
duration_ms=5000,
)
# 2f. Segments transition: narrow red band near start
narrow_start = num_leds // 16
narrow_end = narrow_start + max(4, num_leds // 32)
name_trans6, preset_trans6 = make_segments_transition_preset(
"mp_segments_transition_6",
colors=[(255, 0, 0)],
n_values=[
narrow_start,
narrow_end - 1,
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=4000,
)
# Register presets in Presets and run them in sequence
presets.presets[name_static] = preset_static
presets.presets[name_trans1] = preset_trans1
presets.presets[name_trans2] = preset_trans2
presets.presets[name_trans3] = preset_trans3
presets.presets[name_trans4] = preset_trans4
presets.presets[name_trans5] = preset_trans5
presets.presets[name_trans6] = preset_trans6
print("Showing static segments...")
presets.select(name_static)
presets.tick() # draw once
run_for(presets, 3000)
print("Running segments transition 1 (fading to new half/half layout)...")
presets.select(name_trans1)
run_for(presets, 3500)
print("Running segments transition 2 (fading to quarter-band layout)...")
presets.select(name_trans2)
run_for(presets, 4500)
print("Running segments transition 3 (fading to center green band)...")
presets.select(name_trans3)
run_for(presets, 5500)
print("Running segments transition 4 (fading to full warm white ring)...")
presets.select(name_trans4)
run_for(presets, 6500)
print("Running segments transition 5 (fading to warm/cool halves)...")
presets.select(name_trans5)
run_for(presets, 5500)
print("Running segments transition 6 (fading to narrow red band)...")
presets.select(name_trans6)
run_for(presets, 4500)
print("Multi-pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
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()

View File

@@ -1,26 +1,26 @@
"""
On-device test for the Point pattern using mpremote.
On-device test for the Segments 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_point.py :
mpremote connect <device> run test_point.py
mpremote connect <device> cp test/test_segments.py :
mpremote connect <device> run test_segments.py
This script:
- Instantiates Presets
- Creates a few in-memory 'point' presets with different ranges/colors
- Creates a few in-memory 'point' (Segments) presets with different ranges/colors
- Selects each one so you can visually confirm the segments
"""
from presets import Presets, Preset
def make_point_preset(name, colors, n_values, brightness=255):
def make_segments_preset(name, colors, n_values, brightness=255):
"""
Helper to build a Preset for the 'point' pattern.
Helper to build a Preset for the 'segments' pattern (key 'point').
colors: list of up to 4 (r,g,b) tuples
n_values: list/tuple of 8 ints [n1..n8]
@@ -32,7 +32,7 @@ def make_point_preset(name, colors, n_values, brightness=255):
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "point",
"p": "segments", # pattern key for Segments
"c": cs,
"b": brightness,
"n1": n1,
@@ -43,16 +43,16 @@ def make_point_preset(name, colors, n_values, brightness=255):
"n6": n6,
"n7": n7,
"n8": n8,
# 'a' is not used by point; it's static
# 'a' is not used by segments; it's static
}
return name, Preset(data)
def show_and_wait(presets, name, preset_obj, wait_ms):
"""Select a static 'point' preset and hold it for wait_ms."""
"""Select a static segments preset and hold it for wait_ms."""
presets.presets[name] = preset_obj
presets.select(name)
# Point draws immediately in run(), then just yields; one tick is enough.
# Segments draws immediately in run(), then just yields; one tick is enough.
presets.tick()
import utime
@@ -69,46 +69,46 @@ def main():
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting point test.")
print("No strips; aborting segments test.")
return
print("Starting point pattern test...")
print("Starting segments pattern test...")
quarter = num_leds // 4
half = num_leds // 2
point_presets = []
segments_presets = []
# 1. Single band: first quarter, red
point_presets.append(
make_point_preset(
"point_red_q1",
segments_presets.append(
make_segments_preset(
"segments_red_q1",
colors=[(255, 0, 0)],
n_values=[0, quarter - 1, 0, -1, 0, -1, 0, -1],
)
)
# 2. Two bands: red first half, green second half
point_presets.append(
make_point_preset(
"point_red_green_halves",
segments_presets.append(
make_segments_preset(
"segments_red_green_halves",
colors=[(255, 0, 0), (0, 255, 0)],
n_values=[0, half - 1, half, num_leds - 1, 0, -1, 0, -1],
)
)
# 3. Three bands: R, G, B quarters
point_presets.append(
make_point_preset(
"point_rgb_quarters",
segments_presets.append(
make_segments_preset(
"segments_rgb_quarters",
colors=[(255, 0, 0), (0, 255, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red
quarter - 1, # red
quarter,
2 * quarter - 1, # green
2 * quarter - 1, # green
2 * quarter,
3 * quarter - 1, # blue
3 * quarter - 1, # blue
0,
-1,
],
@@ -116,11 +116,11 @@ def main():
)
# Show each for ~4 seconds
for name, preset_obj in point_presets:
print("Showing point preset:", name)
for name, preset_obj in segments_presets:
print("Showing segments preset:", name)
show_and_wait(presets, name, preset_obj, wait_ms=4000)
print("Point pattern test finished. Turning off LEDs.")
print("Segments pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()

128
pico/test/test_spin.py Normal file
View File

@@ -0,0 +1,128 @@
"""
On-device test for the Spin pattern using mpremote and 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 presets.json :
mpremote connect <device> cp test/test_spin.py :
mpremote connect <device> run test_spin.py
This script:
- Instantiates Presets
- Creates a few in-memory spin 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_spin_preset(
name,
color_inner,
color_outer,
rate=4,
delay_ms=30,
margin=0,
brightness=255,
):
"""Helper to build a Preset dict for the spin pattern."""
data = {
"p": "spin",
"c": [color_inner, color_outer],
"b": brightness,
"d": delay_ms,
"n1": rate, # expansion step per tick
"n2": margin, # margin from strip ends
"a": True,
}
return name, Preset(data)
def run_preset(presets, name, preset_obj, duration_ms):
"""Run a given spin preset for duration_ms using the existing tick loop."""
# Start each preset from a blank frame so both sides are balanced.
presets.select("off")
presets.tick()
presets.presets[name] = preset_obj
presets.select(name)
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()
# Ensure we start from a blank frame.
presets.select("off")
presets.tick()
print("Starting spin pattern test...")
# Use strip 0 length to derive a reasonable margin.
ref_len = presets.strip_length(0)
margin_small = ref_len // 16 if ref_len > 0 else 0
margin_large = ref_len // 8 if ref_len > 0 else 0
spin_presets = []
# 1. Slow spin, warm white to orange, small margin
spin_presets.append(
make_spin_preset(
"spin_slow_warm",
color_inner=(255, 200, 120),
color_outer=(255, 100, 0),
rate=2,
delay_ms=40,
margin=margin_small,
brightness=255,
)
)
# 2. Medium spin, cyan to magenta, larger margin
spin_presets.append(
make_spin_preset(
"spin_medium_cyan_magenta",
color_inner=(0, 255, 180),
color_outer=(255, 0, 180),
rate=4,
delay_ms=30,
margin=margin_large,
brightness=255,
)
)
# 3. Fast spin, white to off (fade outwards), no margin
spin_presets.append(
make_spin_preset(
"spin_fast_white",
color_inner=(255, 255, 255),
color_outer=(0, 0, 0),
rate=6,
delay_ms=20,
margin=0,
brightness=255,
)
)
# Run each spin preset for about 6 seconds
for name, preset_obj in spin_presets:
print("Running spin preset:", name)
run_preset(presets, name, preset_obj, duration_ms=6000)
print("Spin pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()