diff --git a/src/patterns/aurora.py b/src/patterns/aurora.py index a7aa2ea..f9341d2 100644 --- a/src/patterns/aurora.py +++ b/src/patterns/aurora.py @@ -26,13 +26,13 @@ class Aurora: c = self.driver.apply_brightness(colors[idx], preset.b) w = 255 - abs(128 - ((i * 8 + phase) & 255)) * 2 w = max(0, min(255, w + shimmer)) - self.driver.n[i] = ( + self.driver.n[self.driver.led_i(preset, i)] = ( (c[0] * w) // 255, (c[1] * w) // 255, (c[2] * w) // 255, ) self.driver.n.write() - phase = (phase + 1) & 255 + phase = (phase + self.driver.signed(preset, 1)) & 255 self.driver.step = phase last = utime.ticks_add(last, d) if not preset.a: @@ -76,9 +76,9 @@ class Aurora: peak = lerp3(colors[fi], colors[fi + 1], frac) peak = self.driver.apply_brightness(peak, preset.b) mixf = min(255, int(w * contrast * 2) >> 1) - self.driver.n[i] = lerp3(bg, peak, mixf) + self.driver.n[self.driver.led_i(preset, i)] = lerp3(bg, peak, mixf) self.driver.n.write() - phase = (phase + drift) % 256 + phase = (phase + self.driver.signed(preset, drift)) % 256 last = utime.ticks_add(last, d) if not preset.a: yield diff --git a/src/patterns/blizzard.py b/src/patterns/blizzard.py index a04849a..e3c2ab3 100644 --- a/src/patterns/blizzard.py +++ b/src/patterns/blizzard.py @@ -46,12 +46,14 @@ class Blizzard: for pos, ci, wj in flakes: p = pos lateral = wind + (wj if wj else 0) - p -= speed - p += lateral + p -= self.driver.signed(preset, speed) + p += self.driver.signed(preset, lateral) if p < -2 or p >= nled + 2: continue pi = max(0, min(nled - 1, int(p))) - self.driver.n[pi] = self.driver.apply_brightness(colors[ci], preset.b) + self.driver.n[self.driver.led_i(preset, pi)] = self.driver.apply_brightness( + colors[ci], preset.b + ) nf.append([p, ci, wj]) flakes = nf diff --git a/src/patterns/chase.py b/src/patterns/chase.py index 9a01bfb..19697d6 100644 --- a/src/patterns/chase.py +++ b/src/patterns/chase.py @@ -12,7 +12,7 @@ class Chase: def _run_marquee(self, preset, colors): on_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 3) off_len = max(1, int(preset.n2) if int(preset.n2) > 0 else 2) - step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1) + step = max(1, abs(self.driver.signed(preset, int(preset.n3) if int(preset.n3) > 0 else 1))) phase = self.driver.step % (on_len + off_len) last = utime.ticks_ms() while True: @@ -23,9 +23,9 @@ class Chase: bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) for i in range(self.driver.num_leds): m = (i + phase) % (on_len + off_len) - self.driver.n[i] = c if m < on_len else bg_color + self.driver.n[self.driver.led_i(preset, i)] = c if m < on_len else bg_color self.driver.n.write() - phase = (phase + step) % (on_len + off_len) + phase = (phase + self.driver.signed(preset, step)) % (on_len + off_len) self.driver.step = phase last = utime.ticks_add(last, d) if not preset.a: @@ -66,8 +66,8 @@ class Chase: n1 = max(1, int(preset.n1)) # LEDs of color 0 n2 = max(1, int(preset.n2)) # LEDs of color 1 - n3 = int(preset.n3) # Step movement on even steps (can be negative) - n4 = int(preset.n4) # Step movement on odd steps (can be negative) + n3 = self.driver.signed(preset, int(preset.n3)) # Step movement on even steps + n4 = self.driver.signed(preset, int(preset.n4)) # Step movement on odd steps segment_length = n1 + n2 @@ -101,9 +101,9 @@ class Chase: # Determine which color based on position in segment if relative_pos < n1: - self.driver.n[i] = color0 + self.driver.n[self.driver.led_i(preset, i)] = color0 else: - self.driver.n[i] = color1 + self.driver.n[self.driver.led_i(preset, i)] = color1 self.driver.n.write() print("[chase] step", step_count) @@ -147,9 +147,9 @@ class Chase: # Determine which color based on position in segment if relative_pos < n1: - self.driver.n[i] = color0 + self.driver.n[self.driver.led_i(preset, i)] = color0 else: - self.driver.n[i] = color1 + self.driver.n[self.driver.led_i(preset, i)] = color1 self.driver.n.write() print("[chase] step", step_count) diff --git a/src/patterns/colour_cycle.py b/src/patterns/colour_cycle.py index 105acb2..fb13859 100644 --- a/src/patterns/colour_cycle.py +++ b/src/patterns/colour_cycle.py @@ -18,7 +18,7 @@ class ColourCycle: pos -= 170 return (0, pos * 3, 255 - pos * 3) - def _render_gradient(self, colors, phase, brightness): + def _render_gradient(self, preset, colors, phase, brightness): num_leds = self.driver.num_leds color_count = len(colors) if num_leds <= 0 or color_count <= 0: @@ -40,14 +40,16 @@ class ColourCycle: c1[1] + ((c2[1] - c1[1]) * frac) // 256, c1[2] + ((c2[2] - c1[2]) * frac) // 256, ) - self.driver.n[i] = self.driver.apply_brightness(blended, brightness) + self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness( + blended, brightness + ) self.driver.n.write() - def _render_rainbow(self, phase, brightness): + def _render_rainbow(self, preset, phase, brightness): num_leds = self.driver.num_leds for i in range(num_leds): rc_index = (i * 256 // max(1, num_leds)) + phase - self.driver.n[i] = self.driver.apply_brightness( + self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness( self._wheel(rc_index & 255), brightness ) self.driver.n.write() @@ -64,8 +66,8 @@ class ColourCycle: if mode == 1: if not preset.a: - self._render_rainbow(phase, preset.b) - self.driver.step = (phase + step_amount) % 256 + self._render_rainbow(preset, phase, preset.b) + self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256 yield return last_update = utime.ticks_ms() @@ -73,16 +75,16 @@ class ColourCycle: delay_ms = max(1, int(preset.d)) now = utime.ticks_ms() if utime.ticks_diff(now, last_update) >= delay_ms: - self._render_rainbow(phase, preset.b) - phase = (phase + step_amount) % 256 + self._render_rainbow(preset, phase, preset.b) + phase = (phase + self.driver.signed(preset, step_amount)) % 256 self.driver.step = phase last_update = utime.ticks_add(last_update, delay_ms) yield colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)] if not preset.a: - self._render_gradient(colors, phase, preset.b) - self.driver.step = (phase + step_amount) % 256 + self._render_gradient(preset, colors, phase, preset.b) + self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256 yield return @@ -91,8 +93,8 @@ class ColourCycle: delay_ms = max(1, int(preset.d)) now = utime.ticks_ms() if utime.ticks_diff(now, last_update) >= delay_ms: - self._render_gradient(colors, phase, preset.b) - phase = (phase + step_amount) % 256 + self._render_gradient(preset, colors, phase, preset.b) + phase = (phase + self.driver.signed(preset, step_amount)) % 256 self.driver.step = phase last_update = utime.ticks_add(last_update, delay_ms) yield diff --git a/src/patterns/icicles.py b/src/patterns/icicles.py index 23071c7..8a64887 100644 --- a/src/patterns/icicles.py +++ b/src/patterns/icicles.py @@ -44,7 +44,7 @@ class Icicles: if idx >= nled: break br = ((k + 1) * 255) // max(1, ic_len) - self.driver.n[idx] = ( + self.driver.n[self.driver.led_i(preset, idx)] = ( (tip[0] * br + bg[0] * (255 - br)) // 255, (tip[1] * br + bg[1] * (255 - br)) // 255, (tip[2] * br + bg[2] * (255 - br)) // 255, @@ -52,7 +52,7 @@ class Icicles: aidx += 1 self.driver.n.write() - phase = (phase + phase_step) % span + phase = (phase + self.driver.signed(preset, phase_step)) % span last = utime.ticks_add(last, d_ms) if not preset.a: diff --git a/src/patterns/meteor.py b/src/patterns/meteor.py index 6f88e28..3f160f3 100644 --- a/src/patterns/meteor.py +++ b/src/patterns/meteor.py @@ -30,9 +30,9 @@ class Meteor: base = colors[color_index % len(colors)] lit = self.driver.apply_brightness(base, preset.b) if 0 <= head < self.driver.num_leds: - self.driver.n[head] = lit + self.driver.n[self.driver.led_i(preset, head)] = lit self.driver.n.write() - head += direction * speed + head += self.driver.signed(preset, direction * speed) if head >= self.driver.num_leds + tail_len: head = self.driver.num_leds - 1 direction = -1 @@ -62,14 +62,22 @@ class Meteor: i1 = p1 - t if 0 <= i1 < self.driver.num_leds: s = (255 * (tail - t)) // max(1, tail) - self.driver.n[i1] = ((c1[0] * s) // 255, (c1[1] * s) // 255, (c1[2] * s) // 255) + self.driver.n[self.driver.led_i(preset, i1)] = ( + (c1[0] * s) // 255, + (c1[1] * s) // 255, + (c1[2] * s) // 255, + ) i2 = p2 + t if 0 <= i2 < self.driver.num_leds: s = (255 * (tail - t)) // max(1, tail) - self.driver.n[i2] = ((c2[0] * s) // 255, (c2[1] * s) // 255, (c2[2] * s) // 255) + self.driver.n[self.driver.led_i(preset, i2)] = ( + (c2[0] * s) // 255, + (c2[1] * s) // 255, + (c2[2] * s) // 255, + ) self.driver.n.write() - p1 += speed - p2 -= speed + p1 += self.driver.signed(preset, speed) + p2 -= self.driver.signed(preset, speed) if p1 - tail > self.driver.num_leds and p2 + tail < 0: p1 = 0 p2 = self.driver.num_leds - 1 - gap @@ -89,10 +97,10 @@ class Meteor: if dist < 0: dist = -dist if dist > width: - self.driver.n[i] = bg_color + self.driver.n[self.driver.led_i(preset, i)] = bg_color else: scale = ((width - dist) * 255) // max(1, width) - self.driver.n[i] = ( + self.driver.n[self.driver.led_i(preset, i)] = ( (base[0] * scale) // 255, (base[1] * scale) // 255, (base[2] * scale) // 255, @@ -101,7 +109,7 @@ class Meteor: if pause_frames > 0: pause_frames -= 1 else: - center += direction + center += self.driver.signed(preset, direction) if center >= self.driver.num_leds - 1: center = self.driver.num_leds - 1 direction = -1 @@ -121,7 +129,11 @@ class Meteor: if mode == 1: gap = max(0, int(preset.n3)) - p1, p2 = 0, self.driver.num_leds - 1 - gap + nled = self.driver.num_leds + if self.driver.is_reversed(preset): + p1, p2 = nled - 1, gap + else: + p1, p2 = 0, nled - 1 - gap last = utime.ticks_ms() while True: p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last) @@ -131,7 +143,11 @@ class Meteor: yield if mode == 2: - color_index, center, direction, pause_frames = 0, 0, 1, 0 + nled = self.driver.num_leds + if self.driver.is_reversed(preset): + color_index, center, direction, pause_frames = 0, max(0, nled - 1), -1, 0 + else: + color_index, center, direction, pause_frames = 0, 0, 1, 0 last_update = utime.ticks_ms() while True: color_index, center, direction, pause_frames, last_update, stepped = ( @@ -144,7 +160,11 @@ class Meteor: return yield - color_index, head, direction = 0, 0, 1 + nled = self.driver.num_leds + if self.driver.is_reversed(preset): + color_index, head, direction = 0, max(0, nled - 1), -1 + else: + color_index, head, direction = 0, 0, 1 last_update = utime.ticks_ms() while True: color_index, head, direction, last_update, stepped = self._run_meteor( diff --git a/src/patterns/particles.py b/src/patterns/particles.py index e84fb5f..dc4d23d 100644 --- a/src/patterns/particles.py +++ b/src/patterns/particles.py @@ -12,7 +12,7 @@ class Particles: def _run_snowfall(self, preset, colors, flakes, last): density = max(1, int(preset.n1) if int(preset.n1) > 0 else 20) - speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) + speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 1))) d = max(1, int(preset.d)) now = utime.ticks_ms() if utime.ticks_diff(now, last) < d: @@ -25,8 +25,10 @@ class Particles: nf = [] for pos, ci in flakes: if 0 <= pos < self.driver.num_leds: - self.driver.n[pos] = self.driver.apply_brightness(colors[ci], preset.b) - pos -= speed + self.driver.n[self.driver.led_i(preset, pos)] = self.driver.apply_brightness( + colors[ci], preset.b + ) + pos -= self.driver.signed(preset, speed) if pos >= -1: nf.append([pos, ci]) self.driver.n.write() @@ -34,7 +36,7 @@ class Particles: def _run_starfall(self, preset, colors, stars, last): rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 14)) - speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2) + speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 2))) tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10) max_stars = 4 d = max(1, int(preset.d)) @@ -65,13 +67,14 @@ class Particles: (base[2] * fade) // 255, ) lit = self.driver.apply_brightness(lit, preset.b) - o = self.driver.n[idx] - self.driver.n[idx] = ( + pix = self.driver.led_i(preset, idx) + o = self.driver.n[pix] + self.driver.n[pix] = ( max(o[0], lit[0]), max(o[1], lit[1]), max(o[2], lit[2]), ) - h -= speed + h -= self.driver.signed(preset, speed) if h >= -tail: s["h"] = h ns.append(s) diff --git a/src/patterns/pattern_direction.py b/src/patterns/pattern_direction.py new file mode 100644 index 0000000..c337a81 --- /dev/null +++ b/src/patterns/pattern_direction.py @@ -0,0 +1,19 @@ +"""Strip install direction: n5 bit 0 reverses along-strip motion (upside-down wiring).""" + + +def is_reversed(preset): + return bool(int(getattr(preset, "n5", 0) or 0) & 1) + + +def led_i(driver, preset, logical_index): + """Map a logical strip index (0 = pattern start) to a physical pixel index.""" + n = int(driver.num_leds) + i = int(logical_index) + if 0 <= i < n and is_reversed(preset): + return n - 1 - i + return i + + +def signed(preset, value): + v = int(value) + return -v if is_reversed(preset) else v diff --git a/src/patterns/pulse.py b/src/patterns/pulse.py index 1a74ce5..1f05469 100644 --- a/src/patterns/pulse.py +++ b/src/patterns/pulse.py @@ -13,13 +13,8 @@ class Pulse: bg_base = preset.background_or(colors) self.driver.fill(self.driver.apply_brightness(bg_base, preset.b)) + manual = not preset.a color_index = self.driver.step % max(1, len(colors)) - if not preset.a: - # Manual / beat trigger: each select restarts this generator and resets - # cycle_start below. Advancing step here makes each beat the next colour - # without requiring a full wall-clock cycle between beats. - nclr = max(1, len(colors)) - self.driver.step = (color_index + 1) % nclr cycle_start = utime.ticks_ms() # State machine based pulse using a single generator loop @@ -29,7 +24,7 @@ class Pulse: attack_ms = max(0, int(preset.n1)) # Attack time in ms hold_ms = max(0, int(preset.n2)) # Hold time in ms decay_ms = max(0, int(preset.n3)) # Decay time in ms - delay_ms = max(0, int(preset.d)) + delay_ms = 0 if manual else max(0, int(preset.d)) total_ms = attack_ms + hold_ms + decay_ms + delay_ms if total_ms <= 0: @@ -58,12 +53,13 @@ class Pulse: # Delay phase: LEDs off between pulses self.driver.fill(bg_color) else: - # End of cycle: auto advances colour and loops; manual already - # advanced step at run start for the next beat. - if not preset.a: - break - color_index = (color_index + 1) % max(1, len(colors)) + # End of cycle: advance colour for the next run, then loop or stop. + nclr = max(1, len(colors)) + color_index = (color_index + 1) % nclr self.driver.step = color_index + if manual: + self.driver.fill(bg_color) + break cycle_start = now yield continue diff --git a/src/preset.py b/src/preset.py index 1b990dc..b529465 100644 --- a/src/preset.py +++ b/src/preset.py @@ -32,6 +32,18 @@ class Preset: int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"} allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"} for key, value in data.items(): + if key == "reverse": + try: + if isinstance(value, bool): + self.n5 = 1 if value else 0 + elif isinstance(value, (int, float)): + self.n5 = 1 if int(value) else 0 + elif isinstance(value, str): + lowered = value.lower() + self.n5 = 1 if lowered in ("true", "1", "yes", "on") else 0 + except (TypeError, ValueError): + pass + continue key = aliases.get(key, key) if key not in allowed_fields: continue diff --git a/src/presets.py b/src/presets.py index 7a946e5..fe709d9 100644 --- a/src/presets.py +++ b/src/presets.py @@ -198,7 +198,7 @@ class Presets: if ( preset_name == self.selected and not preset.a - and preset.p == "chase" + and preset.p in ("chase", "pulse") and self.generator is not None ): while self.generator is not None: @@ -222,6 +222,21 @@ class Presets: self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds) self.num_leds = num_leds + def is_reversed(self, preset): + from patterns.pattern_direction import is_reversed as _is_reversed + + return _is_reversed(preset) + + def led_i(self, preset, logical_index): + from patterns.pattern_direction import led_i as _led_i + + return _led_i(self, preset, logical_index) + + def signed(self, preset, value): + from patterns.pattern_direction import signed as _signed + + return _signed(preset, value) + def apply_brightness(self, color, brightness_override=None): # Combine per-preset brightness (override) with global brightness self.b local = brightness_override if brightness_override is not None else 255