Compare commits

..

7 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
47c17dba36 Add mpremote tests for scaling, roll, and point
Made-with: Cursor
2026-03-05 20:25:57 +13:00
e75723e2e7 Refactor presets scaling and clean up patterns
Made-with: Cursor
2026-03-05 20:25:28 +13:00
49 changed files with 1819 additions and 836 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
settings.json settings.json
__pycache__/

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="legswoop">legswoop</option>
<option value="split">split</option> <option value="split">split</option>
<option value="foothang">foothang</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="off">off</option>
<option value="on">on</option> <option value="on">on</option>
<option value="blink">blink</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

@@ -28,6 +28,8 @@ class WS2812B:
self.brightness = brightness self.brightness = brightness
self.invert = invert self.invert = invert
self.pio_dma = dma.PIO_DMA_Transfer(state_machine+4, state_machine, 8, num_leds*3) self.pio_dma = dma.PIO_DMA_Transfer(state_machine+4, state_machine, 8, num_leds*3)
self.dma.start_transfer(self.ar)
def show(self, array=None, offset=0): def show(self, array=None, offset=0):
if array is None: if array is None:

View File

@@ -4,32 +4,16 @@ from .pulse import Pulse
from .transition import Transition from .transition import Transition
from .chase import Chase from .chase import Chase
from .circle import Circle from .circle import Circle
from .double_circle import DoubleCircle
from .roll import Roll from .roll import Roll
from .calibration import Calibration from .calibration import Calibration
from .test import Test from .test import Test
from .scale_test import ScaleTest
from .grab import Grab from .grab import Grab
from .spin import Spin from .spin import Spin
from .lift import Lift from .lift import Lift
from .flare import Flare from .flare import Flare
from .hook import Hook from .hook import Hook
from .invertsplit import Invertsplit
from .pose import Pose from .pose import Pose
from .backbalance import Backbalance from .segments import Segments
from .beat import Beat from .segments_transition import SegmentsTransition
from .crouch import Crouch
from .backbendsplit import Backbendsplit
from .straddle import Straddle
from .frontbalance import Frontbalance
from .elbowhang import Elbowhang
from .elbowhangspin import Elbowhangspin
from .dismount import Dismount
from .fluff import Fluff
from .elbowhangsplit import Elbowhangsplit
from .invert import Invert
from .backbend import Backbend
from .seat import Seat
from .kneehang import Kneehang
from .legswoop import Legswoop
from .split import Split
from .foothang import Foothang
from .point import Point

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Backbalance:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Backbend:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Backbendsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Beat:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

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

View File

