Add Pico presets engine, patterns, and tests.

Wire the Pico to UART-driven preset selection, add pattern modules and presets data, remove old p2p/settings code, and update tests and LED driver.

Made-with: Cursor
This commit is contained in:
2026-03-03 19:28:11 +13:00
parent 646b988cdd
commit 52a5f0f8c4
44 changed files with 2175 additions and 373 deletions

View File

@@ -1,99 +1,63 @@
import sys
# So "from ws2812 import WS2812B" finds pico/lib when run from device / or test/
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
from machine import UART, Pin
import json
from presets import Presets
import gc
# --- Rainbow pattern (outside ws2812): pregen double buffer, show via head offset ---
uart = UART(0, baudrate=921600, rx=Pin(1, Pin.IN))
def hue_to_rgb(hue):
"""Hue 0..360 -> (r, g, b). Simple HSV with S=V=1."""
h = hue % 360
x = 1 - abs((h / 60) % 2 - 1)
if h < 60:
r, g, b = 1, x, 0
elif h < 120:
r, g, b = x, 1, 0
elif h < 180:
r, g, b = 0, 1, x
elif h < 240:
r, g, b = 0, x, 1
elif h < 300:
r, g, b = x, 0, 1
else:
r, g, b = 1, 0, x
return (int(r * 255), int(g * 255), int(b * 255))
presets = Presets()
presets.load()
print(presets.presets.keys())
def make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len).
head must be in 0..strip_len-1 so DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = (i / n) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
b = int(b * brightness) & 0xFF
o = i * 3
double_buf[o] = g
double_buf[o + 1] = r
double_buf[o + 2] = b
strip_len = num_leds * 3
return (double_buf, strip_len)
presets.select("off")
#print memory usage
print(f"Memory usage: {gc.mem_free()/1024} kB free")
def show_rainbow(strip, double_buf, strip_len, head):
"""DMA reads directly from double_buf at head; no copy. head in 0..strip_len-1."""
strip.show(double_buf, head)
# --- Strips + rainbow buffers per strip ---
# Each strip can have a different length; buffers and phase are per-strip.
# Strip config must match pico/src/main.py pins.
STRIP_CONFIG = (
(7, 291),
(3, 290),
(6, 283),
(28, 278),
(29, 275),
(4, 270),
(0, 283),
(2, 290),
)
strips = []
sm = 0
for pin, num_leds in STRIP_CONFIG:
print(pin, num_leds)
ws = WS2812B(num_leds, pin, sm, brightness=0.2) # 1.0 so fill() is visible
strips.append(ws)
sm += 1
# Cumulative LED count before each strip; total ring size
cumulative_leds = [0]
for ws in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + ws.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
bytes_per_cycle = total_ring_leds * 3
# One rainbow double buffer per strip (length = 2 * num_leds for that strip)
now = time.ticks_ms()
rainbow_data = [make_rainbow_double(ws.num_leds, ws.brightness) for ws in strips]
# Global phase in bytes; each strip: head = (phase + cumulative_leds[i]*3) % strip_len[i]
print(time.ticks_diff(time.ticks_ms(), now), "ms")
rainbow_head = 0
step = 3
i = 0
while True:
now = time.ticks_ms()
for i, (strip, (double_buf, strip_len)) in enumerate(zip(strips, rainbow_data)):
head = (rainbow_head + cumulative_leds[i] * 3) % strip_len
show_rainbow(strip, double_buf, strip_len, head)
rainbow_head = (rainbow_head + step) % bytes_per_cycle
#print(time.ticks_diff(time.ticks_ms(), now), "ms")
time.sleep_ms(10)
presets.tick()
if uart.any():
data = uart.readline()
try:
data = json.loads(data)
except:
# Ignore malformed JSON lines
continue
# Select a preset by name (existing behaviour)
preset_name = data.get("select")
if preset_name is not None:
presets.select(preset_name)
presets.tick()
# Create or update a preset:
# {"preset_edit": {"name": "<name>", "data": {<preset_dict>}}}
edit_payload = data.get("preset_edit")
if isinstance(edit_payload, dict):
name = edit_payload.get("name")
preset_data = edit_payload.get("data") or {}
if isinstance(name, str) and isinstance(preset_data, dict):
# Log the incoming preset payload for debugging
print("PRESET_EDIT", name, preset_data)
presets.edit(name, preset_data)
# Delete a preset:
# {"preset_delete": "<name>"}
delete_name = data.get("preset_delete")
if isinstance(delete_name, str):
print("PRESET_DELETE", delete_name)
presets.delete(delete_name)
# Persist all presets to flash:
# {"preset_save": true}
if data.get("preset_save"):
print("PRESET_SAVE")
presets.save()
print(data)
gc.collect()
#print used and free memory
print(f"Memory usage: {gc.mem_alloc()/1024} kB used, {gc.mem_free()/1024} kB free")

View File

@@ -1,16 +0,0 @@
import asyncio
import aioespnow
import json
async def p2p(settings, patterns):
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
e.active(True)
async for mac, msg in e:
try:
data = json.loads(msg)
except:
print(f"Failed to load espnow data {msg}")
continue
if "names" not in data or settings.get("name") in data.get("names", []):
await settings.set_settings(data.get("settings", {}), patterns, data.get("save", False))

