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
This commit is contained in:
2026-03-05 23:41:13 +13:00
parent 47c19eecf1
commit 3e58f4e97e
10 changed files with 749 additions and 51 deletions

View File

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

View File

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

View File

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

View File

@@ -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

@@ -1,618 +0,0 @@
{
"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

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