Files
led-driver/docs/patterns.md
Jimmy 55a97ac51c 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>
2026-05-16 21:14:54 +12:00

119 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`) |