View File

@@ -4,3 +4,32 @@ from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle
from .roll import Roll
from .calibration import Calibration
from .test import Test
from .grab import Grab
from .spin import Spin
from .lift import Lift
from .flare import Flare
from .hook import Hook
from .invertsplit import Invertsplit
from .pose import Pose
from .backbalance import Backbalance
from .beat import Beat
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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
"""Calibration: strips 2 and 6 only. First 10 green, then alternating 10 blue / 10 red. 10% brightness."""
BRIGHTNESS = 0.10
BLOCK = 10
STRIPS_ON = (2, 6) # 0-based: 3rd and 7th strip only
GREEN = (0, 255, 0)
RED = (255, 0, 0)
BLUE = (0, 0, 255)
def _scale(color, factor):
return tuple(int(c * factor) for c in color)
class Calibration:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
green = _scale(GREEN, BRIGHTNESS)
red = _scale(RED, BRIGHTNESS)
blue = _scale(BLUE, BRIGHTNESS)
on_set = set(STRIPS_ON)
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
if strip_idx not in on_set:
strip.fill((0, 0, 0))
strip.show()
continue
for i in range(n):
if i < BLOCK:
strip.set(i, green)
else:
block = (i - BLOCK) // BLOCK
strip.set(i, blue if block % 2 == 0 else red)
strip.show()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
import utime
class Flare:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Flare: on the strip used by the first roll head,
make the strip fade up to brightness over the delay time.
- c[0]: color1 for the first n1 LEDs
- c[1]: color2 for the rest of the strip
- n1: number of LEDs from the start of the strip that use color1
- d: fade-in duration in ms (time to reach full preset brightness b)
"""
strips = self.driver.strips
# Which strip to flare: last roll head, clamped to valid range
strip_idx = getattr(self.driver, "last_roll_head", 0)
if strip_idx < 0 or strip_idx >= len(strips):
strip_idx = 0
strip = strips[strip_idx]
n = strip.num_leds
colors = preset.c
base_c1 = colors[0] if len(colors) > 0 else (255, 255, 255)
base_c2 = colors[1] if len(colors) > 1 else (0, 0, 0)
count_c1 = max(0, min(int(preset.n1), n))
fade_ms = max(1, int(preset.d) or 1)
target_b = int(preset.b) if hasattr(preset, "b") else 255
start_time = utime.ticks_ms()
done = False
while True:
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if not done:
if elapsed >= fade_ms:
factor = 1.0
done = True
else:
factor = elapsed / fade_ms if fade_ms > 0 else 1.0
else:
factor = 1.0
# Effective per-preset brightness scaled over time
current_b = int(target_b * factor)
# Apply global + local brightness to both colors
c1 = self.driver.apply_brightness(base_c1, current_b)
c2 = self.driver.apply_brightness(base_c2, current_b)
for i in range(n):
strip.set(i, c1 if i < count_c1 else c2)
strip.show()
yield

View File

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

View File

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

View File

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

26
pico/src/patterns/grab.py Normal file
View File

@@ -0,0 +1,26 @@
"""Grab: from center of each strip, 10 LEDs each side (21 total) in purple."""
SPAN = 10 # LEDs on each side of center
PURPLE = (180, 0, 255)
class Grab:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
mid = self.driver.strip_midpoints[strip_idx]
strip.fill((0, 0, 0))
start = max(0, mid - SPAN)
end = min(n, mid + SPAN + 1)
for i in range(start, end):
strip.set(i, preset.c[0])
for strip in strips:
strip.show()
while True:
yield

31
pico/src/patterns/hook.py Normal file
View File

@@ -0,0 +1,31 @@
"""Hook: light strips n1 to n2 with a segment span long, offset from dead center."""
class Hook:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
midpoints = self.driver.strip_midpoints
n1 = max(0, int(preset.n1))
n2 = max(n1, int(preset.n2))
span = max(0, int(preset.n3))
offset = int(preset.n4) # positive = toward one end
color = preset.c[0] if preset.c else (0, 0, 0)
for strip_idx, strip in enumerate(strips):
strip.fill((0, 0, 0))
if n1 <= strip_idx <= n2:
mid = midpoints[strip_idx]
n = strip.num_leds
center = mid + offset
start = max(0, center - span)
end = min(n, center + span + 1)
for i in range(start, end):
strip.set(i, color)
for strip in strips:
strip.show()
while True:
yield

View File

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

View File

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

View File

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

View File

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

116
pico/src/patterns/lift.py Normal file
View File

@@ -0,0 +1,116 @@
"""Lift: opposite of Spin — arms contract from the ends toward the center. Preset color, n1 = rate."""
import utime
SPAN = 10 # LEDs on each side of center (match Grab)
LUT_SIZE = 256
class Lift:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
active_indices = (0, 4)
c0 = preset.c[0] if preset.c else (0, 0, 0)
c1 = preset.c[1] if len(preset.c) > 1 else c0
lut = []
for k in range(LUT_SIZE):
t = k / (LUT_SIZE - 1) if LUT_SIZE > 1 else 1
r = int(c0[0] + (c1[0] - c0[0]) * t)
g = int(c0[1] + (c1[1] - c0[1]) * t)
b = int(c0[2] + (c1[2] - c0[2]) * t)
lut.append((r, g, b))
midpoints = self.driver.strip_midpoints
rate = max(1, int(preset.n1) or 1)
delay_ms = max(1, int(preset.d) or 1)
margin = max(0, int(preset.n2) or 0)
left = {}
right = {}
for idx in active_indices:
if 0 <= idx < len(strips):
strip = strips[idx]
n = strip.num_leds
mid = midpoints[idx]
left[idx] = margin
right[idx] = n - margin
last_update = utime.ticks_ms()
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = now
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
strip = strips[idx]
n = strip.num_leds
mid = midpoints[idx]
step = max(1, rate // 2) if idx == 0 else rate
new_left = min(mid - SPAN, left[idx] + step)
new_right = max(mid + SPAN + 1, right[idx] - step)
left_len = max(0, (mid - SPAN) - new_left)
right_len = max(0, new_right - (mid + SPAN + 1))
bright = strip.brightness
ar = strip.ar
# Clear arm regions to black so contracted pixels turn off
for i in range(margin, mid - SPAN):
if 0 <= i < n:
base = i * 3
ar[base] = ar[base + 1] = ar[base + 2] = 0
for i in range(mid + SPAN + 1, n - margin):
if 0 <= i < n:
base = i * 3
ar[base] = ar[base + 1] = ar[base + 2] = 0
for j, i in enumerate(range(new_left, mid - SPAN)):
if 0 <= i < n:
t = 1 - j / (left_len - 1) if left_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
for j, i in enumerate(range(mid + SPAN + 1, new_right)):
if 0 <= i < n:
t = j / (right_len - 1) if right_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
left[idx] = new_left
right[idx] = new_right
strip.show()
# Check if all arms have contracted to center - run once, then hold
all_done = True
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
mid = midpoints[idx]
if left[idx] < mid - SPAN or right[idx] > mid + SPAN + 1:
all_done = False
break
if all_done:
while True:
yield
return
yield

View File

@@ -0,0 +1,68 @@
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

75
pico/src/patterns/pose.py Normal file
View File

@@ -0,0 +1,75 @@
class Pose:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Pose pattern: simple static bands that turn the hoop on
within the specified n ranges, across ALL strips.
Uses the preset's n values as inclusive ranges over the
logical ring (driver.n):
- n1n2: color c[0]
- n3n4: color c[1]
- n5n6: color c[2]
- n7n8: color c[3]
"""
# 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, max_len):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= max_len:
return None
a = max(0, a)
b = min(max_len - 1, b)
if a > b:
return None
return a, b
# For Pose, apply the same ranges on EVERY strip:
# each color band is repeated across all strips.
for strip in self.driver.strips:
strip_len = strip.num_leds
ranges = []
r1 = norm_range(getattr(preset, "n1", 0), getattr(preset, "n2", -1), strip_len)
if r1:
ranges.append((r1[0], r1[1], c1))
r2 = norm_range(getattr(preset, "n3", 0), getattr(preset, "n4", -1), strip_len)
if r2:
ranges.append((r2[0], r2[1], c2))
r3 = norm_range(getattr(preset, "n5", 0), getattr(preset, "n6", -1), strip_len)
if r3:
ranges.append((r3[0], r3[1], c3))
r4 = norm_range(getattr(preset, "n7", 0), getattr(preset, "n8", -1), strip_len)
if r4:
ranges.append((r4[0], r4[1], c4))
# Static draw on this strip: last range wins on overlaps
for i in range(strip_len):
color = (0, 0, 0)
for start, end, c in ranges:
if start <= i <= end:
color = c
strip.set(i, color)
# Flush all strips
for strip in self.driver.strips:
strip.show()
while True:
yield

