diff --git a/pico/src/patterns/roll.py b/pico/src/patterns/roll.py index 1a848b1..c0ae824 100644 --- a/pico/src/patterns/roll.py +++ b/pico/src/patterns/roll.py @@ -1,4 +1,5 @@ import utime +import math class Roll: @@ -6,149 +7,105 @@ class Roll: self.driver = driver def run(self, preset): - """Roll: moving band with gradient from color1 to color2 over the strips. + """Roll: all strips show a shared color gradient palette, cycling out of phase. - - n1: offset from start of strip (effective start = start + n1) - - n2: offset from end of strip (effective end = end - n2, inclusive) - - n3: number of full rotations before stopping (0 = infinite) + - All strips participate; each frame shows N discrete colors + (one per strip), from color1 to color2. + - Over time, each strip cycles through all colors, out of phase with the + others, creating a smooth rolling band around the hoop. - n4: direction (0 = clockwise, 1 = anti-clockwise) - - c[0]: color1 at the head strip - - c[1]: color2 at the tail strip + - c[0]: head color (full intensity) + - c[1]: tail color (usually darker or off) """ colors = preset.c - color1_raw = colors[0] if colors else (255, 255, 255) - color2_raw = colors[1] if len(colors) > 1 else (0, 0, 0) - color1 = self.driver.apply_brightness(color1_raw, preset.b) - color2 = self.driver.apply_brightness(color2_raw, preset.b) + base1 = colors[0] if colors else (255, 255, 255) + base2 = colors[1] if len(colors) > 1 else (0, 0, 0) + color1 = self.driver.apply_brightness(base1, preset.b) + color2 = self.driver.apply_brightness(base2, preset.b) n_segments = self.driver.n.num_strips if hasattr(self.driver.n, "num_strips") else 1 - # Margins from the start and end of each strip - start_margin = max(0, int(getattr(preset, "n1", 0))) - end_margin = max(0, int(getattr(preset, "n2", 0))) - - # Debug info to see why roll might be black - try: - print( - "ROLL preset", - "p=", getattr(preset, "p", None), - "b=", getattr(preset, "b", None), - "colors_raw=", color1_raw, color2_raw, - "colors_bright=", color1, color2, - ) - print( - "ROLL n1..n4", - getattr(preset, "n1", None), - getattr(preset, "n2", None), - getattr(preset, "n3", None), - getattr(preset, "n4", None), - "n_segments=", n_segments, - "start_margin=", start_margin, - "end_margin=", end_margin, - ) - except Exception: - pass - - # n3: number of rotations (0 = infinite) - max_rotations = int(getattr(preset, "n3", 0)) or 0 + # n3: number of full rotations before stopping (0 = continuous) + max_rotations = int(getattr(preset, "n3", 0) or 0) # n4: direction (0=cw, 1=ccw); default clockwise if missing clockwise = int(getattr(preset, "n4", 0)) == 0 step = self.driver.step - delay_ms = max(1, int(preset.d) or 1) - last_update = utime.ticks_ms() - rotations_done = 0 - def scale_color(c, f): - return tuple(int(x * f) for x in c) + # Precompute one shared buffer per brightness level (one per strip), + # using the longest strip length so any strip can DMA from it safely. + strips_list = self.driver.strips + if not strips_list or n_segments <= 0: + while True: + yield - def lerp_color(c1, c2, t): - """Linear gradient between two colors.""" - if t <= 0: - return c1 - if t >= 1: - return c2 - return ( - int(c1[0] + (c2[0] - c1[0]) * t), - int(c1[1] + (c2[1] - c1[1]) * t), - int(c1[2] + (c2[2] - c1[2]) * t), - ) + max_leds = max(s.num_leds for s in strips_list) - def draw(head): - # Remember head strip for flare + # Build N discrete color buffers forming a gradient from color1 to color2. + buffers = [] + for j in range(n_segments): + if n_segments > 1: + t = j / (n_segments - 1) + else: + t = 0.0 + # Linear interpolation between color1 and color2 + r = int(color1[0] + (color2[0] - color1[0]) * t) + g = int(color1[1] + (color2[1] - color1[1]) * t) + b = int(color1[2] + (color2[2] - color1[2]) * t) + + buf = bytearray(max_leds * 3) + for i in range(max_leds): + o = i * 3 + buf[o] = g & 0xFF + buf[o + 1] = r & 0xFF + buf[o + 2] = b & 0xFF + buffers.append(buf) + + def draw(step_index): + # Each strip picks a buffer index offset by its strip index so that: + # - all brightness levels are visible simultaneously (one per strip) + # - over time, each strip cycles through all brightness levels try: - self.driver.last_roll_head = head + self.driver.last_roll_head = step_index % n_segments except AttributeError: pass - strips_list = self.driver.strips - for strip_idx, strip in enumerate(strips_list): if strip_idx < 0 or strip_idx >= n_segments: continue - # Distance from head along direction, 0..n_segments-1 if clockwise: - dist = (head - strip_idx) % n_segments + buf_index = (step_index + strip_idx) % n_segments else: - dist = (strip_idx - head) % n_segments + buf_index = (step_index - strip_idx) % n_segments + buf = buffers[buf_index] - # Color gradient from color1 at the head strip to color2 at the tail strip - if n_segments > 1: - t = dist / (n_segments - 1) - else: - t = 0.0 - c_strip = lerp_color(color1, color2, t) - - n = strip.num_leds - # Effective segment per strip: - # start = 0 + start_margin - # end = (n - 1) - end_margin (inclusive) - width = n - start_margin - end_margin - if width <= 0: - # If margins are too large, fall back to full strip - seg_s = 0 - seg_e = n - else: - seg_s = max(0, min(n, start_margin)) - seg_e = min(n, n - end_margin) - - # Debug for first strip/head to see segment - try: - if strip_idx == 0 and head == 0: - print("ROLL seg strip0 n=", n, "seg_s=", seg_s, "seg_e=", seg_e) - except Exception: - pass - for i in range(n): - if seg_s <= i < seg_e: - strip.set(i, c_strip) - else: - strip.set(i, (0, 0, 0)) - strip.show() + # Show the shared buffer; WS2812B will read num_leds*3 bytes. + strip.show(buf, 0) if not preset.a: - head = step % n_segments if n_segments > 0 else 0 - draw(head) + draw(step) self.driver.step = step + 1 yield return + # Auto mode: advance based on preset.d (ms) for smooth, controllable speed + delay_ms = max(10, int(getattr(preset, "d", 60)) or 10) + last_update = utime.ticks_ms() - delay_ms + rotations_done = 0 + while True: - current_time = utime.ticks_ms() - if utime.ticks_diff(current_time, last_update) >= delay_ms: - head = step % n_segments if n_segments > 0 else 0 - if not clockwise and n_segments > 0: - head = (n_segments - 1 - head) - - draw(head) + now = utime.ticks_ms() + if utime.ticks_diff(now, last_update) >= delay_ms: + draw(step) step += 1 - + self.driver.step = step + last_update = now + # Count full rotations if requested: one rotation per n_segments steps if max_rotations > 0 and n_segments > 0 and (step % n_segments) == 0: rotations_done += 1 if rotations_done >= max_rotations: - self.driver.step = step - last_update = current_time - return - - self.driver.step = step - last_update = current_time + # Hold the final frame and stop advancing; keep yielding so + # the generator stays alive without changing the LEDs. + while True: + yield yield diff --git a/pico/test/roll.py b/pico/test/roll.py new file mode 100644 index 0000000..663387e --- /dev/null +++ b/pico/test/roll.py @@ -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 cp src/*.py : + mpremote connect cp src/patterns/*.py :patterns + mpremote connect cp lib/*.py : + mpremote connect cp test/roll.py : + mpremote connect 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() + diff --git a/pico/test/roll_strips.py b/pico/test/roll_strips.py deleted file mode 100644 index ab67aa3..0000000 --- a/pico/test/roll_strips.py +++ /dev/null @@ -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) diff --git a/pico/test/test_all_on.py b/pico/test/test_all_on.py new file mode 100644 index 0000000..c7e2603 --- /dev/null +++ b/pico/test/test_all_on.py @@ -0,0 +1,6 @@ +from neopixel import NeoPixel +from machine import Pin + +p = NeoPixel(Pin(6), 291) +p.fill((255, 255, 255)) +p.write() \ No newline at end of file diff --git a/pico/test/test_all_on_presets.py b/pico/test/test_all_on_presets.py new file mode 100644 index 0000000..07b3727 --- /dev/null +++ b/pico/test/test_all_on_presets.py @@ -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 cp src/*.py : + mpremote connect cp src/patterns/*.py :patterns + mpremote connect cp lib/*.py : + mpremote connect cp test/test_all_on_presets.py : + mpremote connect 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() + diff --git a/pico/test/test_chase_via_presets.py b/pico/test/test_chase_via_presets.py new file mode 100644 index 0000000..181783d --- /dev/null +++ b/pico/test/test_chase_via_presets.py @@ -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 cp src/*.py : + mpremote connect cp src/patterns/*.py :patterns + mpremote connect cp lib/*.py : + mpremote connect cp test/test_chase_via_presets.py : + mpremote connect 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() + diff --git a/pico/test/test_roll.py b/pico/test/test_roll.py deleted file mode 100644 index 486e2da..0000000 --- a/pico/test/test_roll.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -On-device test for the Roll pattern using mpremote. - -Usage (from pico/ dir or project root with adjusted paths): - - mpremote connect cp src/*.py : - mpremote connect cp src/patterns/*.py :patterns - mpremote connect cp lib/*.py : - mpremote connect cp test/test_roll.py : - mpremote connect run test_roll.py - -This script: - - Instantiates Presets - - Creates a few in-memory roll presets with different parameters - - Runs each one for a short time so you can visually compare behaviour -""" - -import utime -from presets import Presets, Preset - - -def make_roll_preset(name, color1, color2, n1=0, n2=0, n3=0, n4=0, delay_ms=50, brightness=255): - """Helper to build a Preset dict for the roll pattern.""" - data = { - "p": "roll", - "c": [color1, color2], - "b": brightness, - "d": delay_ms, - "n1": n1, - "n2": n2, - "n3": n3, - "n4": n4, - "a": True, # animated - } - return name, Preset(data) - - -def run_preset(presets, name, preset_obj, duration_ms): - """Run a given roll preset for duration_ms using the existing tick loop.""" - presets.presets[name] = preset_obj - presets.select(name) - - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms: - presets.tick() - - -def main(): - presets = Presets() - presets.load() - - print("Starting roll pattern test...") - - ref_len = presets.strip_length(0) - # Use some margins based on strip length to show different bands. - quarter = ref_len // 4 - - # Define a few different roll presets: - roll_presets = [] - - # 1. Full-strip, white -> off, clockwise, medium speed - roll_presets.append( - make_roll_preset( - "roll_full_cw", - color1=(255, 255, 255), - color2=(0, 0, 0), - n1=0, - n2=0, - n3=2, # 2 rotations then stop - n4=0, # clockwise - delay_ms=40, - brightness=255, - ) - ) - - # 2. Inner band only, red -> blue, clockwise, slower - roll_presets.append( - make_roll_preset( - "roll_inner_band", - color1=(255, 0, 0), - color2=(0, 0, 255), - n1=quarter, # start margin - n2=quarter, # end margin - n3=2, - n4=0, - delay_ms=60, - brightness=255, - ) - ) - - # 3. Full-strip, green -> off, counter-clockwise, faster - roll_presets.append( - make_roll_preset( - "roll_full_ccw", - color1=(0, 255, 0), - color2=(0, 0, 0), - n1=0, - n2=0, - n3=2, - n4=1, # counter-clockwise - delay_ms=30, - brightness=255, - ) - ) - - # Run each roll preset for about 5 seconds - for name, preset_obj in roll_presets: - print("Running roll preset:", name) - run_preset(presets, name, preset_obj, duration_ms=5000) - - print("Roll pattern test finished. Turning off LEDs.") - presets.select("off") - presets.tick() - - -if __name__ == "__main__": - main() -