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 <cursoragent@cursor.com>
This commit is contained in:
2026-05-16 21:14:54 +12:00
parent 794f1a2841
commit 55a97ac51c
44 changed files with 998 additions and 1539 deletions

118
docs/patterns.md Normal file
View File

@@ -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 (0255), 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 0255 (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 presets `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.<basename>`. 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 repositorys `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 modules `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`) |