View File

@@ -1,51 +1,97 @@
import utime
def _hue_to_rgb(hue):
"""Hue 0..360 -> (r, g, b). Simple HSV with S=V=1."""
h = hue % 360
x = 1 - abs((h / 60) % 2 - 1)
if h < 60:
r, g, b = 1, x, 0
elif h < 120:
r, g, b = x, 1, 0
elif h < 180:
r, g, b = 0, 1, x
elif h < 240:
r, g, b = 0, x, 1
elif h < 300:
r, g, b = x, 0, 1
else:
r, g, b = 1, 0, x
return (int(r * 255), int(g * 255), int(b * 255))
def _make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len_bytes).
DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = (i / n) * 360 * 2
r, g, b = _hue_to_rgb(hue)
double_buf[i * 3] = int(g * brightness) & 0xFF
double_buf[i * 3 + 1] = int(r * brightness) & 0xFF
double_buf[i * 3 + 2] = int(b * brightness) & 0xFF
strip_len_bytes = num_leds * 3
return (double_buf, strip_len_bytes)
def _ensure_buffers(driver, preset, buffers_cache):
"""Build or refresh per-strip double buffers with current brightness. Returns (rainbow_data, cumulative_bytes)."""
effective = (preset.b * driver.b) / (255 * 255)
key = (preset.b, driver.b)
if buffers_cache.get("key") == key and buffers_cache.get("data"):
return buffers_cache["data"], buffers_cache["cumulative_bytes"]
strips = driver.strips
rainbow_data = [_make_rainbow_double(s.num_leds, effective) for s in strips]
cumulative_bytes = [0]
for s in strips:
cumulative_bytes.append(cumulative_bytes[-1] + s.num_leds * 3)
buffers_cache["key"] = key
buffers_cache["data"] = rainbow_data
buffers_cache["cumulative_bytes"] = cumulative_bytes
return rainbow_data, cumulative_bytes
class Rainbow:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
self._buffers_cache = {}
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
step_amount = max(1, int(preset.n1)) # n1 = bytes to advance per frame (speed)
total_ring_bytes = self.driver.num_leds * 3
# Phase in bytes; driver.step kept in 0..255 for compatibility
phase = (self.driver.step * total_ring_bytes) // 256
# If auto is False, run a single step and then stop
rainbow_data, cumulative_bytes = _ensure_buffers(
self.driver, preset, self._buffers_cache
)
strips = self.driver.strips
def show_frame(phase):
for i, (strip, (double_buf, strip_len_bytes)) in enumerate(zip(strips, rainbow_data)):
head = (phase + cumulative_bytes[i]) % strip_len_bytes
strip.show(double_buf, head)
self.driver.step = (phase * 256) // total_ring_bytes
# Single step then stop
if not preset.a:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
self.driver.n.write()
# Increment step by n1 for next manual call
self.driver.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
show_frame(phase)
phase = (phase + step_amount) % total_ring_bytes
self.driver.step = (phase * 256) // total_ring_bytes
yield
return
last_update = utime.ticks_ms()
sleep_ms = max(1, int(preset.d))
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.d)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255),
preset.b,
rainbow_data, cumulative_bytes = _ensure_buffers(
self.driver, preset, self._buffers_cache
)
self.driver.n.write()
step = (step + step_amount) % 256
self.driver.step = step
show_frame(phase)
phase = (phase + step_amount) % total_ring_bytes
last_update = current_time
# Yield once per tick so other logic can run
yield

