From 55a97ac51cc4bb7f776b15277e27f8acfc6d5e72 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sat, 16 May 2026 21:14:54 +1200 Subject: [PATCH] feat(patterns): merge pattern styles and add mode support Consolidate legacy pattern ids into meteor, particles, sparkle, chase, and colour_cycle with n6/mode style selection; add pattern_modes helper, self-contained tests/all.py, and preset mode alias on wire. Co-authored-by: Cursor --- dev.py | 26 +---- docs/patterns.md | 118 ++++++++++++++++++++ src/background_tasks.py | 5 +- src/main.py | 37 ++----- src/mem_stats.py | 34 ++++++ src/patterns/aurora.py | 74 ++++++++++++- src/patterns/breathing_dual.py | 40 ------- src/patterns/chase.py | 40 ++++++- src/patterns/colour_cycle.py | 66 +++++++++-- src/patterns/comet_dual.py | 44 -------- src/patterns/fireflies.py | 35 ------ src/patterns/gradient_scroll.py | 57 ---------- src/patterns/heartbeat.py | 36 ------ src/patterns/ice_sparkle.py | 69 ------------ src/patterns/marquee.py | 31 ------ src/patterns/meteor.py | 156 ++++++++++++++++++++++++++ src/patterns/meteor_rain.py | 62 ----------- src/patterns/northern_wave.py | 53 --------- src/patterns/particles.py | 108 ++++++++++++++++++ src/patterns/pattern_modes.py | 18 +++ src/patterns/radiate.py | 13 ++- src/patterns/rainbow.py | 51 --------- src/patterns/scanner.py | 67 ----------- src/patterns/segment_chase.py | 45 -------- src/patterns/snowfall.py | 37 ------- src/patterns/sparkle.py | 147 ++++++++++++++++++++++++ src/patterns/sparkle_trail.py | 31 ------ src/patterns/starfall.py | 65 ----------- src/patterns/wave.py | 32 ------ src/preset.py | 1 + src/presets.py | 45 ++++++-- src/print_timestamp.py | 17 +++ src/wifi_sta.py | 36 ++++++ tests/all.py | 159 +++++++++++++++++++++++--- tests/patterns/auto_manual.py | 190 -------------------------------- tests/patterns/blizzard.py | 43 -------- tests/patterns/candle_glow.py | 40 ------- tests/patterns/ice_sparkle.py | 40 ------- tests/patterns/icicles.py | 43 -------- tests/patterns/northern_wave.py | 40 ------- tests/patterns/radiate.py | 52 --------- tests/patterns/rainbow.py | 151 ------------------------- tests/patterns/rime.py | 43 -------- tests/patterns/starfall.py | 40 ------- 44 files changed, 998 insertions(+), 1539 deletions(-) create mode 100644 docs/patterns.md create mode 100644 src/mem_stats.py delete mode 100644 src/patterns/breathing_dual.py delete mode 100644 src/patterns/comet_dual.py delete mode 100644 src/patterns/fireflies.py delete mode 100644 src/patterns/gradient_scroll.py delete mode 100644 src/patterns/heartbeat.py delete mode 100644 src/patterns/ice_sparkle.py delete mode 100644 src/patterns/marquee.py create mode 100644 src/patterns/meteor.py delete mode 100644 src/patterns/meteor_rain.py delete mode 100644 src/patterns/northern_wave.py create mode 100644 src/patterns/particles.py create mode 100644 src/patterns/pattern_modes.py delete mode 100644 src/patterns/rainbow.py delete mode 100644 src/patterns/scanner.py delete mode 100644 src/patterns/segment_chase.py delete mode 100644 src/patterns/snowfall.py create mode 100644 src/patterns/sparkle.py delete mode 100644 src/patterns/sparkle_trail.py delete mode 100644 src/patterns/starfall.py delete mode 100644 src/patterns/wave.py create mode 100644 src/print_timestamp.py delete mode 100644 tests/patterns/auto_manual.py delete mode 100644 tests/patterns/blizzard.py delete mode 100644 tests/patterns/candle_glow.py delete mode 100644 tests/patterns/ice_sparkle.py delete mode 100644 tests/patterns/icicles.py delete mode 100644 tests/patterns/northern_wave.py delete mode 100644 tests/patterns/radiate.py delete mode 100644 tests/patterns/rainbow.py delete mode 100644 tests/patterns/rime.py delete mode 100644 tests/patterns/starfall.py diff --git a/dev.py b/dev.py index 0d9fd7f..7c0bb71 100755 --- a/dev.py +++ b/dev.py @@ -67,27 +67,9 @@ for cmd in sys.argv[1:]: print("Error: Port required for 'db' command") case "test": if port: - if "all" in sys.argv[1:]: - test_files = sorted( - str(path) - for path in Path("test").rglob("*.py") - if path.is_file() - ) - failed = [] - for test_file in test_files: - print(f"Running {test_file}") - code = subprocess.call( - [*mpremote_base(), "connect", port, "run", test_file] - ) - if code != 0: - failed.append((test_file, code)) - if failed: - print("Some tests failed:") - for test_file, code in failed: - print(f" {test_file} (exit {code})") - else: - subprocess.call( - [*mpremote_base(), "connect", port, "run", "test/all.py"] - ) + # Single self-contained suite (tests/all.py); requires ``src`` on device first. + subprocess.call( + [*mpremote_base(), "connect", port, "run", "tests/all.py"] + ) else: print("Error: Port required for 'test' command") diff --git a/docs/patterns.md b/docs/patterns.md new file mode 100644 index 0000000..19ccdb6 --- /dev/null +++ b/docs/patterns.md @@ -0,0 +1,118 @@ +# Patterns and presets on the LED driver + +This document describes **how patterns are wired**, how **presets** map to patterns, and what each **shipped pattern** expects. For the JSON wire format (`v`: `"1"`, `presets`, `select`, short keys `p` / `c` / `b`, etc.), see [API.md](API.md). + +## End-to-end control + +1. The controller sends a **v1 JSON** object (ESP-NOW, serial bridge, or one line per message over TCP WebSocket in Wi-Fi mode). +2. `controller_messages.process_data()` parses it and applies fields in a fixed order (see `src/controller_messages.py`): + - `device_config` — name, LED count, colour order, startup mode; may reload `presets.json` and re-select the previous preset. + - `b` — **global** output brightness (0–255), stored in settings and in `presets.b`. + - `presets` — merge definitions into the in-memory preset table (`Presets.edit()` per id). + - `clear_presets` — optional wipe of all presets. + - `select` — pick the active preset (and optional step) for **this** device (matched by `settings["name"]`). + - `default` — update saved default preset when `targets` includes this device. + - `manifest` — pattern OTA: fetch pattern `.py` files and `reload_patterns()`. + - `save` — persist presets and/or settings when combined with the relevant fields. +3. The main loop calls `presets.tick()` so the active pattern **generator** advances one frame per iteration. + +## Presets + +- **Class:** `src/preset.py` — `Preset` holds the pattern configuration. +- **Short keys** (what the driver uses internally after `apply_presets` normalisation): + + | Key | Meaning | Default | + |-----|---------|--------| + | `p` | Pattern id (string), must match a registered pattern | `"off"` | + | `c` | Colours as RGB tuples (after colour-order conversion) | `[(255,255,255)]` | + | `d` | Delay (ms); meaning is pattern-specific | `100` | + | `b` | Preset brightness 0–255 (combined with global `presets.b`) | `127` | + | `a` | Auto: continuous animation; `false` = manual / beat-stepped where supported | `True` | + | `bg` | Background colour (hex string or RGB tuple on device) | `(0,0,0)` | + | `n1`–`n6` | Pattern-specific integers | `0` | + + Long aliases from the controller (`pattern`, `colors`, `delay`, `brightness`, `auto`, `background`) are converted in `Preset.edit()`. + +- **Persistence:** `presets.json` on flash; **`MAX_PRESETS` = 32** (exceptions for auto-created `"on"` / `"off"`). +- **Activation:** `Presets.select(preset_name, step=None)` loads the preset, looks up **`preset.p`** in the pattern registry, and sets `generator = patterns[preset.p](preset)`, then runs one `tick()` so the first frame appears. + +## Brightness + +- **Global:** `presets.b` from message `{"v":"1","b":…}` scales every output channel. +- **Per preset:** `preset.b`; combined in `Presets.apply_brightness(colour, preset.b)` as + `effective = round(preset_channel * presets.b / 255)` with preset level applied first conceptually (`apply_brightness` takes the preset’s `b` as the override for that colour). + +## Pattern registry + +Built in `Presets.reload_patterns()` (`src/presets.py`): + +1. **Built-ins:** `"off"` and `"on"` — methods on the `Presets` instance (not separate files). +2. **Dynamic modules:** Every `patterns/*.py` on flash (except `__init__.py`), imported as `patterns.`. The loader takes the **first class** in the module that defines **`run`**, instantiates it with `Presets(self)` (the driver / NeoPixel wrapper), and registers: + + ```text + patterns[basename] = PatternClass(driver).run + ``` + +So the **`p` field must equal the file basename without `.py`** (e.g. file `radiate.py` ⇒ pattern `"radiate"`). + +### Adding or updating patterns on device + +- **OTA:** v1 message with `"manifest"` (URL or inline JSON listing `files` with `name`, `url` or `code`) — see `apply_patterns_ota()` in `controller_messages.py`. +- **HTTP:** `POST /patterns/upload` on the device (`src/main.py`) with a safe `.py` filename; optional reload of the registry. + +After new files land in `patterns/`, call `presets.reload_patterns()` (done automatically by OTA and upload when configured). + +## Auto vs manual (`a`) + +- **`a: true` (auto):** The main loop keeps calling `tick()`; the generator runs continuously (subject to internal `yield` timing / `utime`). +- **`a: false` (manual):** Intended for patterns that advance **once per explicit `select`** (or per beat routing from the controller). The driver does **not** call `select()` again when editing a manual preset-only push — manual steps are driven by incoming `select` messages. + +Special case in `Presets.select()`: for **manual chase**, if the same preset is re-selected mid-generator, pending frames may be flushed so step indices stay aligned with beats. + +## Built-in patterns + +### `off` + +- **Registration:** built-in method `Presets.off`. +- **Behaviour:** fills the strip with black (after generator setup, `tick` completes immediately). +- **Parameters:** ignores preset colours for the strip; optional `preset` argument unused for pixels. + +### `on` + +- **Registration:** built-in method `Presets.on`. +- **Behaviour:** solid fill with `preset.c[0]` (or white if no colours), via `apply_brightness(..., preset.b)`. +- **Parameters:** `c`, `b`; `d` / `n*` not used. + +## Dynamic pattern: `radiate` + +- **File:** `src/patterns/radiate.py` +- **Class:** `Radiate` — `run(self, preset)` is a **generator** (must `yield` each frame). +- **Pattern key:** `p` = `"radiate"` + +Concept: repeating **nodes** along the strip every **`n1`** LEDs; from each node a lit region expands outward then contracts (timed by **`n2`** / **`n3`**). In **auto**, a new pulse train starts every **`d`** ms and the active colour index advances. In **manual**, a **single** out-and-back cycle runs, then the generator ends (next colour on the next `select`). + +| Field | Role | +|-------|------| +| `n1` | Node spacing in LEDs (`>= 1`; half-spacing used for symmetry) | +| `n2` | Outbound travel time (ms), `>= 1` | +| `n3` | Return travel time (ms), `>= 1` | +| `d` | Auto only: interval (ms) between re-triggers; `>= 1` | +| `c` | Colour list; cycles per retrigger / per manual cycle | +| `bg` | Off state / gap colour (via `preset.background_or`) | +| `b` | Preset brightness | +| `a` | `true` = repeating pulses on a timer; `false` = one shot per select | + +Debug: if `presets.debug` is true (from settings), periodic logs print timing and lit LED counts. + +## Other pattern names (`blink`, `rainbow`, `pulse`, …) + +Those pattern **ids** are valid on the **wire** and in **led-controller** `db/pattern.json`, but they are **not** all present in this repository’s `src/patterns/` tree. On a real device they normally appear as **additional** `patterns/*.py` files delivered by OTA or upload. For the intended **`n1`–`n6`** semantics on the wire, use [API.md](API.md) **Pattern-Specific Parameters**; the implementation must match that contract in each module’s `run(preset)` generator. + +## Quick reference: files + +| File | Role | +|------|------| +| `src/preset.py` | Preset field model and aliases | +| `src/presets.py` | Registry, `select`, `tick`, `off` / `on`, dynamic load | +| `src/controller_messages.py` | Parse v1 JSON, apply presets/select/brightness/OTA | +| `src/patterns/*.py` | One pattern module per dynamic id (basename = `p`) | diff --git a/src/background_tasks.py b/src/background_tasks.py index 1bafbf0..cbb64b1 100644 --- a/src/background_tasks.py +++ b/src/background_tasks.py @@ -1,8 +1,8 @@ import asyncio -import gc import utime from hello import broadcast_hello_udp +from mem_stats import print_mem from wifi_sta import try_reconnect _UDP_HELLO_ATTEMPT = 0 @@ -16,8 +16,7 @@ async def presets_loop(presets, wdt): if bool(getattr(presets, "debug", False)): now = utime.ticks_ms() if utime.ticks_diff(now, last_mem_log) >= 5000: - gc.collect() - print("mem runtime:", {"free": gc.mem_free(), "alloc": gc.mem_alloc()}) + print_mem("runtime") last_mem_log = now # tick() does not await; yield so UDP hello and HTTP/WebSocket can run. await asyncio.sleep(0) diff --git a/src/main.py b/src/main.py index d244453..c934d13 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,6 @@ +import print_timestamp # noqa: F401 — prefixes every print with [ticks_ms] from settings import Settings import machine -import network import utime import asyncio import json @@ -10,8 +10,9 @@ from microdot.websocket import WebSocketError, with_websocket from presets import Presets from controller_messages import apply_startup_pattern, process_data from runtime_state import RuntimeState -from background_tasks import udp_hello_loop_after_http_ready -from wifi_sta import connect_until_up +from background_tasks import presets_loop, udp_hello_loop_after_http_ready +from mem_stats import print_mem +from wifi_sta import boot_sta try: import uos as os except ImportError: @@ -25,9 +26,8 @@ machine.freq(160000000) settings = Settings() - - gc.collect() +sta_if = boot_sta(settings, wdt) presets = Presets(settings["led_pin"], settings["num_leds"]) presets.load(settings) @@ -37,21 +37,6 @@ gc.collect() apply_startup_pattern(settings, presets) -# On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated. -# Reset both interfaces and collect before bringing STA up. -ap_if = network.WLAN(network.AP_IF) -ap_if.active(False) -sta_if = network.WLAN(network.STA_IF) -if sta_if.active(): - sta_if.active(False) -utime.sleep_ms(100) -gc.collect() -sta_if.active(True) -sta_if.config(pm=network.WLAN.PM_NONE) -_boot_ssid = settings.get("ssid") or "" -if _boot_ssid: - connect_until_up(sta_if, _boot_ssid, settings.get("password") or "", wdt) - def _print_network_ips(controller_ip=None): """Always log STA address and led-controller (WS client) address when known.""" @@ -64,6 +49,7 @@ def _print_network_ips(controller_ip=None): _print_network_ips() +print_mem("startup") runtime_state = RuntimeState() @@ -94,6 +80,7 @@ async def ws_handler(request, ws): except Exception: controller_ip = None _print_network_ips(controller_ip) + print_mem("ws connect") try: while True: data = await ws.receive() @@ -167,16 +154,8 @@ async def upload_pattern(request): }), 201, {"Content-Type": "application/json"} -async def presets_loop(): - last_mem_log = utime.ticks_ms() - while True: - presets.tick() - wdt.feed() - await asyncio.sleep(0) - - async def main(port=80): - asyncio.create_task(presets_loop()) + asyncio.create_task(presets_loop(presets, wdt)) asyncio.create_task( udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state) ) diff --git a/src/mem_stats.py b/src/mem_stats.py new file mode 100644 index 0000000..bc4f671 --- /dev/null +++ b/src/mem_stats.py @@ -0,0 +1,34 @@ +"""GC / heap snapshot helpers for debug logging.""" + +import gc + + +def snapshot(): + """Return a dict of memory stats after ``gc.collect()``.""" + gc.collect() + out = { + "free": gc.mem_free(), + "alloc": gc.mem_alloc(), + } + try: + import esp32 + + blocks = esp32.idf_heap_info(esp32.HEAP_DATA) + if blocks: + block = blocks[0] + if isinstance(block, dict): + if "total_free_bytes" in block: + out["idf_free"] = block["total_free_bytes"] + largest = block.get("largest_free_block") + if largest is None: + largest = block.get("largest_free_block_in_bytes") + if largest is not None: + out["idf_largest"] = largest + except Exception: + pass + return out + + +def print_mem(label): + """Print one timestamped memory line (via ``print_timestamp`` when installed).""" + print("mem %s:" % label, snapshot()) diff --git a/src/patterns/aurora.py b/src/patterns/aurora.py index 8dca687..a7aa2ea 100644 --- a/src/patterns/aurora.py +++ b/src/patterns/aurora.py @@ -1,12 +1,16 @@ +import math import utime +from patterns.pattern_modes import style_mode + +_LEGACY = {"northern_wave": 1} + class Aurora: def __init__(self, driver): self.driver = driver - def run(self, preset): - colors = preset.c if preset.c else [(40, 200, 140), (80, 120, 255), (160, 80, 220)] + def _run_bands(self, preset, colors): bands = max(1, int(preset.n1) if int(preset.n1) > 0 else 3) shimmer = max(0, min(255, int(preset.n2) if int(preset.n2) > 0 else 40)) phase = self.driver.step % 256 @@ -16,11 +20,17 @@ class Aurora: now = utime.ticks_ms() if utime.ticks_diff(now, last) >= d: for i in range(self.driver.num_leds): - idx = ((i * bands) // max(1, self.driver.num_leds) + (phase // 32)) % len(colors) + idx = ( + (i * bands) // max(1, self.driver.num_leds) + (phase // 32) + ) % len(colors) c = self.driver.apply_brightness(colors[idx], preset.b) - w = (255 - abs(128 - ((i * 8 + phase) & 255)) * 2) + w = 255 - abs(128 - ((i * 8 + phase) & 255)) * 2 w = max(0, min(255, w + shimmer)) - self.driver.n[i] = ((c[0]*w)//255, (c[1]*w)//255, (c[2]*w)//255) + self.driver.n[i] = ( + (c[0] * w) // 255, + (c[1] * w) // 255, + (c[2] * w) // 255, + ) self.driver.n.write() phase = (phase + 1) & 255 self.driver.step = phase @@ -29,3 +39,57 @@ class Aurora: yield return yield + + def _run_northern(self, preset, colors): + period = max(4, int(preset.n1) if int(preset.n1) > 0 else 20) + contrast = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 200)) + drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 2) + phase = 0 + last = utime.ticks_ms() + ncols = len(colors) + if ncols < 2: + colors = list(colors) + [(120, 180, 255)] + ncols = len(colors) + twopi = 6.2831853 + + def lerp3(a, b, f): + return ( + a[0] + ((b[0] - a[0]) * f) // 255, + a[1] + ((b[1] - a[1]) * f) // 255, + a[2] + ((b[2] - a[2]) * f) // 255, + ) + + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + t = (i * twopi / period) + (phase * twopi / 256.0) + w = (math.sin(t) + 1.0) * 0.5 + u = w * (ncols - 1) * 256.0 + fi = int(u) >> 8 + frac = int(u) & 255 + if fi >= ncols - 1: + fi = ncols - 2 + frac = 255 + 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.write() + phase = (phase + drift) % 256 + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield + + def run(self, preset): + """Aurora bands (n6=0) or sine northern wave (n6=1, legacy northern_wave).""" + colors = preset.c if preset.c else [(40, 200, 140), (80, 120, 255), (160, 80, 220)] + if style_mode(preset, 0, _LEGACY) == 1: + colors = preset.c if preset.c else [(20, 55, 120), (60, 140, 220), (180, 220, 255)] + yield from self._run_northern(preset, colors) + return + yield from self._run_bands(preset, colors) diff --git a/src/patterns/breathing_dual.py b/src/patterns/breathing_dual.py deleted file mode 100644 index a5a5af5..0000000 --- a/src/patterns/breathing_dual.py +++ /dev/null @@ -1,40 +0,0 @@ -import utime - - -class BreathingDual: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 0, 140), (0, 120, 255)] - phase_offset = max(0, min(255, int(preset.n1))) - ease = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) - phase = self.driver.step % 256 - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - p1 = phase - p2 = (phase + phase_offset) & 255 - t1 = 255 - abs(128 - p1) * 2 - t2 = 255 - abs(128 - p2) * 2 - if ease > 1: - t1 = (t1 * t1) // 255 - t2 = (t2 * t2) // 255 - c1 = self.driver.apply_brightness(colors[0], preset.b) - c2 = self.driver.apply_brightness(colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b) - half = self.driver.num_leds // 2 - for i in range(self.driver.num_leds): - if i < half: - self.driver.n[i] = ((c1[0]*t1)//255, (c1[1]*t1)//255, (c1[2]*t1)//255) - else: - self.driver.n[i] = ((c2[0]*t2)//255, (c2[1]*t2)//255, (c2[2]*t2)//255) - self.driver.n.write() - phase = (phase + 2) & 255 - self.driver.step = phase - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/chase.py b/src/patterns/chase.py index 7b07946..9a01bfb 100644 --- a/src/patterns/chase.py +++ b/src/patterns/chase.py @@ -1,13 +1,49 @@ import utime +from patterns.pattern_modes import style_mode + +_LEGACY = {"marquee": 1} + class Chase: def __init__(self, driver): self.driver = driver + 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) + phase = self.driver.step % (on_len + off_len) + last = utime.ticks_ms() + while True: + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) >= d: + c = self.driver.apply_brightness(colors[0], preset.b) + 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.write() + phase = (phase + step) % (on_len + off_len) + self.driver.step = phase + last = utime.ticks_add(last, d) + if not preset.a: + yield + return + yield + def run(self, preset): - """Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating. - Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)""" + """Chase (n6=0) or marquee dashes (n6=1, legacy marquee). + + Chase: n1/n2 segment lengths, n3/n4 step on even/odd beats. + Marquee: n1 on length, n2 off length, n3 scroll step. + """ + if style_mode(preset, 0, _LEGACY) == 1: + colors = preset.c if preset.c else [(255, 255, 255)] + yield from self._run_marquee(preset, colors) + return + colors = preset.c if len(colors) < 1: # Need at least 1 color diff --git a/src/patterns/colour_cycle.py b/src/patterns/colour_cycle.py index 8ede6d8..105acb2 100644 --- a/src/patterns/colour_cycle.py +++ b/src/patterns/colour_cycle.py @@ -1,11 +1,24 @@ import utime +from patterns.pattern_modes import style_mode + +_LEGACY = {"rainbow": 1, "gradient_scroll": 0} + class ColourCycle: def __init__(self, driver): self.driver = driver - def _render(self, colors, phase, brightness): + def _wheel(self, pos): + if pos < 85: + return (pos * 3, 255 - pos * 3, 0) + if pos < 170: + pos -= 85 + return (255 - pos * 3, 0, pos * 3) + pos -= 170 + return (0, pos * 3, 255 - pos * 3) + + def _render_gradient(self, colors, phase, brightness): num_leds = self.driver.num_leds color_count = len(colors) if num_leds <= 0 or color_count <= 0: @@ -15,14 +28,11 @@ class ColourCycle: return full_span = color_count * 256 - # Match rainbow behaviour: phase is 0..255 and maps to one full-strip shift. phase_shift = (phase * full_span) // 256 for i in range(num_leds): - # Position around the colour loop, shifted by phase. pos = ((i * full_span) // num_leds + phase_shift) % full_span idx = pos // 256 frac = pos & 255 - c1 = colors[idx] c2 = colors[(idx + 1) % color_count] blended = ( @@ -33,23 +43,55 @@ class ColourCycle: self.driver.n[i] = self.driver.apply_brightness(blended, brightness) self.driver.n.write() - def run(self, preset): - colors = preset.c if preset.c else [(255, 255, 255)] - phase = self.driver.step % 256 - step_amount = max(1, int(preset.n1)) + def _render_rainbow(self, 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._wheel(rc_index & 255), brightness + ) + self.driver.n.write() + def run(self, preset): + """Scroll gradient (n6=0) or fixed spectrum wheel (n6=1, legacy rainbow). + + n1: step rate + n6: 0 gradient scroll, 1 rainbow wheel + """ + mode = style_mode(preset, 0, _LEGACY) + step_amount = max(1, int(preset.n1) if int(preset.n1) > 0 else 1) + phase = self.driver.step % 256 + + if mode == 1: + if not preset.a: + self._render_rainbow(phase, preset.b) + self.driver.step = (phase + step_amount) % 256 + yield + return + last_update = utime.ticks_ms() + while True: + 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.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(colors, phase, preset.b) + self._render_gradient(colors, phase, preset.b) self.driver.step = (phase + step_amount) % 256 yield return last_update = utime.ticks_ms() while True: - current_time = utime.ticks_ms() delay_ms = max(1, int(preset.d)) - if utime.ticks_diff(current_time, last_update) >= delay_ms: - self._render(colors, phase, preset.b) + 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.driver.step = phase last_update = utime.ticks_add(last_update, delay_ms) diff --git a/src/patterns/comet_dual.py b/src/patterns/comet_dual.py deleted file mode 100644 index 280ad41..0000000 --- a/src/patterns/comet_dual.py +++ /dev/null @@ -1,44 +0,0 @@ -import utime - - -class CometDual: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 255, 255)] - tail = max(1, int(preset.n1) if int(preset.n1) > 0 else 6) - speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) - gap = max(0, int(preset.n3)) - p1 = 0 - p2 = self.driver.num_leds - 1 - gap - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - self.driver.n[i] = bg_color - c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b) - c2 = self.driver.apply_brightness(colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b) - for t in range(tail): - 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) - 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.write() - p1 += speed - p2 -= speed - if p1 - tail > self.driver.num_leds and p2 + tail < 0: - p1 = 0 - p2 = self.driver.num_leds - 1 - gap - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/fireflies.py b/src/patterns/fireflies.py deleted file mode 100644 index 09f56a2..0000000 --- a/src/patterns/fireflies.py +++ /dev/null @@ -1,35 +0,0 @@ -import random -import utime - - -class Fireflies: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 210, 80), (120, 255, 120)] - count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6) - speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 8) - bugs = [[random.randint(0, max(0, self.driver.num_leds - 1)), random.randint(0, 255)] for _ in range(count)] - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - self.driver.n[i] = bg_color - for b in bugs: - idx, ph = b - tri = 255 - abs(128 - ph) * 2 - c = self.driver.apply_brightness(colors[idx % len(colors)], preset.b) - self.driver.n[idx] = ((c[0]*tri)//255, (c[1]*tri)//255, (c[2]*tri)//255) - b[1] = (ph + speed) & 255 - if random.randint(0, 31) == 0: - b[0] = random.randint(0, max(0, self.driver.num_leds - 1)) - self.driver.n.write() - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/gradient_scroll.py b/src/patterns/gradient_scroll.py deleted file mode 100644 index dfc8215..0000000 --- a/src/patterns/gradient_scroll.py +++ /dev/null @@ -1,57 +0,0 @@ -import utime - - -class GradientScroll: - def __init__(self, driver): - self.driver = driver - - def _render(self, colors, phase, brightness): - num_leds = self.driver.num_leds - color_count = len(colors) - if num_leds <= 0 or color_count <= 0: - return - if color_count == 1: - self.driver.fill(self.driver.apply_brightness(colors[0], brightness)) - return - - full_span = color_count * 256 - phase_shift = (phase * full_span) // 256 - for i in range(num_leds): - pos = ((i * full_span) // num_leds + phase_shift) % full_span - idx = pos // 256 - frac = pos & 255 - - c1 = colors[idx] - c2 = colors[(idx + 1) % color_count] - blended = ( - c1[0] + ((c2[0] - c1[0]) * frac) // 256, - 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.write() - - def run(self, preset): - """Scrolling blended gradient. - - n1: phase step amount (default 1) - """ - colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)] - phase = self.driver.step % 256 - step_amount = max(1, int(preset.n1) if int(preset.n1) > 0 else 1) - last_update = utime.ticks_ms() - - while True: - delay_ms = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last_update) >= delay_ms: - self._render(colors, phase, preset.b) - phase = (phase + step_amount) % 256 - self.driver.step = phase - last_update = utime.ticks_add(last_update, delay_ms) - - if not preset.a: - yield - return - - yield diff --git a/src/patterns/heartbeat.py b/src/patterns/heartbeat.py deleted file mode 100644 index b4104f5..0000000 --- a/src/patterns/heartbeat.py +++ /dev/null @@ -1,36 +0,0 @@ -import utime - - -class Heartbeat: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 0, 40)] - phase = 0 - phase_start = utime.ticks_ms() - did_manual_pulse = False - while True: - p1 = max(20, int(preset.n1) if int(preset.n1) > 0 else 120) - p2 = max(20, int(preset.n2) if int(preset.n2) > 0 else 80) - pause = max(20, int(preset.n3) if int(preset.n3) > 0 else 500) - beat_gap = max(20, int(preset.d)) - colors = preset.c if preset.c else [(255, 0, 40)] - lit_color = self.driver.apply_brightness(colors[0], preset.b) - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - phase_durations = (p1, beat_gap, p2, pause) - phase_colors = (lit_color, bg_color, lit_color, bg_color) - - now = utime.ticks_ms() - while utime.ticks_diff(now, phase_start) >= phase_durations[phase]: - phase_start = utime.ticks_add(phase_start, phase_durations[phase]) - phase = (phase + 1) % 4 - - self.driver.fill(phase_colors[phase]) - yield - if not preset.a: - if did_manual_pulse or phase == 0: - self.driver.fill(bg_color) - yield - return - did_manual_pulse = True diff --git a/src/patterns/ice_sparkle.py b/src/patterns/ice_sparkle.py deleted file mode 100644 index 5e3735b..0000000 --- a/src/patterns/ice_sparkle.py +++ /dev/null @@ -1,69 +0,0 @@ -import random -import utime - - -class IceSparkle: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(240, 248, 255), (200, 235, 255), (255, 255, 255)] - rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 55)) - decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 140)) - halo = max(0, min(3, int(preset.n3))) - sparks = [] - cap = 28 - last = utime.ticks_ms() - - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - self.driver.n[i] = bg - ns = [] - for s in sparks: - lv = s["lv"] - decay - if lv > 0: - s["lv"] = lv - ns.append(s) - sparks = ns - if len(sparks) < cap and random.randint(0, 255) < rate: - sparks.append( - { - "p": random.randint(0, max(0, self.driver.num_leds - 1)), - "lv": 255, - "ci": random.randint(0, len(colors) - 1), - } - ) - for s in sparks: - p = s["p"] - lv = s["lv"] - ci = s["ci"] - base = colors[ci] - for off in range(-halo, halo + 1): - idx = p + off - if 0 <= idx < self.driver.num_leds: - dist = abs(off) - fac = lv if dist == 0 else (lv * (halo - dist + 1)) // (halo + 1) - lit = self.driver.apply_brightness( - ( - (base[0] * fac) // 255, - (base[1] * fac) // 255, - (base[2] * fac) // 255, - ), - preset.b, - ) - o = self.driver.n[idx] - self.driver.n[idx] = ( - min(255, o[0] + lit[0]), - min(255, o[1] + lit[1]), - min(255, o[2] + lit[2]), - ) - self.driver.n.write() - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/marquee.py b/src/patterns/marquee.py deleted file mode 100644 index fbcb85a..0000000 --- a/src/patterns/marquee.py +++ /dev/null @@ -1,31 +0,0 @@ -import utime - - -class Marquee: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 255, 255)] - 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) - phase = self.driver.step % (on_len + off_len) - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - c = self.driver.apply_brightness(colors[0], preset.b) - 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.write() - phase = (phase + step) % (on_len + off_len) - self.driver.step = phase - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/meteor.py b/src/patterns/meteor.py new file mode 100644 index 0000000..6f88e28 --- /dev/null +++ b/src/patterns/meteor.py @@ -0,0 +1,156 @@ +import utime + +from patterns.pattern_modes import style_mode + +_LEGACY = {"comet_dual": 1, "scanner": 2} + + +class Meteor: + def __init__(self, driver): + self.driver = driver + + def _fade(self, color, fade_amount): + return ( + (color[0] * fade_amount) // 255, + (color[1] * fade_amount) // 255, + (color[2] * fade_amount) // 255, + ) + + def _run_meteor(self, preset, colors, color_index, head, direction, last_update): + tail_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 8) + speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) + fade_amount = int(preset.n3) if int(preset.n3) > 0 else 192 + fade_amount = max(1, min(255, fade_amount)) + delay_ms = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last_update) < delay_ms: + return color_index, head, direction, last_update, False + for i in range(self.driver.num_leds): + self.driver.n[i] = self._fade(self.driver.n[i], fade_amount) + 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.write() + head += direction * speed + if head >= self.driver.num_leds + tail_len: + head = self.driver.num_leds - 1 + direction = -1 + color_index += 1 + elif head < -tail_len: + head = 0 + direction = 1 + color_index += 1 + return color_index, head, direction, utime.ticks_add(last_update, delay_ms), True + + def _run_comet_dual(self, preset, colors, p1, p2, last): + tail = max(1, int(preset.n1) if int(preset.n1) > 0 else 6) + speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) + gap = max(0, int(preset.n3)) + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return p1, p2, last, False + bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg_color + c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b) + c2 = self.driver.apply_brightness( + colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b + ) + for t in range(tail): + 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) + 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.write() + p1 += speed + p2 -= speed + if p1 - tail > self.driver.num_leds and p2 + tail < 0: + p1 = 0 + p2 = self.driver.num_leds - 1 - gap + return p1, p2, utime.ticks_add(last, d), True + + def _run_scanner(self, preset, colors, color_index, center, direction, pause_frames, last_update): + width = max(1, int(preset.n1) if int(preset.n1) > 0 else 4) + end_pause = max(0, int(preset.n2)) + delay_ms = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last_update) < delay_ms: + return color_index, center, direction, pause_frames, last_update, False + base = self.driver.apply_brightness(colors[color_index % len(colors)], preset.b) + bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + dist = i - center + if dist < 0: + dist = -dist + if dist > width: + self.driver.n[i] = bg_color + else: + scale = ((width - dist) * 255) // max(1, width) + self.driver.n[i] = ( + (base[0] * scale) // 255, + (base[1] * scale) // 255, + (base[2] * scale) // 255, + ) + self.driver.n.write() + if pause_frames > 0: + pause_frames -= 1 + else: + center += direction + if center >= self.driver.num_leds - 1: + center = self.driver.num_leds - 1 + direction = -1 + pause_frames = end_pause + color_index += 1 + elif center <= 0: + center = 0 + direction = 1 + pause_frames = end_pause + color_index += 1 + return color_index, center, direction, pause_frames, utime.ticks_add(last_update, delay_ms), True + + def run(self, preset): + """Moving lights: n6 style 0 meteor, 1 dual comet, 2 scanner (legacy ids still work).""" + mode = style_mode(preset, 0, _LEGACY) + colors = preset.c if preset.c else [(255, 255, 255)] + + if mode == 1: + gap = max(0, int(preset.n3)) + p1, p2 = 0, self.driver.num_leds - 1 - gap + last = utime.ticks_ms() + while True: + p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last) + if stepped and not preset.a: + yield + return + yield + + if mode == 2: + 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 = ( + self._run_scanner( + preset, colors, color_index, center, direction, pause_frames, last_update + ) + ) + if stepped and not preset.a: + yield + return + yield + + color_index, head, direction = 0, 0, 1 + last_update = utime.ticks_ms() + while True: + color_index, head, direction, last_update, stepped = self._run_meteor( + preset, colors, color_index, head, direction, last_update + ) + if stepped and not preset.a: + yield + return + yield diff --git a/src/patterns/meteor_rain.py b/src/patterns/meteor_rain.py deleted file mode 100644 index d69e9a9..0000000 --- a/src/patterns/meteor_rain.py +++ /dev/null @@ -1,62 +0,0 @@ -import utime - - -class MeteorRain: - def __init__(self, driver): - self.driver = driver - - def _fade(self, color, fade_amount): - return ( - (color[0] * fade_amount) // 255, - (color[1] * fade_amount) // 255, - (color[2] * fade_amount) // 255, - ) - - def run(self, preset): - """Single meteor with a fading tail. - - n1: tail length (default 8) - n2: speed in LEDs per frame (default 1) - n3: fade amount per frame, 1..255 (default 192) - """ - colors = preset.c if preset.c else [(255, 255, 255)] - color_index = 0 - head = 0 - direction = 1 - last_update = utime.ticks_ms() - - while True: - delay_ms = max(1, int(preset.d)) - tail_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 8) - speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) - fade_amount = int(preset.n3) if int(preset.n3) > 0 else 192 - fade_amount = max(1, min(255, fade_amount)) - - now = utime.ticks_ms() - if utime.ticks_diff(now, last_update) >= delay_ms: - for i in range(self.driver.num_leds): - self.driver.n[i] = self._fade(self.driver.n[i], fade_amount) - - 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.write() - - head += direction * speed - if head >= self.driver.num_leds + tail_len: - head = self.driver.num_leds - 1 - direction = -1 - color_index += 1 - elif head < -tail_len: - head = 0 - direction = 1 - color_index += 1 - - last_update = utime.ticks_add(last_update, delay_ms) - - if not preset.a: - yield - return - - yield diff --git a/src/patterns/northern_wave.py b/src/patterns/northern_wave.py deleted file mode 100644 index 11de78f..0000000 --- a/src/patterns/northern_wave.py +++ /dev/null @@ -1,53 +0,0 @@ -import math -import utime - - -class NorthernWave: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(20, 55, 120), (60, 140, 220), (180, 220, 255)] - period = max(4, int(preset.n1) if int(preset.n1) > 0 else 20) - contrast = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 200)) - drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 2) - phase = 0 - last = utime.ticks_ms() - ncols = len(colors) - if ncols < 2: - colors = list(colors) + [(120, 180, 255)] - ncols = len(colors) - twopi = 6.2831853 - - def lerp3(a, b, f): - return ( - a[0] + ((b[0] - a[0]) * f) // 255, - a[1] + ((b[1] - a[1]) * f) // 255, - a[2] + ((b[2] - a[2]) * f) // 255, - ) - - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - t = (i * twopi / period) + (phase * twopi / 256.0) - w = (math.sin(t) + 1.0) * 0.5 - u = w * (ncols - 1) * 256.0 - fi = int(u) >> 8 - frac = int(u) & 255 - if fi >= ncols - 1: - fi = ncols - 2 - frac = 255 - 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.write() - phase = (phase + drift) % 256 - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/particles.py b/src/patterns/particles.py new file mode 100644 index 0000000..e84fb5f --- /dev/null +++ b/src/patterns/particles.py @@ -0,0 +1,108 @@ +import random +import utime + +from patterns.pattern_modes import style_mode + +_LEGACY = {"starfall": 1} + + +class Particles: + def __init__(self, driver): + self.driver = driver + + 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) + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return flakes, last, False + bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) + if random.randint(0, 255) < density: + flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors) - 1)]) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg_color + 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 + if pos >= -1: + nf.append([pos, ci]) + self.driver.n.write() + return nf, utime.ticks_add(last, d), True + + 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) + tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10) + max_stars = 4 + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return stars, last, False + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg + if len(stars) < max_stars and random.randint(0, 255) < rate: + top = self.driver.num_leds - 1 + random.randint( + 0, min(8, self.driver.num_leds // 2) + ) + stars.append({"h": float(top), "ci": random.randint(0, len(colors) - 1)}) + ns = [] + for s in stars: + h = s["h"] + ci = s["ci"] + ih = int(h) + for t in range(tail): + idx = ih + t + if 0 <= idx < self.driver.num_leds: + fade = 255 - (t * 255 // max(1, tail - 1)) + base = colors[ci] + lit = ( + (base[0] * fade) // 255, + (base[1] * fade) // 255, + (base[2] * fade) // 255, + ) + lit = self.driver.apply_brightness(lit, preset.b) + o = self.driver.n[idx] + self.driver.n[idx] = ( + max(o[0], lit[0]), + max(o[1], lit[1]), + max(o[2], lit[2]), + ) + h -= speed + if h >= -tail: + s["h"] = h + ns.append(s) + stars = ns + self.driver.n.write() + return stars, utime.ticks_add(last, d), True + + def run(self, preset): + """Falling particles: n6 0 snowfall flakes, 1 starfall streaks.""" + mode = style_mode(preset, 0, _LEGACY) + colors = preset.c if preset.c else [(255, 255, 255), (180, 220, 255)] + last = utime.ticks_ms() + + if mode == 1: + colors = preset.c if preset.c else [ + (255, 255, 255), + (200, 230, 255), + (255, 248, 220), + ] + stars = [] + while True: + stars, last, stepped = self._run_starfall(preset, colors, stars, last) + if stepped and not preset.a: + yield + return + yield + + flakes = [] + while True: + flakes, last, stepped = self._run_snowfall(preset, colors, flakes, last) + if stepped and not preset.a: + yield + return + yield diff --git a/src/patterns/pattern_modes.py b/src/patterns/pattern_modes.py new file mode 100644 index 0000000..2e4fef4 --- /dev/null +++ b/src/patterns/pattern_modes.py @@ -0,0 +1,18 @@ +"""Resolve pattern style from n6 or legacy preset pattern id (p).""" + + +def style_mode(preset, default=0, legacy=None): + legacy = legacy or {} + p = getattr(preset, "p", "") or "" + if p in legacy: + return legacy[p] + mode = getattr(preset, "mode", None) + if mode is None and isinstance(preset, dict): + mode = preset.get("mode") + if mode is not None: + try: + return int(mode) + except (TypeError, ValueError): + pass + n6 = int(getattr(preset, "n6", 0) or 0) + return n6 if n6 > 0 else default diff --git a/src/patterns/radiate.py b/src/patterns/radiate.py index ce682cd..1a2584a 100644 --- a/src/patterns/radiate.py +++ b/src/patterns/radiate.py @@ -1,7 +1,7 @@ import utime # When ``driver.debug`` is True (``settings["debug"]``), log at most this often (ms). -_RADIATE_DBG_INTERVAL_MS = 800 +_RADIATE_DBG_INTERVAL_MS = 2500 class Radiate: @@ -37,6 +37,7 @@ class Radiate: if not preset.a: # Manual mode: one-shot pulse using the same ms-based timing as auto. cycle_start = utime.ticks_ms() + last_dbg = cycle_start while True: dbg = bool(getattr(self.driver, "debug", False)) spacing = max(1, int(preset.n1)) @@ -78,10 +79,12 @@ class Radiate: "[radiate] debug on n1=%s n2=%s n3=%s d=%s auto=%s num_leds=%d" % (preset.n1, preset.n2, preset.n3, preset.d, preset.a, self.driver.num_leds) ) - print( - "[radiate] manual frame age=%d/%d front=%d lit=%d" - % (age, pulse_lifetime, front, lit_count) - ) + if utime.ticks_diff(now, last_dbg) >= _RADIATE_DBG_INTERVAL_MS: + print( + "[radiate] manual frame age=%d/%d front=%d lit=%d" + % (age, pulse_lifetime, front, lit_count) + ) + last_dbg = now yield if age >= pulse_lifetime: diff --git a/src/patterns/rainbow.py b/src/patterns/rainbow.py deleted file mode 100644 index a49670e..0000000 --- a/src/patterns/rainbow.py +++ /dev/null @@ -1,51 +0,0 @@ -import utime - - -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) - - def run(self, preset): - step = self.driver.step % 256 - step_amount = max(1, int(preset.n1)) # n1 controls step increment - - # If auto is False, run a single step and 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 - yield - return - - last_update = utime.ticks_ms() - - 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, - ) - self.driver.n.write() - step = (step + step_amount) % 256 - self.driver.step = step - last_update = utime.ticks_add(last_update, sleep_ms) - # Yield once per tick so other logic can run - yield diff --git a/src/patterns/scanner.py b/src/patterns/scanner.py deleted file mode 100644 index a619384..0000000 --- a/src/patterns/scanner.py +++ /dev/null @@ -1,67 +0,0 @@ -import utime - - -class Scanner: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Classic scanner eye with soft falloff. - - n1: eye width (default 4) - n2: end pause in frames (default 0) - """ - colors = preset.c if preset.c else [(255, 0, 0)] - color_index = 0 - center = 0 - direction = 1 - pause_frames = 0 - last_update = utime.ticks_ms() - - while True: - delay_ms = max(1, int(preset.d)) - width = max(1, int(preset.n1) if int(preset.n1) > 0 else 4) - end_pause = max(0, int(preset.n2)) - - now = utime.ticks_ms() - if utime.ticks_diff(now, last_update) >= delay_ms: - base = colors[color_index % len(colors)] - base = self.driver.apply_brightness(base, preset.b) - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - dist = i - center - if dist < 0: - dist = -dist - if dist > width: - self.driver.n[i] = bg_color - else: - scale = ((width - dist) * 255) // max(1, width) - self.driver.n[i] = ( - (base[0] * scale) // 255, - (base[1] * scale) // 255, - (base[2] * scale) // 255, - ) - self.driver.n.write() - - if pause_frames > 0: - pause_frames -= 1 - else: - center += direction - if center >= self.driver.num_leds - 1: - center = self.driver.num_leds - 1 - direction = -1 - pause_frames = end_pause - color_index += 1 - elif center <= 0: - center = 0 - direction = 1 - pause_frames = end_pause - color_index += 1 - - last_update = utime.ticks_add(last_update, delay_ms) - - if not preset.a: - yield - return - - yield diff --git a/src/patterns/segment_chase.py b/src/patterns/segment_chase.py deleted file mode 100644 index 7a048b9..0000000 --- a/src/patterns/segment_chase.py +++ /dev/null @@ -1,45 +0,0 @@ -import utime - - -class SegmentChase: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Independent moving segments (distinct from classic two-color chase). - - n1: segment size (LEDs per segment) - n2: step size (phase increment each frame) - n3: per-segment phase offset - n4: gap spacing inside segment (0 = solid segment) - """ - colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)] - seg = max(1, int(preset.n1) if int(preset.n1) > 0 else 4) - phase_step = max(1, int(preset.n2) if int(preset.n2) > 0 else 1) - seg_offset = max(0, int(preset.n3)) - gap = max(0, int(preset.n4)) - phase = self.driver.step % 256 - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - seg_idx = i // seg - in_seg = i % seg - local_phase = (phase + seg_idx * seg_offset) % seg - lit_idx = (in_seg + local_phase) % seg - if gap > 0 and lit_idx >= max(1, seg - gap): - self.driver.n[i] = bg_color - else: - color_idx = seg_idx % len(colors) - self.driver.n[i] = self.driver.apply_brightness(colors[color_idx], preset.b) - self.driver.n.write() - phase = (phase + phase_step) % seg - self.driver.step = phase - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/snowfall.py b/src/patterns/snowfall.py deleted file mode 100644 index cbccd67..0000000 --- a/src/patterns/snowfall.py +++ /dev/null @@ -1,37 +0,0 @@ -import random -import utime - - -class Snowfall: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 255, 255), (180, 220, 255)] - 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) - flakes = [] - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) - if random.randint(0, 255) < density: - flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors)-1)]) - for i in range(self.driver.num_leds): - self.driver.n[i] = bg_color - 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 - if pos >= -1: - nf.append([pos, ci]) - flakes = nf - self.driver.n.write() - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/sparkle.py b/src/patterns/sparkle.py new file mode 100644 index 0000000..16083a7 --- /dev/null +++ b/src/patterns/sparkle.py @@ -0,0 +1,147 @@ +import random +import utime + +from patterns.pattern_modes import style_mode + +_LEGACY = {"sparkle_trail": 0, "ice_sparkle": 1, "fireflies": 2} + + +class Sparkle: + def __init__(self, driver): + self.driver = driver + + def _run_trail(self, preset, colors, last): + density = max(1, int(preset.n1) if int(preset.n1) > 0 else 24) + decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 210)) + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return last, False + for i in range(self.driver.num_leds): + r, g, b = self.driver.n[i] + self.driver.n[i] = ((r * decay) // 255, (g * decay) // 255, (b * decay) // 255) + sparks = max(1, self.driver.num_leds * density // 255) + for _ in range(sparks): + idx = random.randint(0, max(0, self.driver.num_leds - 1)) + c = self.driver.apply_brightness( + colors[random.randint(0, len(colors) - 1)], preset.b + ) + self.driver.n[idx] = c + self.driver.n.write() + return utime.ticks_add(last, d), True + + def _run_ice(self, preset, colors, sparks, last): + rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 55)) + decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 140)) + halo = max(0, min(3, int(preset.n3))) + cap = 28 + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return sparks, last, False + bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg + ns = [] + for s in sparks: + lv = s["lv"] - decay + if lv > 0: + s["lv"] = lv + ns.append(s) + sparks = ns + if len(sparks) < cap and random.randint(0, 255) < rate: + sparks.append( + { + "p": random.randint(0, max(0, self.driver.num_leds - 1)), + "lv": 255, + "ci": random.randint(0, len(colors) - 1), + } + ) + for s in sparks: + p = s["p"] + lv = s["lv"] + ci = s["ci"] + base = colors[ci] + for off in range(-halo, halo + 1): + idx = p + off + if 0 <= idx < self.driver.num_leds: + dist = abs(off) + fac = lv if dist == 0 else (lv * (halo - dist + 1)) // (halo + 1) + lit = self.driver.apply_brightness( + ( + (base[0] * fac) // 255, + (base[1] * fac) // 255, + (base[2] * fac) // 255, + ), + preset.b, + ) + o = self.driver.n[idx] + self.driver.n[idx] = ( + min(255, o[0] + lit[0]), + min(255, o[1] + lit[1]), + min(255, o[2] + lit[2]), + ) + self.driver.n.write() + return sparks, utime.ticks_add(last, d), True + + def _run_fireflies(self, preset, colors, bugs, last): + count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6) + speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 8) + d = max(1, int(preset.d)) + now = utime.ticks_ms() + if utime.ticks_diff(now, last) < d: + return bugs, last, False + bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b) + for i in range(self.driver.num_leds): + self.driver.n[i] = bg_color + for b in bugs: + idx, ph = b + tri = 255 - abs(128 - ph) * 2 + c = self.driver.apply_brightness(colors[idx % len(colors)], preset.b) + self.driver.n[idx] = ((c[0] * tri) // 255, (c[1] * tri) // 255, (c[2] * tri) // 255) + b[1] = (ph + speed) & 255 + if random.randint(0, 31) == 0: + b[0] = random.randint(0, max(0, self.driver.num_leds - 1)) + self.driver.n.write() + return bugs, utime.ticks_add(last, d), True + + def run(self, preset): + """Sparkles: n6 0 trail decay, 1 ice burst+halo, 2 fireflies.""" + mode = style_mode(preset, 0, _LEGACY) + colors = preset.c if preset.c else [(120, 120, 255)] + last = utime.ticks_ms() + + if mode == 2: + colors = preset.c if preset.c else [(255, 210, 80), (120, 255, 120)] + count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6) + bugs = [ + [random.randint(0, max(0, self.driver.num_leds - 1)), random.randint(0, 255)] + for _ in range(count) + ] + while True: + bugs, last, stepped = self._run_fireflies(preset, colors, bugs, last) + if stepped and not preset.a: + yield + return + yield + + if mode == 1: + colors = preset.c if preset.c else [ + (240, 248, 255), + (200, 235, 255), + (255, 255, 255), + ] + sparks = [] + while True: + sparks, last, stepped = self._run_ice(preset, colors, sparks, last) + if stepped and not preset.a: + yield + return + yield + + while True: + last, stepped = self._run_trail(preset, colors, last) + if stepped and not preset.a: + yield + return + yield diff --git a/src/patterns/sparkle_trail.py b/src/patterns/sparkle_trail.py deleted file mode 100644 index 13b3ee2..0000000 --- a/src/patterns/sparkle_trail.py +++ /dev/null @@ -1,31 +0,0 @@ -import random -import utime - - -class SparkleTrail: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(120, 120, 255)] - density = max(1, int(preset.n1) if int(preset.n1) > 0 else 24) - decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 210)) - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - for i in range(self.driver.num_leds): - r,g,b = self.driver.n[i] - self.driver.n[i] = ((r*decay)//255, (g*decay)//255, (b*decay)//255) - sparks = max(1, self.driver.num_leds * density // 255) - for _ in range(sparks): - idx = random.randint(0, max(0, self.driver.num_leds - 1)) - c = self.driver.apply_brightness(colors[random.randint(0, len(colors)-1)], preset.b) - self.driver.n[idx] = c - self.driver.n.write() - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/starfall.py b/src/patterns/starfall.py deleted file mode 100644 index 28a41f5..0000000 --- a/src/patterns/starfall.py +++ /dev/null @@ -1,65 +0,0 @@ -import random -import utime - - -class Starfall: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(255, 255, 255), (200, 230, 255), (255, 248, 220)] - 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) - tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10) - stars = [] - max_stars = 4 - last = utime.ticks_ms() - - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - bg = self.driver.apply_brightness(preset.background_or(colors), preset.b) - for i in range(self.driver.num_leds): - self.driver.n[i] = bg - if len(stars) < max_stars and random.randint(0, 255) < rate: - top = self.driver.num_leds - 1 + random.randint(0, min(8, self.driver.num_leds // 2)) - stars.append( - { - "h": float(top), - "ci": random.randint(0, len(colors) - 1), - } - ) - ns = [] - for s in stars: - h = s["h"] - ci = s["ci"] - ih = int(h) - for t in range(tail): - idx = ih + t - if 0 <= idx < self.driver.num_leds: - fade = 255 - (t * 255 // max(1, tail - 1)) - base = colors[ci] - lit = ( - (base[0] * fade) // 255, - (base[1] * fade) // 255, - (base[2] * fade) // 255, - ) - lit = self.driver.apply_brightness(lit, preset.b) - o = self.driver.n[idx] - self.driver.n[idx] = ( - max(o[0], lit[0]), - max(o[1], lit[1]), - max(o[2], lit[2]), - ) - h -= speed - if h >= -tail: - s["h"] = h - ns.append(s) - stars = ns - self.driver.n.write() - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/patterns/wave.py b/src/patterns/wave.py deleted file mode 100644 index fd9292f..0000000 --- a/src/patterns/wave.py +++ /dev/null @@ -1,32 +0,0 @@ -import utime - - -class Wave: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - colors = preset.c if preset.c else [(0, 180, 255)] - wavelength = max(2, int(preset.n1) if int(preset.n1) > 0 else 12) - amp = max(0, min(255, int(preset.n2) if int(preset.n2) > 0 else 180)) - drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 1) - phase = self.driver.step % 256 - last = utime.ticks_ms() - while True: - d = max(1, int(preset.d)) - now = utime.ticks_ms() - if utime.ticks_diff(now, last) >= d: - base = self.driver.apply_brightness(colors[0], preset.b) - for i in range(self.driver.num_leds): - x = (i * 256 // wavelength + phase) & 255 - tri = 255 - abs(128 - x) * 2 - s = (tri * amp) // 255 - self.driver.n[i] = ((base[0]*s)//255, (base[1]*s)//255, (base[2]*s)//255) - self.driver.n.write() - phase = (phase + drift) % 256 - self.driver.step = phase - last = utime.ticks_add(last, d) - if not preset.a: - yield - return - yield diff --git a/src/preset.py b/src/preset.py index 50aa8d3..1b990dc 100644 --- a/src/preset.py +++ b/src/preset.py @@ -27,6 +27,7 @@ class Preset: "brightness": "b", "auto": "a", "background": "bg", + "mode": "n6", } int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"} allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"} diff --git a/src/presets.py b/src/presets.py index b2f06ef..7a946e5 100644 --- a/src/presets.py +++ b/src/presets.py @@ -70,8 +70,29 @@ class Presets: except Exception as e: print("Pattern init failed:", module_name, e) + self._apply_pattern_aliases(loaded) return loaded + def _apply_pattern_aliases(self, loaded): + """Legacy pattern ids -> merged implementations (same generator).""" + aliases = ( + ("rainbow", "colour_cycle"), + ("gradient_scroll", "colour_cycle"), + ("meteor_rain", "meteor"), + ("comet_dual", "meteor"), + ("scanner", "meteor"), + ("snowfall", "particles"), + ("starfall", "particles"), + ("sparkle_trail", "sparkle"), + ("ice_sparkle", "sparkle"), + ("fireflies", "sparkle"), + ("marquee", "chase"), + ("northern_wave", "aurora"), + ) + for old, new in aliases: + if new in loaded and old not in loaded: + loaded[old] = loaded[new] + def save(self): """Save the presets to a file.""" with open("presets.json", "w") as f: @@ -112,16 +133,24 @@ class Presets: """Create or update a preset with the given name.""" if name in self.presets: # Update existing preset + was_auto = self.presets[name].a self.presets[name].edit(data) - # Editing the live preset (e.g. toggling auto/manual) must reset runtime - # state; re-select alone keeps step because preset name is unchanged. + # Editing the live preset: auto still re-selects (one tick) so the strip + # restarts without a separate select message (controller often sends both). + # Manual must NOT call select() here — presets-only pushes (e.g. zone sequence + # arming the first step) would otherwise run select's first tick and consume a + # beat/step. Manual advances only on explicit select from the controller. if self.selected == name: - self.step = 0 - self.generator = None - self.fill((0, 0, 0)) - # Re-start pattern so manual/auto and other edits apply without a - # separate select message (controller usually sends both). - self.select(name) + preset = self.presets[name] + if preset.a: + self.step = 0 + self.generator = None + self.fill((0, 0, 0)) + self.select(name) + elif was_auto: + self.step = 0 + self.generator = None + self.fill((0, 0, 0)) else: if len(self.presets) >= MAX_PRESETS and name not in ("on", "off"): print("Preset limit reached:", MAX_PRESETS) diff --git a/src/print_timestamp.py b/src/print_timestamp.py new file mode 100644 index 0000000..9c516b4 --- /dev/null +++ b/src/print_timestamp.py @@ -0,0 +1,17 @@ +"""Install a builtins.print wrapper that prefixes each line with uptime (ms). + +Import this module before other led-driver imports that print (e.g. first in main). +""" + +import builtins +import utime + +_original_print = builtins.print + + +def _timestamped_print(*args, **kwargs): + ts = utime.ticks_ms() + return _original_print("[%d]" % ts, *args, **kwargs) + + +builtins.print = _timestamped_print diff --git a/src/wifi_sta.py b/src/wifi_sta.py index b4733e6..de0842f 100644 --- a/src/wifi_sta.py +++ b/src/wifi_sta.py @@ -1,5 +1,7 @@ """STA connect helpers aligned with tests/test_wifi.py (status polling, fatal codes).""" +import gc +import machine import utime import network @@ -57,6 +59,40 @@ def _one_association_campaign(sta_if, ssid, password, wdt): return True +def boot_sta(settings, wdt): + """Tear down and bring up STA. Call before large heap users (NeoPixel, patterns). + + On ESP32-C3, soft reboots can leave the Wi-Fi driver allocated; init while the + heap is still free. If re-init fails after a soft reboot, hard-reset once. + """ + sta_if = network.WLAN(network.STA_IF) + try: + if sta_if.active(): + try: + sta_if.disconnect() + except Exception: + pass + sta_if.active(False) + except Exception: + pass + utime.sleep_ms(100) + gc.collect() + try: + sta_if.active(True) + except OSError as e: + err = str(e) + if "Out of Memory" in err or "WiFi" in err: + if machine.reset_cause() == machine.SOFT_RESET: + print("wifi_sta: init failed after soft reboot, hard reset:", err) + machine.reset() + raise + sta_if.config(pm=network.WLAN.PM_NONE) + ssid = settings.get("ssid") or "" + if ssid: + connect_until_up(sta_if, ssid, settings.get("password") or "", wdt) + return sta_if + + def connect_until_up(sta_if, ssid, password, wdt): """Boot: repeat campaigns until STA has a route (same strategy as tests/test_wifi.py).""" if not ssid: diff --git a/tests/all.py b/tests/all.py index b6e0549..93222da 100644 --- a/tests/all.py +++ b/tests/all.py @@ -1,14 +1,50 @@ #!/usr/bin/env python3 -"""Self-contained led-driver test runner for MicroPython/mpremote.""" +"""Self-contained led-driver test runner for MicroPython/mpremote. + +Run on device (from led-driver repo root):: + + mpremote connect run tests/all.py + +Or via dev helper:: + + python dev.py test +""" import json -import os +import sys import utime from machine import WDT -from settings import Settings -from presets import Presets, run_tick -from utils import convert_and_reorder_colors + +def _bootstrap_import_path(): + """Find ``settings`` / ``presets`` whether this file lives in ``tests/`` or ``:/``.""" + try: + import uos as os + except ImportError: + import os + + candidates = [] + try: + here = __file__.rsplit("/", 1)[0] + if here: + candidates.append(here) + parent = here.rsplit("/", 1)[0] + if parent: + candidates.append(parent) + except NameError: + pass + candidates.extend([".", "..", "/"]) + for p in candidates: + if p and p not in sys.path: + sys.path.insert(0, p) + + +_bootstrap_import_path() + +from settings import Settings # noqa: E402 +from presets import Presets, run_tick # noqa: E402 +from preset import Preset # noqa: E402 +from utils import convert_and_reorder_colors # noqa: E402 class _TestContext: @@ -27,6 +63,20 @@ class _TestContext: utime.sleep_ms(sleep_ms) +def _pattern_loaded(ctx, pattern_id): + return pattern_id in ctx.presets.patterns + + +def _smoke_preset(ctx, name, data, ms=80): + pattern_id = data.get("p") or data.get("pattern") + if not _pattern_loaded(ctx, pattern_id): + raise AssertionError("pattern not loaded: %s" % pattern_id) + ctx.presets.edit(name, data) + if not ctx.presets.select(name): + raise AssertionError("select failed: %s" % name) + ctx.tick_for_ms(ms) + + def _process_message(ctx, payload): """Small test helper that mirrors the main message handling logic.""" try: @@ -93,8 +143,7 @@ def _process_message(ctx, payload): should_apply_default = this_device_name_norm in normalized_targets if ( should_apply_default - and - isinstance(default_name, str) + and isinstance(default_name, str) and default_name and default_name in ctx.presets.presets ): @@ -145,6 +194,40 @@ def test_preset_edit_sanitization(): assert not hasattr(p, "unknown_field") +def test_preset_mode_alias_maps_to_n6(): + ctx = _TestContext() + ctx.presets.edit( + "rainbow_mode", + {"pattern": "colour_cycle", "mode": 1, "d": 50, "n1": 2, "a": True}, + ) + p = ctx.presets.presets["rainbow_mode"] + assert p.p == "colour_cycle" + assert p.n6 == 1 + + +def test_style_mode_and_legacy_aliases(): + from patterns.pattern_modes import style_mode + + p = Preset({"p": "colour_cycle", "mode": 0, "d": 50, "c": [(255, 0, 0)]}) + assert style_mode(p, 0, {"rainbow": 1}) == 0 + + legacy = Preset({"p": "rainbow", "d": 50, "c": [(255, 0, 0)]}) + assert style_mode(legacy, 0, {"rainbow": 1}) == 1 + + ctx = _TestContext() + legacy_ids = ( + "rainbow", + "meteor_rain", + "snowfall", + "sparkle_trail", + "marquee", + "northern_wave", + ) + for lid in legacy_ids: + if not _pattern_loaded(ctx, lid): + raise AssertionError("legacy alias not registered: %s" % lid) + + def test_colour_conversion_and_transition(): ctx = _TestContext() msg = { @@ -162,7 +245,6 @@ def test_colour_conversion_and_transition(): result = _process_message(ctx, msg) assert result == "ok" assert ctx.presets.selected == "fade" - # Smoke-run the generator to ensure math runs without type errors. ctx.tick_for_ms(250) @@ -172,19 +254,54 @@ def test_pattern_smoke(): "t_on": {"p": "on", "c": [(16, 8, 4)]}, "t_off": {"p": "off"}, "t_blink": {"p": "blink", "c": [(255, 0, 0)], "d": 20}, - "t_rainbow": {"p": "rainbow", "d": 5, "n1": 2}, - "t_pulse": {"p": "pulse", "c": [(255, 0, 0)], "n1": 20, "n2": 10, "n3": 20, "d": 10}, - "t_transition": {"p": "transition", "c": [(255, 0, 0), (0, 0, 255)], "d": 30}, + "t_colour_cycle": {"p": "colour_cycle", "n6": 0, "d": 5, "n1": 2, "c": [(255, 0, 0), (0, 255, 0)]}, "t_chase": {"p": "chase", "c": [(255, 0, 0), (0, 0, 255)], "n1": 3, "n2": 2, "n3": 1, "n4": 1, "d": 20}, - "t_circle": {"p": "circle", "c": [(255, 255, 0), (0, 0, 8)], "n1": 5, "n2": 10, "n3": 5, "n4": 2}, } for name, data in cases.items(): - ctx.presets.edit(name, data) - assert ctx.presets.select(name), "select failed: %s" % name - ctx.tick_for_ms(120) + _smoke_preset(ctx, name, data, ms=100) + + +def test_merged_pattern_modes(): + """Smoke each style (``n6`` / ``mode``) for merged multi-mode patterns.""" + ctx = _TestContext() + colors = [(200, 220, 255), (255, 180, 80)] + cases = ( + ("mc_grad", "colour_cycle", {"p": "colour_cycle", "n6": 0, "n1": 2, "d": 8, "c": colors}), + ("mc_wheel", "colour_cycle", {"p": "colour_cycle", "mode": 1, "n1": 2, "d": 8}), + ("chase_std", "chase", {"p": "chase", "n6": 0, "n1": 2, "n2": 2, "n3": 1, "n4": 1, "d": 15, "c": colors}), + ("chase_marq", "chase", {"p": "chase", "n6": 1, "n1": 3, "n2": 2, "n3": 1, "d": 15, "c": colors}), + ("meteor_0", "meteor", {"p": "meteor", "n6": 0, "n1": 4, "n2": 2, "n3": 8, "d": 10, "c": colors}), + ("meteor_1", "meteor", {"p": "meteor", "n6": 1, "n1": 3, "n2": 2, "n3": 4, "d": 10, "c": colors}), + ("part_0", "particles", {"p": "particles", "n6": 0, "n1": 4, "n2": 1, "d": 10, "c": colors}), + ("part_1", "particles", {"p": "particles", "mode": 1, "n1": 3, "n2": 1, "n3": 4, "d": 10, "c": colors}), + ("spark_0", "sparkle", {"p": "sparkle", "n6": 0, "n1": 4, "n2": 6, "d": 10, "c": colors}), + ("spark_1", "sparkle", {"p": "sparkle", "n6": 1, "n1": 3, "n2": 4, "n3": 2, "d": 10, "c": colors}), + ("aurora_0", "aurora", {"p": "aurora", "n6": 0, "n1": 3, "n2": 2, "n3": 0, "d": 12, "c": colors}), + ("aurora_1", "aurora", {"p": "aurora", "mode": 1, "n1": 8, "n2": 2, "n3": 1, "d": 12, "c": colors}), + ) + for name, pattern_id, data in cases: + if not _pattern_loaded(ctx, pattern_id): + continue + _smoke_preset(ctx, name, data, ms=60) + + legacy_smoke = ( + ("leg_rainbow", "rainbow", {"p": "rainbow", "d": 8, "n1": 2}), + ("leg_ice", "ice_sparkle", {"p": "ice_sparkle", "n1": 3, "n2": 2, "n3": 2, "d": 10, "c": colors}), + ("leg_wave", "northern_wave", {"p": "northern_wave", "n1": 6, "n2": 2, "n3": 1, "d": 12, "c": colors}), + ("leg_star", "starfall", {"p": "starfall", "n1": 3, "n2": 1, "n3": 3, "d": 10, "c": colors}), + ) + for name, pattern_id, data in legacy_smoke: + if not _pattern_loaded(ctx, pattern_id): + continue + _smoke_preset(ctx, name, data, ms=60) def test_patterns_do_not_use_blocking_sleep(): + try: + import uos as os + except ImportError: + import os + pattern_dir = "patterns" offenders = [] try: @@ -192,8 +309,9 @@ def test_patterns_do_not_use_blocking_sleep(): except OSError: raise AssertionError("patterns directory is missing") + skip = frozenset(("__init__.py", "main.py", "pattern_modes.py")) for filename in files: - if not filename.endswith(".py") or filename in ("__init__.py", "main.py"): + if not filename.endswith(".py") or filename in skip: continue path = pattern_dir + "/" + filename try: @@ -223,6 +341,7 @@ def test_default_requires_existing_preset(): _process_message(ctx, {"v": "1", "default": "exists"}) assert ctx.settings.get("default") == "exists" + def test_default_targets_gate_by_device_name(): ctx = _TestContext() ctx.settings["name"] = "a" @@ -243,6 +362,11 @@ def test_default_targets_gate_by_device_name(): def test_save_and_load_roundtrip(): + try: + import uos as os + except ImportError: + import os + ctx = _TestContext() ctx.presets.edit( "persist", @@ -270,8 +394,11 @@ def run_all(): tests = [ test_invalid_messages_do_not_crash, test_preset_edit_sanitization, + test_preset_mode_alias_maps_to_n6, + test_style_mode_and_legacy_aliases, test_colour_conversion_and_transition, test_pattern_smoke, + test_merged_pattern_modes, test_patterns_do_not_use_blocking_sleep, test_default_requires_existing_preset, test_default_targets_gate_by_device_name, diff --git a/tests/patterns/auto_manual.py b/tests/patterns/auto_manual.py deleted file mode 100644 index e779b79..0000000 --- a/tests/patterns/auto_manual.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, duration_ms): - """Run pattern for specified duration.""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - print("=" * 50) - print("Testing Auto and Manual Modes") - print("=" * 50) - - # Test 1: Rainbow in AUTO mode (continuous) - print("\nTest 1: Rainbow pattern in AUTO mode (should run continuously)") - p.edit("rainbow_auto", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": True, - }) - p.select("rainbow_auto") - print("Running rainbow_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Pattern ran continuously") - - # Test 2: Rainbow in MANUAL mode (one step per tick) - print("\nTest 2: Rainbow pattern in MANUAL mode (one step per tick)") - p.edit("rainbow_manual", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": False, - }) - p.select("rainbow_manual") - print("Calling tick() 5 times (should advance 5 steps)...") - for i in range(5): - run_tick(p) - utime.sleep_ms(100) # Small delay to see changes - print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}") - - # Check if generator stopped after one cycle - if p.generator is None: - print("✓ Manual mode: Generator stopped after one step (as expected)") - else: - print("⚠ Manual mode: Generator still active (may need multiple ticks)") - - # Test 3: Pulse in AUTO mode (continuous cycles) - print("\nTest 3: Pulse pattern in AUTO mode (should pulse continuously)") - p.edit("pulse_auto", { - "p": "pulse", - "b": 128, - "d": 100, - "n1": 500, # Attack - "n2": 200, # Hold - "n3": 500, # Decay - "c": [(255, 0, 0)], - "a": True, - }) - p.select("pulse_auto") - print("Running pulse_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Pulse ran continuously") - - # Test 4: Pulse in MANUAL mode (one cycle then stop) - print("\nTest 4: Pulse pattern in MANUAL mode (one cycle then stop)") - p.edit("pulse_manual", { - "p": "pulse", - "b": 128, - "d": 100, - "n1": 300, # Attack - "n2": 200, # Hold - "n3": 300, # Decay - "c": [(0, 255, 0)], - "a": False, - }) - p.select("pulse_manual") - print("Running pulse_manual until generator stops...") - tick_count = 0 - max_ticks = 200 # Safety limit - while p.generator is not None and tick_count < max_ticks: - run_tick(p) - tick_count += 1 - utime.sleep_ms(10) - - if p.generator is None: - print(f"✓ Manual mode: Pulse completed one cycle after {tick_count} ticks") - else: - print(f"⚠ Manual mode: Pulse still running after {tick_count} ticks") - - # Test 5: Transition in AUTO mode (continuous transitions) - print("\nTest 5: Transition pattern in AUTO mode (continuous transitions)") - p.edit("transition_auto", { - "p": "transition", - "b": 128, - "d": 500, - "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)], - "a": True, - }) - p.select("transition_auto") - print("Running transition_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Transition ran continuously") - - # Test 6: Transition in MANUAL mode (one transition then stop) - print("\nTest 6: Transition pattern in MANUAL mode (one transition then stop)") - p.edit("transition_manual", { - "p": "transition", - "b": 128, - "d": 500, - "c": [(255, 0, 0), (0, 255, 0)], - "a": False, - }) - p.select("transition_manual") - print("Running transition_manual until generator stops...") - tick_count = 0 - max_ticks = 200 - while p.generator is not None and tick_count < max_ticks: - run_tick(p) - tick_count += 1 - utime.sleep_ms(10) - - if p.generator is None: - print(f"✓ Manual mode: Transition completed after {tick_count} ticks") - else: - print(f"⚠ Manual mode: Transition still running after {tick_count} ticks") - - # Test 7: Switching between auto and manual modes - print("\nTest 7: Switching between auto and manual modes") - p.edit("switch_test", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": True, - }) - p.select("switch_test") - print("Running in auto mode for 1 second...") - run_for(p, wdt, 1000) - - # Switch to manual mode by editing the preset - print("Switching to manual mode...") - p.edit("switch_test", {"a": False}) - p.select("switch_test") # Re-select to apply changes - - print("Calling tick() 3 times in manual mode...") - for i in range(3): - run_tick(p) - utime.sleep_ms(100) - print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}") - - # Switch back to auto mode - print("Switching back to auto mode...") - p.edit("switch_test", {"a": True}) - p.select("switch_test") - print("Running in auto mode for 1 second...") - run_for(p, wdt, 1000) - print("✓ Successfully switched between auto and manual modes") - - # Cleanup - print("\nCleaning up...") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_tick(p) - utime.sleep_ms(100) - - print("\n" + "=" * 50) - print("All tests completed!") - print("=" * 50) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/blizzard.py b/tests/patterns/blizzard.py deleted file mode 100644 index fb94007..0000000 --- a/tests/patterns/blizzard.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - p.tick() - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 48)) - wdt = WDT(timeout=10000) - - p.edit( - "test_blizzard", - { - "p": "blizzard", - "b": 220, - "d": 40, - "c": [(255, 255, 255), (200, 220, 255)], - "n1": 100, - "n2": 2, - "n3": 150, - "a": True, - }, - ) - p.select("test_blizzard") - run_for(p, wdt, 2500) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 80) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/candle_glow.py b/tests/patterns/candle_glow.py deleted file mode 100644 index 59f7c7e..0000000 --- a/tests/patterns/candle_glow.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) - wdt = WDT(timeout=10000) - - p.edit("test_candle_glow", { - "p": "candle_glow", - "b": 200, - "d": 60, - "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], - "n1": 4, - "n2": 2, - "n3": 120, - "a": True, - }) - p.select("test_candle_glow") - run_for(p, wdt, 3000) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/ice_sparkle.py b/tests/patterns/ice_sparkle.py deleted file mode 100644 index 41a5b7c..0000000 --- a/tests/patterns/ice_sparkle.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) - wdt = WDT(timeout=10000) - - p.edit("test_ice_sparkle", { - "p": "ice_sparkle", - "b": 200, - "d": 60, - "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], - "n1": 4, - "n2": 2, - "n3": 120, - "a": True, - }) - p.select("test_ice_sparkle") - run_for(p, wdt, 3000) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/icicles.py b/tests/patterns/icicles.py deleted file mode 100644 index d7fbe63..0000000 --- a/tests/patterns/icicles.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - p.tick() - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 40)) - wdt = WDT(timeout=10000) - - p.edit( - "test_icicles", - { - "p": "icicles", - "b": 220, - "d": 50, - "c": [(200, 230, 255), (255, 255, 255)], - "n1": 10, - "n2": 8, - "n3": 1, - "a": True, - }, - ) - p.select("test_icicles") - run_for(p, wdt, 2500) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 80) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/northern_wave.py b/tests/patterns/northern_wave.py deleted file mode 100644 index b55b9b8..0000000 --- a/tests/patterns/northern_wave.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) - wdt = WDT(timeout=10000) - - p.edit("test_northern_wave", { - "p": "northern_wave", - "b": 200, - "d": 60, - "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], - "n1": 4, - "n2": 2, - "n3": 120, - "a": True, - }) - p.select("test_northern_wave") - run_for(p, wdt, 3000) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/radiate.py b/tests/patterns/radiate.py deleted file mode 100644 index a2bc61e..0000000 --- a/tests/patterns/radiate.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets - - -def main(): - print("[test] radiate: start") - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) - p.debug = True - wdt = WDT(timeout=10000) - - print("[test] radiate: auto phase begin") - p.edit("test_pattern", {"p": "radiate", "b": 64, "a": True, "d": 3000, "c": [(255, 0, 0), (0, 0, 255)]}) - if not p.select("test_pattern"): - raise Exception("radiate select failed in auto phase") - auto_start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), auto_start) < 2500: - wdt.feed() - p.run_step() - utime.sleep_ms(20) - remaining_ms = utime.ticks_diff(p.next_tick_ms, utime.ticks_ms()) - if p.next_tick_ms == 0 or remaining_ms <= 0: - raise Exception("radiate delay scheduling invalid") - print("[test] radiate: auto phase end") - - print("[test] radiate: manual phase begin") - p.edit("test_pattern", {"p": "radiate", "b": 64, "a": False, "d": 3000, "c": [(255, 0, 0), (0, 0, 255)]}) - if not p.select("test_pattern", step=0): - raise Exception("radiate select failed in manual phase") - for _ in range(6): - current_step = int(p.step) - if not p.select("test_pattern", step=current_step): - raise Exception("radiate external select failed") - p.run_step() - wdt.feed() - if int(p.step) == current_step: - raise Exception("radiate external step did not advance") - if p.generator is not None: - raise Exception("radiate manual mode rescheduled generator") - hold_start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), hold_start) < 700: - wdt.feed() - utime.sleep_ms(20) - print("[test] radiate: manual phase end") - print("[test] radiate: pass") - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/rainbow.py b/tests/patterns/rainbow.py deleted file mode 100644 index e0d0c8a..0000000 --- a/tests/patterns/rainbow.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Basic rainbow with auto=True (continuous) - print("Test 1: Basic rainbow (auto=True, n1=1)") - p.edit("rainbow1", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 1, - "a": True, - }) - p.select("rainbow1") - run_for(p, wdt, 3000) - - # Test 2: Fast rainbow - print("Test 2: Fast rainbow (low delay, n1=1)") - p.edit("rainbow2", { - "p": "rainbow", - "d": 50, - "n1": 1, - "a": True, - }) - p.select("rainbow2") - run_for(p, wdt, 2000) - - # Test 3: Slow rainbow - print("Test 3: Slow rainbow (high delay, n1=1)") - p.edit("rainbow3", { - "p": "rainbow", - "d": 500, - "n1": 1, - "a": True, - }) - p.select("rainbow3") - run_for(p, wdt, 3000) - - # Test 4: Low brightness rainbow - print("Test 4: Low brightness rainbow (n1=1)") - p.edit("rainbow4", { - "p": "rainbow", - "b": 64, - "d": 100, - "n1": 1, - "a": True, - }) - p.select("rainbow4") - run_for(p, wdt, 2000) - - # Test 5: Single-step rainbow (auto=False) - print("Test 5: Single-step rainbow (auto=False, n1=1)") - p.edit("rainbow5", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 1, - "a": False, - }) - p.step = 0 - for i in range(10): - p.select("rainbow5") - # One tick advances the generator one frame when auto=False - run_tick(p) - utime.sleep_ms(100) - wdt.feed() - - # Test 6: Verify step updates correctly - print("Test 6: Verify step updates (auto=False, n1=1)") - p.edit("rainbow6", { - "p": "rainbow", - "n1": 1, - "a": False, - }) - initial_step = p.step - p.select("rainbow6") - run_tick(p) - final_step = p.step - print(f"Step updated from {initial_step} to {final_step} (expected increment: 1)") - - # Test 7: Fast step increment (n1=5) - print("Test 7: Fast rainbow (n1=5, auto=True)") - p.edit("rainbow7", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 5, - "a": True, - }) - p.select("rainbow7") - run_for(p, wdt, 2000) - - # Test 8: Very fast step increment (n1=10) - print("Test 8: Very fast rainbow (n1=10, auto=True)") - p.edit("rainbow8", { - "p": "rainbow", - "n1": 10, - "a": True, - }) - p.select("rainbow8") - run_for(p, wdt, 2000) - - # Test 9: Verify n1 controls step increment (auto=False) - print("Test 9: Verify n1 step increment (auto=False, n1=5)") - p.edit("rainbow9", { - "p": "rainbow", - "n1": 5, - "a": False, - }) - p.step = 0 - initial_step = p.step - p.select("rainbow9") - run_tick(p) - final_step = p.step - expected_step = (initial_step + 5) % 256 - print(f"Step updated from {initial_step} to {final_step} (expected: {expected_step})") - if final_step == expected_step: - print("✓ n1 step increment working correctly") - else: - print(f"✗ Step increment mismatch! Expected {expected_step}, got {final_step}") - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() - diff --git a/tests/patterns/rime.py b/tests/patterns/rime.py deleted file mode 100644 index fe07aae..0000000 --- a/tests/patterns/rime.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - p.tick() - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 40)) - wdt = WDT(timeout=10000) - - p.edit( - "test_rime", - { - "p": "rime", - "b": 200, - "d": 80, - "c": [(240, 248, 255), (255, 255, 255)], - "n1": 48, - "n2": 14, - "n3": 4, - "a": True, - }, - ) - p.select("test_rime") - run_for(p, wdt, 2800) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 80) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/starfall.py b/tests/patterns/starfall.py deleted file mode 100644 index 2a01ee8..0000000 --- a/tests/patterns/starfall.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30)) - wdt = WDT(timeout=10000) - - p.edit("test_starfall", { - "p": "starfall", - "b": 200, - "d": 60, - "c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)], - "n1": 4, - "n2": 2, - "n3": 120, - "a": True, - }) - p.select("test_starfall") - run_for(p, wdt, 3000) - - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main()