@@ -1,123 +1,165 @@
import utime 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: class Chase:
def __init__(self, driver): def __init__(self, driver):
self.driver = driver self.driver = driver
self._buffers_cache = {}
def run(self, preset): def run(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating. """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)""" Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)."""
colors = preset.c colors = preset.c or []
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
if not colors: if not colors:
return return
# If only one color provided, use it for both colors # If only one color provided, use it for both colors
if len(colors) < 2: if len(colors) < 2:
color0 = colors[0] base0 = colors[0]
color1 = colors[0] base1 = colors[0]
else: else:
color0 = colors[0] base0 = colors[0]
color1 = colors[1] base1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b) # Apply preset/global brightness
color1 = self.driver.apply_brightness(color1, preset.b) 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 n1 = max(1, int(getattr(preset, "n1", 1)) or 1) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1 n2 = max(1, int(getattr(preset, "n2", 1)) or 1) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative) n3 = int(getattr(preset, "n3", 0) or 0) # step on even steps
n4 = int(preset.n4) # Step movement on odd steps (can be negative) 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 chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
step_count = self.driver.step self.driver, color0, color1, n1, n2, self._buffers_cache
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc. )
strips = self.driver.strips
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: if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3 # Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3 pos = (step_count // 2) * (n3 + n4) + n3
else: else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4) # Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (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
# Wrap position to keep it reasonable # Single-step mode: render one frame, then stop
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# If auto is False, run a single step and then stop
if not preset.a: if not preset.a:
# Clear all LEDs step_count = int(self.driver.step)
self.driver.n.fill((0, 0, 0)) chase_pos = head_from_step(step_count)
# Draw repeating pattern starting at position show_frame(chase_pos)
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 # Advance step for next trigger
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
self.driver.step = step_count + 1 self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield yield
return return
# Auto mode: continuous loop # Auto mode: continuous loop driven by delay d
# Use transition_duration for timing and force the first update to happen immediately transition_duration = max(10, int(getattr(preset, "d", 50)) or 10)
transition_duration = max(10, int(preset.d))
last_update = utime.ticks_ms() - transition_duration last_update = utime.ticks_ms() - transition_duration
while True: while True:
current_time = utime.ticks_ms() current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration: if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count # Rebuild buffers if geometry/colors changed
if step_count % 2 == 0: chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
position = (step_count // 2) * (n3 + n4) + n3 self.driver, color0, color1, n1, n2, self._buffers_cache
else: )
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position step_count = int(self.driver.step)
max_pos = self.driver.num_leds + segment_length chase_pos = head_from_step(step_count)
position = position % max_pos
if position < 0:
position += max_pos
# Clear all LEDs show_frame(chase_pos)
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position # Advance step for next frame
for i in range(self.driver.num_leds): self.driver.step = step_count + 1
# 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
last_update = current_time last_update = current_time
# Yield once per tick so other logic can run # Yield once per tick so other logic can run

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Crouch:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Dismount:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

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,9 +0,0 @@
"""Placeholder until implemented."""
class Elbowhang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Elbowhangspin:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Elbowhangsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Fluff:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Foothang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Frontbalance:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Invert:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Invertsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Kneehang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Legswoop:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,68 +0,0 @@
class Point:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Point pattern: color bands defined by n ranges.
- n1n2: LEDs with color1 (c[0])
- n3n4: LEDs with color2 (c[1])
- n5n6: LEDs with color3 (c[2])
- n7n8: LEDs with color4 (c[3])
All indices are along the logical ring (driver.n), inclusive ranges.
"""
num_leds = self.driver.num_leds
# 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
c1 = self.driver.apply_brightness(colors[0], preset.b)
c2 = self.driver.apply_brightness(colors[1], preset.b)
c3 = self.driver.apply_brightness(colors[2], preset.b)
c4 = self.driver.apply_brightness(colors[3], preset.b)
# Helper to normalize and clamp a range
def norm_range(a, b):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= num_leds:
return None
a = max(0, a)
b = min(num_leds - 1, b)
if a > b:
return None
return a, b
ranges = []
r1 = norm_range(getattr(preset, "n1", 0), getattr(preset, "n2", -1))
if r1:
ranges.append((r1[0], r1[1], c1))
r2 = norm_range(getattr(preset, "n3", 0), getattr(preset, "n4", -1))
if r2:
ranges.append((r2[0], r2[1], c2))
r3 = norm_range(getattr(preset, "n5", 0), getattr(preset, "n6", -1))
if r3:
ranges.append((r3[0], r3[1], c3))
r4 = norm_range(getattr(preset, "n7", 0), getattr(preset, "n8", -1))
if r4:
ranges.append((r4[0], r4[1], c4))
# Static draw: last range wins on overlaps
for i in range(num_leds):
color = (0, 0, 0)
for start, end, c in ranges:
if start <= i <= end:
color = c
self.driver.n[i] = color
self.driver.n.write()
while True:
yield

View File