154
pico/src/patterns/roll.py Normal file
View File

@@ -0,0 +1,154 @@
import utime
class Roll:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Roll: moving band with gradient from color1 to color2 over the strips.
- 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)
- n4: direction (0 = clockwise, 1 = anti-clockwise)
- c[0]: color1 at the head strip
- c[1]: color2 at the tail strip
"""
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)
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
# 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)
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),
)
def draw(head):
# Remember head strip for flare
try:
self.driver.last_roll_head = head
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
else:
dist = (strip_idx - head) % n_segments
# 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()
if not preset.a:
head = step % n_segments if n_segments > 0 else 0
draw(head)
self.driver.step = step + 1
yield
return
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)
step += 1
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
yield

View File

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

98
pico/src/patterns/spin.py Normal file
View File

@@ -0,0 +1,98 @@
"""Spin: continues from Grab — segment (10 each side of center) moves slowly up to the top. Preset color, n1 = rate."""
import utime
SPAN = 10 # LEDs on each side of center (match Grab)
LUT_SIZE = 256 # gradient lookup table entries
class Spin:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
active_indices = (0, 4)
c0 = preset.c[0]
c1 = preset.c[1]
# Precompute gradient LUT: t in [0,1] maps to (r,g,b)
lut = []
for k in range(LUT_SIZE):
t = k / (LUT_SIZE - 1) if LUT_SIZE > 1 else 1
r = int(c0[0] + (c1[0] - c0[0]) * t)
g = int(c0[1] + (c1[1] - c0[1]) * t)
b = int(c0[2] + (c1[2] - c0[2]) * t)
lut.append((r, g, b))
# For each active strip we expand from just outside the grab center
# left: from (mid - SPAN) down to 0
# right: from (mid + SPAN) up to end
midpoints = self.driver.strip_midpoints
rate = max(1, int(preset.n1) or 1)
delay_ms = max(1, int(preset.d) or 1)
margin = max(0, int(preset.n2) or 0)
# Track current extents of each arm
left = {}
right = {}
for idx in active_indices:
if 0 <= idx < len(strips):
mid = midpoints[idx]
left[idx] = mid - SPAN # inner edge of left arm
right[idx] = mid + SPAN + 1 # inner edge of right arm
last_update = utime.ticks_ms()
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = now
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
strip = strips[idx]
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
new_left = max(margin, left[idx] - step)
new_right = min(n - margin, right[idx] + step)
# Left arm: c1 at outer, c0 at inner. Right arm: c0 at inner, c1 at outer.
left_len = max(0, (mid - SPAN) - new_left)
right_len = max(0, new_right - (mid + SPAN + 1))
bright = strip.brightness
ar = strip.ar
for j, i in enumerate(range(new_left, mid - SPAN)):
if 0 <= i < n:
t = 1 - j / (left_len - 1) if left_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
for j, i in enumerate(range(mid + SPAN + 1, new_right)):
if 0 <= i < n:
t = j / (right_len - 1) if right_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
left[idx] = new_left
right[idx] = new_right
# Show only on this strip
strip.show()
yield

View File

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

View File

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

28
pico/src/patterns/test.py Normal file
View File

@@ -0,0 +1,28 @@
"""Test pattern: strip i has (i+1) LEDs on at the start (indices 0..i) plus the midpoint LED on. 50% red."""
BRIGHTNESS = 0.50
RED = (255, 0, 0)
def _scale(color, factor):
return tuple(int(c * factor) for c in color)
class Test:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
red = _scale(RED, BRIGHTNESS)
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
mid = self.driver.strip_midpoints[strip_idx] # from STRIP_CONFIG
strip.fill((0, 0, 0))
# First (strip_idx + 1) LEDs on: indices 0..strip_idx
for i in range(min(strip_idx + 1, n)):
strip.set(i, red)
# Midpoint LED on
strip.set(mid, red)
strip.show()

View File

@@ -12,6 +12,8 @@ class Preset:
self.n4 = 0
self.n5 = 0
self.n6 = 0
self.n7 = 0
self.n8 = 0
# Override defaults with provided data
self.edit(data)
@@ -76,4 +78,6 @@ class Preset:
"n4": self.n4,
"n5": self.n5,
"n6": self.n6,
"n7": self.n7,
"n8": self.n8,
}

618
pico/src/presets.json Normal file
View File

@@ -0,0 +1,618 @@
{
"start": {
"p": "off",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"grab": {
"p": "grab",
"d": 0,
"b": 0,
"c": [
[64,0,255]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin1": {
"p": "spin",
"d": 0,
"b": 100,
"c": [
[64,0,255],
[255,105,180]
],
"n1": 1,
"n2": 20,
"n3": 0,
"n4": 0
},
"lift": {
"p": "lift",
"d": 0,
"b": 100,
"c": [
[64,0,255],
[255,105,180]
],
"n1": 1,
"n2": 20,
"n3": 0,
"n4": 0
},
"flare": {
"p": "flare",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"hook": {
"p": "hook",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 7,
"n3": 15,
"n4": 15
},
"roll1": {
"p": "roll",
"d": 200,
"b": 100,
"c": [
[64,0,255],
[20,20,40]
],
"n1": 50,
"n2": 160,
"n3": 1,
"n4": 0
},
"invertsplit": {
"p": "invertsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose1": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,0],
[0,255,0],
[0,0,255],
[255,255,255]
],
"n1": 100,
"n2": 150,
"n3": 650,
"n4": 700,
"n5": 1200,
"n6": 1250,
"n7": 1750,
"n8": 1800
},
"pose2": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,105,180],
[64,0,255],
[255,165,0],
[0,255,255]
],
"n1": 150,
"n2": 200,
"n3": 700,
"n4": 750,
"n5": 1250,
"n6": 1300,
"n7": 1800,
"n8": 1850
},
"roll2": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance1": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat1": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose3": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,255,0],
[255,0,255],
[0,255,255],
[255,255,255]
],
"n1": 200,
"n2": 250,
"n3": 750,
"n4": 800,
"n5": 1300,
"n6": 1350,
"n7": 1850,
"n8": 1900
},
"roll3": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"crouch": {
"p": "crouch",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose4": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[64,0,255],
[255,105,180],
[255,255,255],
[255,140,0]
],
"n1": 250,
"n2": 300,
"n3": 800,
"n4": 850,
"n5": 1350,
"n6": 1400,
"n7": 1900,
"n8": 1950
},
"roll4": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbendsplit": {
"p": "backbendsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance2": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance3": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat2": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"straddle": {
"p": "straddle",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat3": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"frontbalance1": {
"p": "frontbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose5": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,127],
[0,127,255],
[127,255,0],
[255,255,255]
],
"n1": 300,
"n2": 350,
"n3": 850,
"n4": 900,
"n5": 1400,
"n6": 1450,
"n7": 1950,
"n8": 2000
},
"pose6": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,80,0],
[0,200,120],
[80,0,255],
[255,255,255]
],
"n1": 350,
"n2": 400,
"n3": 900,
"n4": 950,
"n5": 1450,
"n6": 1500,
"n7": 2000,
"n8": 2050
},
"elbowhang": {
"p": "elbowhang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"elbowhangspin": {
"p": "elbowhangspin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin2": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"dismount": {
"p": "dismount",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin3": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"fluff": {
"p": "fluff",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin4": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"flare2": {
"p": "flare",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"elbowhangsplit2": {
"p": "elbowhangsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"invert": {
"p": "invert",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"roll5": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbend": {
"p": "backbend",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose7": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,0],
[255,165,0],
[255,255,0],
[255,255,255]
],
"n1": 400,
"n2": 450,
"n3": 950,
"n4": 1000,
"n5": 1500,
"n6": 1550,
"n7": 2050,
"n8": 2100
},
"roll6": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"seat": {
"p": "seat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"kneehang": {
"p": "kneehang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"legswoop": {
"p": "legswoop",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"split": {
"p": "split",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"foothang": {
"p": "foothang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"end": {
"p": "off",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
}
}

View File

@@ -1,20 +1,92 @@
from machine import Pin
from ws2812 import WS2812B
from preset import Preset
from patterns import Blink, Rainbow, Pulse, Transition, Chase, Circle
from patterns import (
Blink, Rainbow, Pulse, Transition, Chase, Circle, Roll, Calibration, Test,
Grab, Spin, Lift, Flare, Hook, Invertsplit, Pose,
Backbalance, Beat, Crouch, Backbendsplit, Straddle,
Frontbalance, Elbowhang, Elbowhangspin, Dismount, Fluff,
Elbowhangsplit, Invert, Backbend, Seat, Kneehang,
Legswoop, Split, Foothang, Point,
)
import json
# Order: strips[0]=physical 1 … strips[7]=physical 8. (pin, num_leds, midpoint_index).
STRIP_CONFIG = (
(6, 291, 291 // 2), # 1
(29, 290, 290 // 2-1), # 2
(3, 283, 283 // 2), # 3
(28, 278, 278 // 2-1), # 4
(2, 278, 275 // 2), # 5 (bottom of hoop)
(0, 283, 278 // 2-1), # 6
(4, 290, 283 // 2), # 7
(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:
def __init__(self, pin, num_leds, state_machine=0):
def __init__(self):
self.strips = []
self.strip_midpoints = [] # midpoint LED index per strip (from STRIP_CONFIG)
state_machine = 0
for entry in STRIP_CONFIG:
pin, num_leds = entry[0], entry[1]
mid = entry[2] if len(entry) >= 3 else num_leds // 2
self.strip_midpoints.append(mid)
self.strips.append(WS2812B(num_leds, pin, state_machine, brightness=1.0))
state_machine += 1
# Single logical strip over all 8 strips for patterns (n[i], .fill(), .write())
self.n = StripRing(self.strips)
self.num_leds = self.n.num_leds
# WS2812B with brightness=1.0 so Presets.apply_brightness() does all scaling (NeoPixel drop-in)
num_leds = int(num_leds)
if isinstance(pin, Pin):
self.n = WS2812B(pin, num_leds) # NeoPixel-style (Pin, n)
else:
self.n = WS2812B(num_leds, int(pin), state_machine, brightness=1.0)
self.num_leds = num_leds
self.step = 0
# Remember which strip was last used as the roll head (for flare, etc.)
self.last_roll_head = 0
# Global brightness (0255), controlled via UART/JSON {"b": <value>}
self.b = 255
@@ -32,8 +104,79 @@ class Presets:
"transition": Transition(self).run,
"chase": Chase(self).run,
"circle": Circle(self).run,
"roll": Roll(self).run,
"calibration": Calibration(self).run,
"test": Test(self).run,
"grab": Grab(self).run,
"spin": Spin(self).run,
"lift": Lift(self).run,
"flare": Flare(self).run,
"hook": Hook(self).run,
"invertsplit": Invertsplit(self).run,
"pose": Pose(self).run,
"backbalance": Backbalance(self).run,
"beat": Beat(self).run,
"crouch": Crouch(self).run,
"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 -------------------------------------------------
def strip_length(self, strip_idx):
"""Return number of LEDs for a physical strip index."""
if 0 <= strip_idx < len(self.strips):
return self.strips[strip_idx].num_leds
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):
"""Save the presets to a file."""
with open("presets.json", "w") as f:
@@ -87,19 +230,26 @@ class Presets:
self.generator = None
def select(self, preset_name, step=None):
if preset_name is None:
return False
print(f"Selecting preset: {preset_name}")
preset = None
pattern_key = preset_name
if preset_name in self.presets:
preset = self.presets[preset_name]
if preset.p in self.patterns:
# Set step value if explicitly provided
pattern_key = preset.p
if pattern_key not in self.patterns:
return False
# Run by pattern name (works for saved presets and built-ins like calibration, off, test)
if preset is None:
preset = Preset({"p": pattern_key}) if pattern_key != "off" else None
if step is not None:
self.step = step
elif preset.p == "off" or self.selected != preset_name:
elif pattern_key == "off" or self.selected != preset_name:
self.step = 0
self.generator = self.patterns[preset.p](preset)
self.selected = preset_name # Store the preset name, not the object
self.generator = self.patterns[pattern_key](preset)
self.selected = preset_name
return True
# If preset doesn't exist or pattern not found, default to "off"
return False
def update_num_leds(self, pin, num_leds):
num_leds = int(num_leds)

View File

@@ -1,94 +0,0 @@
import json
import ubinascii
import machine
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
self.color_order = self.get_color_order(self["color_order"])
def _default_name(self):
"""Device name: use unique_id on Pico (no WiFi); use AP MAC on ESP32."""
try:
import network
mac = network.WLAN(network.AP_IF).config("mac")
return "led-%s" % ubinascii.hexlify(mac).decode()
except Exception:
return "led-%s" % ubinascii.hexlify(machine.unique_id()).decode()
def set_defaults(self):
self["led_pin"] = 10
self["num_leds"] = 50
self["color_order"] = "rgb"
self["name"] = self._default_name()
self["debug"] = False
self["startup_preset"] = None
self["brightness"] = 255
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
self.set_defaults()
self.save()
def get_color_order(self, color_order):
"""Convert color order string to tuple of hex string indices."""
color_orders = {
"rgb": (1, 3, 5),
"rbg": (1, 5, 3),
"grb": (3, 1, 5),
"gbr": (3, 5, 1),
"brg": (5, 1, 3),
"bgr": (5, 3, 1)
}
return color_orders.get(color_order.lower(), (1, 3, 5)) # Default to RGB
def get_rgb_channel_order(self, color_order=None):
"""Convert color order string to RGB channel indices for reordering tuples.
Returns tuple of channel indices: (r_channel, g_channel, b_channel)
Example: 'grb' -> (1, 0, 2) means (G, R, B)"""
if color_order is None:
color_order = self.get("color_order", "rgb")
color_order = color_order.lower()
# Map hex string positions to RGB channel indices
# Position 1 (R in hex) -> channel 0, Position 3 (G) -> channel 1, Position 5 (B) -> channel 2
hex_to_channel = {1: 0, 3: 1, 5: 2}
hex_indices = self.get_color_order(color_order)
return tuple(hex_to_channel[pos] for pos in hex_indices)
# Example usage
def main():
settings = Settings()
print(f"Number of LEDs: {settings['num_leds']}")
settings['num_leds'] = 100
print(f"Updated number of LEDs: {settings['num_leds']}")
settings.save()
# Create a new Settings object to test loading
new_settings = Settings()
print(f"Loaded number of LEDs: {new_settings['num_leds']}")
print(settings)
# Run the example
if __name__ == "__main__":
main()

78
pico/test/chase.py Normal file
View File

@@ -0,0 +1,78 @@
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
# --- Chase test: pregenerated double buffer per strip, show via head offset (same as rainbow) ---
# (pin, num_leds) per strip — same config as rainbow
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
cumulative_leds = [0]
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
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."""
n = 2 * num_leds
buf = bytearray(n * 3)
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)
else:
r = grn = b_ = 0
o = b * 3
buf[o] = grn
buf[o + 1] = r
buf[o + 2] = b_
return buf
# Pregenerate one double buffer per strip
chase_buffers = [
make_chase_double(ws.num_leds, cumulative_leds[i], total_ring_leds, CHASE_COLOR, TRAIL_LEN)
for i, ws in enumerate(strips)
]
chase_pos = 0
while True:
for i, strip in enumerate(strips):
# head in [0, strip_len) so DMA read head..head+num_leds*3 stays in double buffer (same as rainbow)
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)

