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>
119 lines
7.1 KiB
Markdown
119 lines
7.1 KiB
Markdown
# 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.<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 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`) |
|