@@ -1,4 +1,5 @@
import utime import utime
import math
class Roll: class Roll:
@@ -6,149 +7,105 @@ class Roll:
self.driver = driver self.driver = driver
def run(self, preset): 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) - All strips participate; each frame shows N discrete colors
- n2: offset from end of strip (effective end = end - n2, inclusive) (one per strip), from color1 to color2.
- n3: number of full rotations before stopping (0 = infinite) - 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) - n4: direction (0 = clockwise, 1 = anti-clockwise)
- c[0]: color1 at the head strip - c[0]: head color (full intensity)
- c[1]: color2 at the tail strip - c[1]: tail color (usually darker or off)
""" """
colors = preset.c colors = preset.c
color1_raw = colors[0] if colors else (255, 255, 255) base1 = colors[0] if colors else (255, 255, 255)
color2_raw = colors[1] if len(colors) > 1 else (0, 0, 0) base2 = colors[1] if len(colors) > 1 else (0, 0, 0)
color1 = self.driver.apply_brightness(color1_raw, preset.b) color1 = self.driver.apply_brightness(base1, preset.b)
color2 = self.driver.apply_brightness(color2_raw, 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 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 # n3: number of full rotations before stopping (0 = continuous)
start_margin = max(0, int(getattr(preset, "n1", 0))) max_rotations = int(getattr(preset, "n3", 0) or 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
# n4: direction (0=cw, 1=ccw); default clockwise if missing # n4: direction (0=cw, 1=ccw); default clockwise if missing
clockwise = int(getattr(preset, "n4", 0)) == 0 clockwise = int(getattr(preset, "n4", 0)) == 0
step = self.driver.step 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): # Precompute one shared buffer per brightness level (one per strip),
return tuple(int(x * f) for x in c) # 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): max_leds = max(s.num_leds for s in strips_list)
"""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),
)
def draw(head): # Build N discrete color buffers forming a gradient from color1 to color2.
# Remember head strip for flare 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: try:
self.driver.last_roll_head = head self.driver.last_roll_head = step_index % n_segments
except AttributeError: except AttributeError:
pass pass
strips_list = self.driver.strips
for strip_idx, strip in enumerate(strips_list): for strip_idx, strip in enumerate(strips_list):
if strip_idx < 0 or strip_idx >= n_segments: if strip_idx < 0 or strip_idx >= n_segments:
continue continue
# Distance from head along direction, 0..n_segments-1
if clockwise: if clockwise:
dist = (head - strip_idx) % n_segments buf_index = (step_index + strip_idx) % n_segments
else: 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 # Show the shared buffer; WS2812B will read num_leds*3 bytes.
if n_segments > 1: strip.show(buf, 0)
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()
if not preset.a: if not preset.a:
head = step % n_segments if n_segments > 0 else 0 draw(step)
draw(head)
self.driver.step = step + 1 self.driver.step = step + 1
yield yield
return 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: while True:
current_time = utime.ticks_ms() now = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= delay_ms: if utime.ticks_diff(now, last_update) >= delay_ms:
head = step % n_segments if n_segments > 0 else 0 draw(step)
if not clockwise and n_segments > 0:
head = (n_segments - 1 - head)
draw(head)
step += 1 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: if max_rotations > 0 and n_segments > 0 and (step % n_segments) == 0:
rotations_done += 1 rotations_done += 1
if rotations_done >= max_rotations: if rotations_done >= max_rotations:
self.driver.step = step # Hold the final frame and stop advancing; keep yielding so
last_update = current_time # the generator stays alive without changing the LEDs.
return while True:
yield
self.driver.step = step
last_update = current_time
yield yield

View File

@@ -0,0 +1,56 @@
import utime
RED = (255, 0, 0)
class ScaleTest:
"""
Animated test for the scale() helper.
A single red pixel moves along the reference strip (strip 0). For each other
strip, the position is mapped using:
n2 = scale(l1, l2, n1)
so that all lit pixels stay aligned by proportional position along the strips.
"""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
if not strips:
return
src_strip_idx = 0
l1 = self.driver.strip_length(src_strip_idx)
if l1 <= 0:
return
step = self.driver.step
delay_ms = max(1, int(getattr(preset, "d", 30) or 30))
last_update = utime.ticks_ms()
color = self.driver.apply_brightness(RED, getattr(preset, "b", 255))
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
n1 = step % l1
# Clear all strips
for strip in strips:
strip.fill((0, 0, 0))
# Light mapped position on each strip using Presets.set/show
for dst_strip_idx, _ in enumerate(strips):
self.driver.set(dst_strip_idx, n1, color)
self.driver.show(dst_strip_idx)
step += 1
self.driver.step = step
last_update = now
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Seat:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
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 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 LUT_SIZE = 256 # gradient lookup table entries
@@ -12,6 +12,9 @@ class Spin:
def run(self, preset): def run(self, preset):
strips = self.driver.strips strips = self.driver.strips
self.driver.fill((0, 0, 0))
self.driver.show_all()
active_indices = (0, 4) active_indices = (0, 4)
c0 = preset.c[0] c0 = preset.c[0]
c1 = preset.c[1] c1 = preset.c[1]
@@ -58,8 +61,8 @@ class Spin:
n = strip.num_leds n = strip.num_leds
mid = midpoints[idx] mid = midpoints[idx]
# Expand arms: inside (strip 1, idx 0) moves slower, outside (strip 5, idx 4) faster # Expand arms at the same rate on both sides
step = max(1, rate // 2) if idx == 0 else rate step = max(1, rate)
new_left = max(margin, left[idx] - step) new_left = max(margin, left[idx] - step)
new_right = min(n - margin, right[idx] + step) new_right = min(n - margin, right[idx] + step)

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Split:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -1,9 +0,0 @@
"""Placeholder until implemented."""
class Straddle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -2,15 +2,57 @@ from machine import Pin
from ws2812 import WS2812B from ws2812 import WS2812B
from preset import Preset from preset import Preset
from patterns import ( from patterns import (
Blink, Rainbow, Pulse, Transition, Chase, Circle, Roll, Calibration, Test, Blink,
Grab, Spin, Lift, Flare, Hook, Invertsplit, Pose, Rainbow,
Backbalance, Beat, Crouch, Backbendsplit, Straddle, Pulse,
Frontbalance, Elbowhang, Elbowhangspin, Dismount, Fluff, Transition,
Elbowhangsplit, Invert, Backbend, Seat, Kneehang, Chase,
Legswoop, Split, Foothang, Point, Circle,
DoubleCircle,
Roll,
Calibration,
Test,
Grab,
Spin,
Lift,
Flare,
Hook,
Pose,
Segments,
SegmentsTransition,
) )
import json 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). # Order: strips[0]=physical 1 … strips[7]=physical 8. (pin, num_leds, midpoint_index).
STRIP_CONFIG = ( STRIP_CONFIG = (
(6, 291, 291 // 2), # 1 (6, 291, 291 // 2), # 1
@@ -23,53 +65,9 @@ STRIP_CONFIG = (
(7, 291, 290 // 2-1), # 8 (7, 291, 290 // 2-1), # 8
) )
class StripRing:
"""Treat multiple WS2812B strips as one logical ring. Equal weight per strip (scale by strip length)."""
def __init__(self, strips):
self.strips = strips
self._cumul = [0]
for s in strips:
self._cumul.append(self._cumul[-1] + s.num_leds)
self.num_leds = self._cumul[-1]
self.num_strips = len(strips)
def _strip_and_local(self, i):
if i < 0 or i >= self.num_leds:
raise IndexError(i)
for s in range(len(self.strips)):
if i < self._cumul[s + 1]:
return s, i - self._cumul[s]
return len(self.strips) - 1, i - self._cumul[-2]
def __setitem__(self, i, color):
s, local = self._strip_and_local(i)
self.strips[s].set(local, color)
def fill(self, color):
for s in self.strips:
s.fill(color)
def write(self):
for s in self.strips:
s.show()
def position(self, i):
"""Normalized position 0..1 with equal span per strip (longer strips get same angular span)."""
s, local = self._strip_and_local(i)
strip_len = self.strips[s].num_leds
frac = (local / strip_len) if strip_len else 0
return (s + frac) / self.num_strips
def segment(self, i):
"""Segment index 0..num_strips-1 (strip index) so segments align with physical strips."""
s, _ = self._strip_and_local(i)
return s
class Presets: class Presets:
def __init__(self): def __init__(self):
self.scale_map = []
self.strips = [] self.strips = []
self.strip_midpoints = [] # midpoint LED index per strip (from STRIP_CONFIG) self.strip_midpoints = [] # midpoint LED index per strip (from STRIP_CONFIG)
@@ -80,10 +78,14 @@ class Presets:
self.strip_midpoints.append(mid) self.strip_midpoints.append(mid)
self.strips.append(WS2812B(num_leds, pin, state_machine, brightness=1.0)) self.strips.append(WS2812B(num_leds, pin, state_machine, brightness=1.0))
state_machine += 1 state_machine += 1
# Single logical strip over all 8 strips for patterns (n[i], .fill(), .write()) self.scale_map.append(self.create_scale_map(num_leds))
self.n = StripRing(self.strips)
self.num_leds = self.n.num_leds # 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) # 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 self.step = 0
# Remember which strip was last used as the roll head (for flare, etc.) # Remember which strip was last used as the roll head (for flare, etc.)
self.last_roll_head = 0 self.last_roll_head = 0
@@ -104,6 +106,7 @@ class Presets:
"transition": Transition(self).run, "transition": Transition(self).run,
"chase": Chase(self).run, "chase": Chase(self).run,
"circle": Circle(self).run, "circle": Circle(self).run,
"double_circle": DoubleCircle(self).run,
"roll": Roll(self).run, "roll": Roll(self).run,
"calibration": Calibration(self).run, "calibration": Calibration(self).run,
"test": Test(self).run, "test": Test(self).run,
@@ -112,27 +115,10 @@ class Presets:
"lift": Lift(self).run, "lift": Lift(self).run,
"flare": Flare(self).run, "flare": Flare(self).run,
"hook": Hook(self).run, "hook": Hook(self).run,
"invertsplit": Invertsplit(self).run,
"pose": Pose(self).run, "pose": Pose(self).run,
"backbalance": Backbalance(self).run, "segments": Segments(self).run,
"beat": Beat(self).run, "segments_transition": SegmentsTransition(self).run,
"crouch": Crouch(self).run, "point": Segments(self).run, # backwards-compatible alias
"backbendsplit": Backbendsplit(self).run,
"straddle": Straddle(self).run,
"frontbalance": Frontbalance(self).run,
"elbowhang": Elbowhang(self).run,
"elbowhangspin": Elbowhangspin(self).run,
"dismount": Dismount(self).run,
"fluff": Fluff(self).run,
"elbowhangsplit": Elbowhangsplit(self).run,
"invert": Invert(self).run,
"backbend": Backbend(self).run,
"seat": Seat(self).run,
"kneehang": Kneehang(self).run,
"legswoop": Legswoop(self).run,
"split": Split(self).run,
"foothang": Foothang(self).run,
"point": Point(self).run,
} }
# --- Strip geometry utilities ------------------------------------------------- # --- Strip geometry utilities -------------------------------------------------
@@ -143,40 +129,6 @@ class Presets:
return self.strips[strip_idx].num_leds return self.strips[strip_idx].num_leds
return 0 return 0
def strip_index_to_angle(self, strip_idx, index):
"""Map an LED index on a given strip to a normalized angle 0..1.
This accounts for different strip lengths so that the same angle value
corresponds to the same physical angle on all concentric strips.
"""
n = self.strip_length(strip_idx)
if n <= 0:
return 0.0
index = int(index) % n
return index / float(n)
def strip_angle_to_index(self, strip_idx, angle):
"""Map a normalized angle 0..1 to an LED index on a given strip.
Use this when you want patterns to align by angle instead of raw index,
despite strips having different circumferences.
"""
n = self.strip_length(strip_idx)
if n <= 0:
return 0
# Normalize angle into [0,1)
angle = float(angle)
angle = angle - int(angle)
if angle < 0.0:
angle += 1.0
return int(angle * n) % n
def remap_strip_index(self, src_strip_idx, dst_strip_idx, src_index):
"""Remap an index from one strip to another so they align by angle."""
angle = self.strip_index_to_angle(src_strip_idx, src_index)
return self.strip_angle_to_index(dst_strip_idx, angle)
def save(self): def save(self):
"""Save the presets to a file.""" """Save the presets to a file."""
with open("presets.json", "w") as f: with open("presets.json", "w") as f:
@@ -251,14 +203,6 @@ class Presets:
self.selected = preset_name self.selected = preset_name
return True return True
def update_num_leds(self, pin, num_leds):
num_leds = int(num_leds)
if isinstance(pin, Pin):
self.n = WS2812B(pin, num_leds)
else:
self.n = WS2812B(num_leds, int(pin), 0, brightness=1.0)
self.num_leds = num_leds
def apply_brightness(self, color, brightness_override=None): def apply_brightness(self, color, brightness_override=None):
# Combine per-preset brightness (override) with global brightness self.b # Combine per-preset brightness (override) with global brightness self.b
local = brightness_override if brightness_override is not None else 255 local = brightness_override if brightness_override is not None else 255
@@ -266,16 +210,36 @@ class Presets:
effective_brightness = int(local * self.b / 255) effective_brightness = int(local * self.b / 255)
return tuple(int(c * effective_brightness / 255) for c in color) return tuple(int(c * effective_brightness / 255) for c in color)
def fill(self, color=None):
fill_color = color if color is not None else (0, 0, 0)
for i in range(self.num_leds):
self.n[i] = fill_color
self.n.write()
def off(self, preset=None): def off(self, preset=None):
self.fill((0, 0, 0)) self.fill((0, 0, 0))
self.show_all()
def on(self, preset): def on(self, preset):
colors = preset.c colors = preset.c
color = colors[0] if colors else (255, 255, 255) color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.b)) self.fill(self.apply_brightness(color, preset.b))
self.show_all()
def fill(self, color):
for strip in self.strips:
strip.fill(color)
def fill_n(self, color, n1, n2):
for i in range(n1, n2):
for strip_idx in range(8):
self.set(strip_idx, i, color)
def set(self, strip, index, color):
if index >= self.strips[0].num_leds:
return False
self.strips[strip].set(self.scale_map[strip][index], color)
return True
def create_scale_map(self, num_leds):
ref_len = STRIP_CONFIG[0][1]
return [int(i * num_leds / ref_len) for i in range(ref_len)]
def show_all(self):
for strip in self.strips:
strip.show()

View File

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

52
pico/test/test_fill_n.py Normal file
View File

@@ -0,0 +1,52 @@
"""
On-device test for Presets.fill_n() 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_fill_n.py :
mpremote connect <device> run test_fill_n.py
This script:
- Instantiates Presets
- Calls fill_n() with a simple range
- Lets you visually confirm that all strips show the same proportional segment
and that equal-length strip pairs have identical lit indices.
"""
from presets import Presets
def main():
presets = Presets()
presets.load()
# Choose a simple test range on the reference strip (strip 0).
ref_len = presets.strip_length(0)
if ref_len <= 0:
print("No strips or invalid length; aborting fill_n test.")
return
# Use a central segment so it's easy to see.
start = ref_len // 4
end = 3 * ref_len // 4
print("Running fill_n test from", start, "to", end, "on reference strip 0.")
color = (0, 50, 0) # dim green
# First, clear everything
for strip in presets.strips:
strip.fill((0, 0, 0))
strip.show()
# Apply fill_n, which will use scale() internally.
presets.fill_n(color, start, end)
print("fill_n test applied; visually inspect strips.")
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()

100
pico/test/test_scale.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Test the Presets.scale() helper on-device with mpremote.
Usage (from project root):
mpremote connect <device> cp pico/src/*.py : &&
mpremote connect <device> cp pico/src/patterns/*.py :patterns &&
mpremote connect <device> cp pico/lib/*.py : &&
mpremote connect <device> cp tests/test_scale.py : &&
mpremote connect <device> run test_scale.py
This script:
- Creates a minimal Presets instance
- Runs a few numeric test cases for scale()
- Optionally displays a short visual check on the LEDs
"""
from presets import Presets
def numeric_tests(presets):
"""
Numeric sanity checks for scale() using the actual strip config.
We treat strip 0 as the reference and print the mapped indices for
a few positions on each other strip.
"""
print("Numeric scale() tests (from strip 0):")
ref_len = presets.strip_length(0)
if ref_len <= 0:
print(" strip 0 length <= 0; skipping numeric tests.")
return
test_positions = [0, ref_len // 2, ref_len - 1]
for pos in test_positions:
print(" pos on strip 0:", pos)
for dst_idx in range(len(presets.strips)):
dst_len = presets.strip_length(dst_idx)
if dst_len <= 0:
continue
n2 = presets.scale(dst_idx, pos)
print(" -> strip", dst_idx, "len", dst_len, "pos", n2)
def visual_test(presets):
"""
Simple visual test:
- Use strip 0 as reference
- Move a pixel along strip 0
- Map position to all other strips with scale()
"""
import utime
strips = presets.strips
if not strips:
print("No strips available for visual test.")
return
src_strip_idx = 0
l1 = presets.strip_length(src_strip_idx)
if l1 <= 0:
print("strip_length(0) <= 0; aborting visual test.")
return
color = (50, 0, 0) # dim red so it doesn't blind you
# Run once across the full length of the reference strip,
# jumping 10 LEDs at a time.
step_size = 10
steps = (l1 + step_size - 1) // step_size
print("Starting visual scale() test with 10-LED jumps:", steps, "steps...")
for step in range(steps):
n1 = (step * step_size) % l1
# Clear all strips
for strip in strips:
strip.fill((0, 0, 0))
# Light mapped position on each strip using Presets.set/show
for dst_strip_idx, _ in enumerate(strips):
presets.set(dst_strip_idx, n1, color)
presets.show(dst_strip_idx)
print("Visual test finished.")
def main():
presets = Presets()
presets.load()
numeric_tests(presets)
# Comment this in/out depending on whether you want the LEDs to run:
try:
visual_test(presets)
except Exception as e:
print("Visual test error:", e)
if __name__ == "__main__":
main()

130
pico/test/test_segments.py Normal file
View File

@@ -0,0 +1,130 @@
"""
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_segments.py :
mpremote connect <device> run test_segments.py
This script:
- Instantiates Presets
- 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_segments_preset(name, colors, n_values, brightness=255):
"""
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]
"""
# Pad or trim colors to 4 entries
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", # pattern key for Segments
"c": cs,
"b": brightness,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
# '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 segments preset and hold it for wait_ms."""
presets.presets[name] = preset_obj
presets.select(name)
# Segments 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:
# Keep ticking in case other logic ever depends on it
presets.tick()
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting segments test.")
return
print("Starting segments pattern test...")
quarter = num_leds // 4
half = num_leds // 2
segments_presets = []
# 1. Single band: first quarter, red
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
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
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,
2 * quarter - 1, # green
2 * quarter,
3 * quarter - 1, # blue
0,
-1,
],
)
)
# Show each for ~4 seconds
for name, preset_obj in segments_presets:
print("Showing segments preset:", name)
show_and_wait(presets, name, preset_obj, wait_ms=4000)
print("Segments pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

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()