View File

@@ -28,13 +28,13 @@ def hue_to_rgb(hue):
return (int(r * 255), int(g * 255), int(b * 255))
def make_rainbow_ring(total_leds, brightness=1.0):
"""Build one rainbow over the whole ring: 2 full hue cycles over total_leds (GRB).
Returns (double_buf, ring_len_bytes). All strips sample from this so phase is continuous."""
n = 2 * total_leds
def make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len).
head must be in 0..strip_len-1 so DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = ((i % total_leds) / total_leds) * 360 * 2
hue = (i / n) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
@@ -43,48 +43,27 @@ def make_rainbow_ring(total_leds, brightness=1.0):
double_buf[o] = g
double_buf[o + 1] = r
double_buf[o + 2] = b
ring_len_bytes = total_leds * 3
return (double_buf, ring_len_bytes)
strip_len = num_leds * 3
return (double_buf, strip_len)
def make_strip_rainbow(num_leds, cumulative_leds, total_ring_leds, brightness=1.0):
"""Per-strip double buffer: pixel j has hue at global position (cumulative_leds + j) % total_ring_leds.
Use same head for all strips: head = rainbow_head % (2*num_leds*3)."""
n = 2 * num_leds
buf = bytearray(n * 3)
for j in range(n):
global_pos = (cumulative_leds + j) % total_ring_leds
hue = (global_pos / total_ring_leds) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
b = int(b * brightness) & 0xFF
o = j * 3
buf[o] = g
buf[o + 1] = r
buf[o + 2] = b
strip_len_bytes = num_leds * 3
return (buf, strip_len_bytes)
def show_rainbow(strip, double_buf, strip_len, head):
"""DMA reads directly from double_buf at head; no copy. head in 0..strip_len-1."""
strip.show(double_buf, head)
def show_rainbow_segment(strip, buf, strip_len_bytes, head):
"""DMA reads strip's segment from buf at head."""
strip.show(buf, head)
# --- Strips + one global ring rainbow (all strips in phase) ---
# Each strip can have a different length; one rainbow spans total_ring_leds so hue is continuous.
# (pin, num_leds) per strip — lengths differ per segment
# --- Strips + rainbow buffers per strip ---
# Each strip can have a different length; buffers and phase are per-strip.
# Strip config must match pico/src/main.py pins.
STRIP_CONFIG = (
(2, 291),
(7, 291),
(3, 290),
(4, 283),
(7, 278),
(0, 275),
(6, 283),
(28, 278),
(29, 283),
(6, 290),
(29, 275),
(4, 278),
(0, 283),
(2, 290),
)
strips = []
@@ -102,24 +81,19 @@ for ws in strips[:-1]:
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
bytes_per_cycle = total_ring_leds * 3
# Per-strip rainbow buffers: each strip's segment of the ring (same phase, no shared-buffer DMA)
# One rainbow double buffer per strip (length = 2 * num_leds for that strip)
now = time.ticks_ms()
rainbow_data = [
make_strip_rainbow(ws.num_leds, cumulative_leds[i], total_ring_leds, ws.brightness)
for i, ws in enumerate(strips)
]
rainbow_data = [make_rainbow_double(ws.num_leds, ws.brightness) for ws in strips]
# Global phase in bytes; each strip: head = (phase + cumulative_leds[i]*3) % strip_len[i]
print(time.ticks_diff(time.ticks_ms(), now), "ms")
rainbow_head = 0
step = 3
while True:
now = time.ticks_ms()
for i, (strip, (buf, strip_len_bytes)) in enumerate(zip(strips, rainbow_data)):
# Same head for all: each strip's buffer is already offset by cumulative_leds[i]
double_len_bytes = 2 * strip.num_leds * 3
head = rainbow_head % double_len_bytes
show_rainbow_segment(strip, buf, strip_len_bytes, head)
for i, (strip, (double_buf, strip_len)) in enumerate(zip(strips, rainbow_data)):
head = (rainbow_head + cumulative_leds[i] * 3) % strip_len
show_rainbow(strip, double_buf, strip_len, head)
rainbow_head = (rainbow_head + step) % bytes_per_cycle
#print(time.ticks_diff(time.ticks_ms(), now), "ms")
time.sleep_ms(10)

81
pico/test/roll_strips.py Normal file
View File

@@ -0,0 +1,81 @@
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)

45
pico/test/test_serial.py Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Serial loopback test single file, runs on Pico and ESP32.
Wire TX to RX (Pico: GP0GP1, ESP32: 1718), then:
mpremote run pico/test/test_serial.py
For ESP32→Pico: run test_serial_send.py on ESP32, test_serial_receive.py on Pico; wire ESP32 TX (17) to Pico RX (1).
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, RX_PIN, BAUD = 1, 17, 18, 115200
else:
UART_ID, TX_PIN, RX_PIN, BAUD = 0, 0, 1, 115200
READ_TIMEOUT_MS = 100
LINE_TERM = b"\n"
print("UART loopback: %s UART%d TX=%s RX=%s %d baud" % (sys.platform, UART_ID, TX_PIN, RX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT), rx=Pin(RX_PIN, Pin.IN))
uart.read()
to_send = [b"hello", b"123", b"{\"v\":\"1\"}"]
errors = []
for msg in to_send:
uart.write(msg + LINE_TERM)
time.sleep_ms(20)
buf = bytearray()
deadline = time.ticks_add(time.ticks_ms(), READ_TIMEOUT_MS)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
n = uart.any()
if n:
buf.extend(uart.read(n))
if LINE_TERM in buf:
break
time.sleep_ms(2)
got = bytes(buf).strip()
if got != msg:
errors.append((msg, got))
uart.deinit()
if errors:
print("FAIL loopback:", errors)
else:
print("PASS loopback: sent and received", to_send)

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
Serial receive test single file. Run on Pico (RX side).
Wire: ESP32 TX (GPIO17) → Pico RX (GPIO1); GND ↔ GND. Run send test on ESP32.
mpremote run pico/test/test_serial_receive.py
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, RX_PIN, BAUD = 1, 17, 18, 115200
else:
UART_ID, TX_PIN, RX_PIN, BAUD = 0, 0, 1, 115200
print("UART receive: %s UART%d TX=%s RX=%s %d baud (10 s)" % (sys.platform, UART_ID, TX_PIN, RX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT), rx=Pin(RX_PIN, Pin.IN))
buf = bytearray()
deadline = time.ticks_add(time.ticks_ms(), 10000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
n = uart.any()
if n:
buf.extend(uart.read(n))
while b"\n" in buf:
idx = buf.index(b"\n")
line = bytes(buf[:idx]).strip()
buf = buf[idx + 1:]
if line:
print("rx:", line.decode("utf-8", "replace"))
time.sleep_ms(10)
uart.deinit()
print("Receive test done.")

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""
Serial send test single file. Run on ESP32 (TX side).
Wire: ESP32 TX (GPIO17) → Pico RX (GPIO1); GND ↔ GND. Run receive test on Pico.
mpremote run pico/test/test_serial_send.py
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, BAUD = 1, 17, 115200
else:
UART_ID, TX_PIN, BAUD = 0, 0, 115200
print("UART send: %s UART%d TX=%s %d baud" % (sys.platform, UART_ID, TX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT))
for line in [b"serial send test 1", b"serial send test 2", b"{\"v\":\"1\",\"b\":128}"]:
uart.write(line + b"\n")
print("sent:", line.decode("utf-8"))
time.sleep_ms(50)
uart.deinit()
print("Send test done.")