37 Commits

Author SHA1 Message Date
3286c4002d chore(settings): update default num_leds and color_order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:56 +12:00
68eb547ec4 feat(espnow): add debug logging and channel diagnostics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:54 +12:00
8403df531d feat(espnow): improve bridge transport and driver sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:38:09 +12:00
088fe161a8 fix(main): blocking espnow rx loop and pass peer host
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:46 +12:00
c9895df512 fix(presets): phase-lock blink and one tick on re-select
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:46 +12:00
39a84696c3 feat(espnow): ping request/response with jittered delay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:39 +12:00
c7560b2e87 fix(settings): default wifi channel to 5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:36 +12:00
ea21563900 fix(controller): apply select when presets not in message
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:30 +12:00
a97f6c7c2c feat(espnow): groups filter and v1 select list on driver
Apply group membership on RX, accept select as [preset_id, step?],
and fix identify/off plus presets layout for manual beat stepping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:44:21 +12:00
1fdb2c9441 fix(espnow): handle binary and JSON RX in simplified main
Use init_espnow for channel alignment; route wire CMD/GROUPS and JSON
v1 payloads to process_data from the poll loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:45:13 +12:00
3e718f7432 feat(espnow): add wire transport and simplify broadcast main
Binary espnow_wire/espnow_transport modules plus a minimal main that
broadcasts a JSON hello and polls ESP-NOW while running presets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:44:39 +12:00
85490a3bd0 feat(deploy): add file_hashes.json manifest on device
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:51 +12:00
94266d5a7c feat(patterns): reverse animation direction via preset n8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:01 +12:00
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
794f1a2841 feat(patterns): add northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:32 +12:00
8f8bc894a9 feat(patterns): add icicles blizzard and rime winter effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:09:59 +12:00
2a768376d0 chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:13:59 +12:00
170a0e05ab feat(patterns): align manual and auto behaviour
Unify manual/auto timing semantics for key patterns, add preset background support, and improve runtime observability while keeping the driver responsive under beat-triggered selects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:07:58 +12:00
4879fcfe90 fix(patterns): use preset background fallback across animations
Align pattern background rendering to use preset.background_or(...) and update pulse/radiate single-step behaviour to preserve visible frames and step progression.
2026-05-09 14:28:05 +12:00
fbebe9f4f9 fix(patterns): correct non-blocking timing and blink off phase
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:52 +12:00
a79c6f4dd3 fix(patterns): remove blocking sleeps from pattern loops
Replace sleep-based timing in pattern generators with non-blocking tick checks so long delays do not block the main loop and risk watchdog resets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:37:33 +12:00
pi
2fcaf2f064 fix(driver): persist brightness when message includes save and b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:23 +12:00
pi
3b38264b70 chore(wifi): log connecting while waiting for STA
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:29 +12:00
3ee89ce3b4 feat(driver): add HTTP routes, startup split, and binary envelope support
Wire controller messages through new modules (background tasks, runtime state,
startup) and add binary envelope handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:54:12 +12:00
74b4b495f9 feat(patterns): add expanded animation pack with smoke tests
Add a broad set of new pattern modules and matching pattern smoke scripts so the new effects can be validated directly on-device.
2026-04-23 20:10:01 +12:00
4575ef16ad test(led-driver): add espnow peer and ap pm0 scripts
Made-with: Cursor
2026-04-21 21:48:42 +12:00
a342187635 feat(patterns): add twinkle pattern defaults
Made-with: Cursor
2026-04-21 21:48:42 +12:00
428ed8b884 feat(led-driver): add preset clear command and runtime debug 2026-04-21 00:44:28 +12:00
a22702df4d feat(patterns): add radiate animation 2026-04-20 23:37:43 +12:00
5a8866add7 feat(esp32): pattern upload route and ws controller ip
Made-with: Cursor
2026-04-19 23:27:33 +12:00
a2cd2f8dc2 test(led-driver): add pattern smoke harness
Made-with: Cursor
2026-04-19 23:27:29 +12:00
c47725e31a feat(patterns): add colour cycle, flicker, and flame
Made-with: Cursor
2026-04-19 23:27:19 +12:00
22b1a8a6d6 fix(led-driver): phase-lock pattern timers
Made-with: Cursor
2026-04-19 21:41:18 +12:00
45a38c05b7 fix(led-driver): persist default preset updates 2026-04-15 00:03:21 +12:00
87bd0338bd fix(led-driver): stop loading patterns main and sync tick call 2026-04-14 23:13:16 +12:00
0a33f399e1 refactor(led-driver): simplify websocket runtime and test layout 2026-04-14 22:12:31 +12:00
pi
ded6e3d360 docs: align readme and driver api with tcp/wifi
Made-with: Cursor
2026-04-12 00:13:49 +12:00
107 changed files with 7790 additions and 1056 deletions

View File

@@ -1,36 +1,52 @@
# LED Driver - MicroPython
# LED Driver MicroPython
MicroPython-based LED driver application for ESP32 microcontrollers.
MicroPython LED driver for ESP32: presets, patterns, **Wi-Fi** (TCP + UDP discovery) or **ESP-NOW** transport, optional HTTP polling, and dynamic pattern modules under `src/patterns/`.
## Prerequisites
- MicroPython firmware installed on ESP32
- MicroPython firmware on the ESP32
- USB cable for programming
- Python 3 with pipenv
- Python 3 with pipenv (on the host, for `dev.py` / tests)
## Setup
1. Install dependencies:
```bash
pipenv install
```
2. Deploy to device:
2. Deploy to the device:
```bash
pipenv run dev
```
## Project Structure
## Project layout
```
led-driver/
├── src/
│ ├── main.py # Main application code
│ ├── presets.py # LED pattern implementations (includes Preset and Presets classes)
│ ├── settings.py # Settings management
── p2p.py # Peer-to-peer communication
├── test/ # Pattern tests
├── web_app.py # Web interface
├── dev.py # Development tools
└── Pipfile # Python dependencies
│ ├── main.py # Entry: Wi-Fi/TCP or ESP-NOW path, process_data(), manifest OTA
│ ├── presets.py # Preset runtime + Presets class
│ ├── preset.py # Single preset helpers
── settings.py # settings.json
│ ├── hello.py # UDP discovery (port 8766) / hello payloads
│ ├── http_poll.py # Optional HTTP polling helper
│ ├── utils.py # Colour conversion / ordering
│ ├── presets.json # Default preset file (on device)
│ └── patterns/ # Pattern modules (.py), loaded dynamically
├── tests/ # Host-side helpers (e.g. udp_client.py, test_mdns.py)
├── test/ # On-device style pattern tests (all.py, patterns/)
├── dev.py # Deploy / sync to serial device
├── docs/API.md # Wire format (long keys); Pi app docs short keys
├── msg.json # Sample message
├── Pipfile
└── LICENSE
```
**Transport:** `settings.json` **`transport_type`** is typically **`wifi`** (TCP to the Pi on port **8765**, discovery on **8766**) or **`espnow`**. ESP-NOW code paths are loaded only when needed so a Wi-Fi-only image stays smaller.
## Further reading
- **`docs/API.md`** — JSON message fields as used in examples (`pattern`, `colors`, …). The Pi app may send **short keys** (`p`, `c`, …); behaviour matches once normalised on device.

8
bulk.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
PORT="${1:-/dev/ttyACM0}"
while true; do
ls "$PORT" && led-cli -p "$PORT" --erase --src --patterns && led-cli -p "$PORT" --reset -f
sleep 0.5
done

26
dev.py
View File

@@ -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")

View File

@@ -1,10 +1,10 @@
# LED Driver ESPNow API Documentation
# LED Driver API (message format)
This document describes the ESPNow message format for controlling LED driver devices.
This document describes the **JSON message format** for controlling LED driver devices. The same object is accepted from **ESP-NOW** (when that transport is enabled) and as **one JSON value per line** over **TCP** in **Wi-Fi** mode (see `src/main.py` on the device).
## Message Format
All messages are JSON objects sent via ESPNow with the following structure:
All messages are JSON objects with the following structure:
```json
{

51
docs/pattern-contract.md Normal file
View File

@@ -0,0 +1,51 @@
# Pattern Contract (Important)
Pattern classes are loaded dynamically by `Presets._load_dynamic_patterns()`.
Patterns must follow this contract exactly.
## Required class shape
- File name is the pattern id (for example `blink.py` -> pattern name `blink`).
- Module exports a class with:
- `__init__(self, driver)` where `driver` is the `Presets` instance.
- `run(self, preset)` that returns a generator.
`Presets` binds patterns like this:
- `pattern_class(self).run`
- then calls `self.patterns[preset.p](preset)` and stores that generator.
- every frame, `Presets.tick()` does `next(self.generator)`.
## `run()` generator rules
- `run()` must `yield` frequently (normally once per tick loop).
- Do not block inside `run()`:
- no `sleep()` / `sleep_ms()` / long loops without `yield`.
- no network or file I/O.
- Use time checks (`utime.ticks_ms()` + `utime.ticks_diff(...)`) to schedule updates.
- Keep pattern state inside local variables in `run()` (or object fields if needed).
## Drawing and brightness
- Use `self.driver.apply_brightness(color, preset.b)` for per-preset brightness.
- Write pixels through `self.driver.n[...]` / `self.driver.n.fill(...)`.
- Flush frame with `self.driver.n.write()`.
- If a pattern needs to clear, use black `(0, 0, 0)`.
## Step semantics
- `self.driver.step` is shared pattern state managed by `Presets.select(...)` and patterns.
- Patterns that use step-based progression should update `self.driver.step` themselves.
- `select(..., step=...)` may set an explicit starting step.
## Error handling
- Let unexpected errors raise inside the generator.
- `Presets.tick()` catches exceptions, logs, and stops the active generator.
- Pattern code should not swallow broad exceptions unless there is a clear recovery path.
## Built-ins
- `off` and `on` are built-in methods on `Presets`, not loaded from this folder.
- `__init__.py` is ignored by dynamic loader.

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`) |

2
lib/microdot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

8
lib/microdot/helpers.py Normal file
View File

@@ -0,0 +1,8 @@
try:
from functools import wraps
except ImportError: # pragma: no cover
# MicroPython does not currently implement functools.wraps
def wraps(wrapped):
def _(wrapper):
return wrapper
return _

1450
lib/microdot/microdot.py Normal file

File diff suppressed because it is too large Load Diff

225
lib/microdot/session.py Normal file
View File

@@ -0,0 +1,225 @@
try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler
from microdot.helpers import wraps
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""Session handling
:param app: The application instance.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None
def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None:
self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
request.g._session = SessionDict(request, self.decode(session))
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = self.encode(session)
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session,
**self.cookie_options)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.delete_cookie('session', **self.cookie_options)
return response
def encode(self, payload, secret_key=None):
"""Encode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None):
"""Decode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
return wrapper

70
lib/microdot/utemplate.py Normal file
View File

@@ -0,0 +1,70 @@
from utemplate import recompile
_loader = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
@classmethod
def initialize(cls, template_dir='templates',
loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default
is the ``recompile.Loader`` class, which
automatically recompiles templates when they
change.
"""
global _loader
_loader = loader_class(None, template_dir)
def __init__(self, template):
if _loader is None: # pragma: no cover
self.initialize()
#: The name of the template
self.name = template
self.template = _loader.load(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return ''.join(self.generate(*args, **kwargs))
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
class sync_to_async_iter():
def __init__(self, iter):
self.iter = iter
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration
return sync_to_async_iter(self.generate(*args, **kwargs))
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
response = ''
async for chunk in self.generate_async(*args, **kwargs):
response += chunk
return response

231
lib/microdot/websocket.py Normal file
View File

@@ -0,0 +1,231 @@
import binascii
import hashlib
from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
from microdot.helpers import wraps
class WebSocketError(Exception):
"""Exception raised when an error occurs in a WebSocket connection."""
pass
class WebSocket:
"""A WebSocket connection object.
An instance of this class is sent to handler functions to manage the
WebSocket connection.
"""
CONT = 0
TEXT = 1
BINARY = 2
CLOSE = 8
PING = 9
PONG = 10
#: Specify the maximum message size that can be received when calling the
#: ``receive()`` method. Messages with payloads that are larger than this
#: size will be rejected and the connection closed. Set to 0 to disable
#: the size check (be aware of potential security issues if you do this),
#: or to -1 to use the value set in
#: ``Request.max_body_length``. The default is -1.
#:
#: Example::
#:
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
max_message_length = -1
def __init__(self, request):
self.request = request
self.closed = False
async def handshake(self):
response = self._handshake_response()
await self.request.sock[1].awrite(
b'HTTP/1.1 101 Switching Protocols\r\n')
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self):
"""Receive a message from the client."""
while True:
opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover
await self.send(data, send_opcode)
elif data: # pragma: no branch
return data
async def send(self, data, opcode=None):
"""Send a message to the client.
:param data: the data to send, given as a string or bytes.
:param opcode: a custom frame opcode to use. If not given, the opcode
is ``TEXT`` or ``BINARY`` depending on the type of the
data.
"""
frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data)
await self.request.sock[1].awrite(frame)
async def close(self):
"""Close the websocket connection."""
if not self.closed: # pragma: no cover
self.closed = True
await self.send(b'', self.CLOSE)
def _handshake_response(self):
connection = False
upgrade = False
websocket_key = None
for header, value in self.request.headers.items():
h = header.lower()
if h == 'connection':
connection = True
if 'upgrade' not in value.lower():
return self.request.app.abort(400)
elif h == 'upgrade':
upgrade = True
if not value.lower() == 'websocket':
return self.request.app.abort(400)
elif h == 'sec-websocket-key':
websocket_key = value
if not connection or not upgrade or not websocket_key:
return self.request.app.abort(400)
d = hashlib.sha1(websocket_key.encode())
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
return binascii.b2a_base64(d.digest())[:-1]
@classmethod
def _parse_frame_header(cls, header):
fin = header[0] & 0x80
opcode = header[0] & 0x0f
if fin == 0 or opcode == cls.CONT: # pragma: no cover
raise WebSocketError('Continuation frames not supported')
has_mask = header[1] & 0x80
length = header[1] & 0x7f
if length == 126:
length = -2
elif length == 127:
length = -8
return fin, opcode, has_mask, length
def _process_websocket_frame(self, opcode, payload):
if opcode == self.TEXT:
payload = payload.decode()
elif opcode == self.BINARY:
pass
elif opcode == self.CLOSE:
raise WebSocketError('Websocket connection closed')
elif opcode == self.PING:
return self.PONG, payload
elif opcode == self.PONG: # pragma: no branch
return None, None
return None, payload
@classmethod
def _encode_websocket_frame(cls, opcode, payload):
frame = bytearray()
frame.append(0x80 | opcode)
if opcode == cls.TEXT:
payload = payload.encode()
if len(payload) < 126:
frame.append(len(payload))
elif len(payload) < (1 << 16):
frame.append(126)
frame.extend(len(payload).to_bytes(2, 'big'))
else:
frame.append(127)
frame.extend(len(payload).to_bytes(8, 'big'))
frame.extend(payload)
return frame
async def _read_frame(self):
header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover
raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not authenticate_user(request):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def websocket_wrapper(f, upgrade_function):
@wraps(f)
async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request)
try:
await f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise
except WebSocketError:
pass
except Exception as exc:
print_exception(exc)
finally: # pragma: no cover
try:
await ws.close()
except Exception:
pass
return Response.already_handled
return wrapper
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
return websocket_wrapper(f, websocket_upgrade)

View File

14
lib/utemplate/compiled.py Normal file
View File

@@ -0,0 +1,14 @@
class Loader:
def __init__(self, pkg, dir):
if dir == ".":
dir = ""
else:
dir = dir.replace("/", ".") + "."
if pkg and pkg != "__main__":
dir = pkg + "." + dir
self.p = dir
def load(self, name):
name = name.replace(".", "_")
return __import__(self.p + name, None, None, (name,)).render

View File

@@ -0,0 +1,21 @@
# (c) 2014-2020 Paul Sokolovsky. MIT license.
try:
from uos import stat, remove
except:
from os import stat, remove
from . import source
class Loader(source.Loader):
def load(self, name):
o_path = self.pkg_path + self.compiled_path(name)
i_path = self.pkg_path + self.dir + "/" + name
try:
o_stat = stat(o_path)
i_stat = stat(i_path)
if i_stat[8] > o_stat[8]:
# input file is newer, remove output to force recompile
remove(o_path)
finally:
return super().load(name)

188
lib/utemplate/source.py Normal file
View File

@@ -0,0 +1,188 @@
# (c) 2014-2019 Paul Sokolovsky. MIT license.
from . import compiled
class Compiler:
START_CHAR = "{"
STMNT = "%"
STMNT_END = "%}"
EXPR = "{"
EXPR_END = "}}"
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
self.file_in = file_in
self.file_out = file_out
self.loader = loader
self.seq = seq
self._indent = indent
self.stack = []
self.in_literal = False
self.flushed_header = False
self.args = "*a, **d"
def indent(self, adjust=0):
if not self.flushed_header:
self.flushed_header = True
self.indent()
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
self.stack.append("def")
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
def literal(self, s):
if not s:
return
if not self.in_literal:
self.indent()
self.file_out.write('yield """')
self.in_literal = True
self.file_out.write(s.replace('"', '\\"'))
def close_literal(self):
if self.in_literal:
self.file_out.write('"""\n')
self.in_literal = False
def render_expr(self, e):
self.indent()
self.file_out.write('yield str(' + e + ')\n')
def parse_statement(self, stmt):
tokens = stmt.split(None, 1)
if tokens[0] == "args":
if len(tokens) > 1:
self.args = tokens[1]
else:
self.args = ""
elif tokens[0] == "set":
self.indent()
self.file_out.write(stmt[3:].strip() + "\n")
elif tokens[0] == "include":
if not self.flushed_header:
# If there was no other output, we still need a header now
self.indent()
tokens = tokens[1].split(None, 1)
args = ""
if len(tokens) > 1:
args = tokens[1]
if tokens[0][0] == "{":
self.indent()
# "1" as fromlist param is uPy hack
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
self.indent()
self.file_out.write("yield from _.render(%s)\n" % args)
return
with self.loader.input_open(tokens[0][1:-1]) as inc:
self.seq += 1
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
inc_id = self.seq
self.seq = c.compile()
self.indent()
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
elif len(tokens) > 1:
if tokens[0] == "elif":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write(stmt + ":\n")
else:
self.indent()
self.file_out.write(stmt + ":\n")
self.stack.append(tokens[0])
else:
if stmt.startswith("end"):
assert self.stack[-1] == stmt[3:]
self.stack.pop(-1)
elif stmt == "else":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write("else:\n")
else:
assert False
def parse_line(self, l):
while l:
start = l.find(self.START_CHAR)
if start == -1:
self.literal(l)
return
self.literal(l[:start])
self.close_literal()
sel = l[start + 1]
#print("*%s=%s=" % (sel, EXPR))
if sel == self.STMNT:
end = l.find(self.STMNT_END)
assert end > 0
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
self.parse_statement(stmt)
end += len(self.STMNT_END)
l = l[end:]
if not self.in_literal and l == "\n":
break
elif sel == self.EXPR:
# print("EXPR")
end = l.find(self.EXPR_END)
assert end > 0
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
self.render_expr(expr)
end += len(self.EXPR_END)
l = l[end:]
else:
self.literal(l[start])
l = l[start + 1:]
def header(self):
self.file_out.write("# Autogenerated file\n")
def compile(self):
self.header()
for l in self.file_in:
self.parse_line(l)
self.close_literal()
return self.seq
class Loader(compiled.Loader):
def __init__(self, pkg, dir):
super().__init__(pkg, dir)
self.dir = dir
if pkg == "__main__":
# if pkg isn't really a package, don't bother to use it
# it means we're running from "filesystem directory", not
# from a package.
pkg = None
self.pkg_path = ""
if pkg:
p = __import__(pkg)
if isinstance(p.__path__, str):
# uPy
self.pkg_path = p.__path__
else:
# CPy
self.pkg_path = p.__path__[0]
self.pkg_path += "/"
def input_open(self, template):
path = self.pkg_path + self.dir + "/" + template
return open(path)
def compiled_path(self, template):
return self.dir + "/" + template.replace(".", "_") + ".py"
def load(self, name):
try:
return super().load(name)
except (OSError, ImportError):
pass
compiled_path = self.pkg_path + self.compiled_path(name)
f_in = self.input_open(name)
f_out = open(compiled_path, "w")
c = Compiler(f_in, f_out, loader=self)
c.compile()
f_in.close()
f_out.close()
return super().load(name)

1
presets.json Normal file
View File

@@ -0,0 +1 @@
{"15": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 500}, "40": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 0]], "b": 255, "n2": 2600, "n1": 35, "p": "flame", "n3": 0, "d": 50}, "41": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[120, 200, 255], [80, 140, 255], [180, 120, 255], [100, 220, 232], [160, 200, 255]], "b": 255, "n2": 10, "n1": 72, "p": "twinkle", "n3": 5, "d": 500}, "42": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[166, 0, 255], [0, 10, 10]], "b": 255, "n2": 900, "n1": 30, "p": "radiate", "n3": 4000, "d": 5000}, "6": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 255, 0]], "b": 255, "n2": 500, "n1": 1000, "p": "pulse", "n3": 1000, "d": 500}, "10": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[230, 242, 255]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "13": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 1, "p": "rainbow", "n3": 0, "d": 150}, "3": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 2, "p": "rainbow", "n3": 0, "d": 100}, "2": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 0, "n2": 0, "n1": 0, "p": "off", "n3": 0, "d": 100}, "38": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 1, "p": "colour_cycle", "n3": 0, "d": 100}, "11": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "12": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "1": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "9": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 245, 230]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "8": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 1000}, "39": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 184, 77]], "b": 255, "n2": 0, "n1": 30, "p": "flicker", "n3": 0, "d": 80}, "14": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 102, 0]], "b": 255, "n2": 1000, "n1": 2000, "p": "pulse", "n3": 2000, "d": 800}, "5": {"n5": 0, "n4": 1, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 5, "n1": 5, "p": "chase", "n3": 1, "d": 200}, "4": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 5000}, "7": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[255, 165, 0], [128, 0, 128]], "b": 255, "n2": 10, "n1": 2, "p": "circle", "n3": 2, "d": 200}}

17
src/background_tasks.py Normal file
View File

@@ -0,0 +1,17 @@
import asyncio
import utime
from mem_stats import print_mem
async def presets_loop(presets, wdt):
last_mem_log = utime.ticks_ms()
while True:
presets.tick()
wdt.feed()
if bool(getattr(presets, "debug", False)):
now = utime.ticks_ms()
if utime.ticks_diff(now, last_mem_log) >= 5000:
print_mem("runtime")
last_mem_log = now
await asyncio.sleep(0)

209
src/binary_envelope.py Normal file
View File

@@ -0,0 +1,209 @@
"""Decode compact binary controller envelopes — v2 native binary, v1 legacy JSON blobs."""
import json
import struct
BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5
def _brightness_0_255_from_wire(wire):
w = max(0, min(127, int(wire)))
return min(255, (w * 255) // 127)
def _decode_preset_record(buf, off):
nl = buf[off]
off += 1
name = buf[off : off + nl].decode("utf-8")
off += nl
pl = buf[off]
off += 1
pattern = buf[off : off + pl].decode("utf-8")
off += pl
nc = buf[off]
off += 1
colors = []
for _ in range(nc):
r, g, b = buf[off], buf[off + 1], buf[off + 2]
off += 3
colors.append("#%02x%02x%02x" % (r, g, b))
if off + 16 > len(buf):
raise ValueError("truncated")
delay, br, auto, n1, n2, n3, n4, n5, n6 = struct.unpack_from(
"<HBBhhhhhh", buf, off
)
off += 16
preset = {
"p": pattern,
"c": colors,
"d": delay,
"b": br,
"a": bool(auto),
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
}
return name, preset, off
def _decode_presets_blob(chunk):
if not chunk:
return {}
off = 0
count = chunk[off]
off += 1
out = {}
for _ in range(count):
name, preset, off = _decode_preset_record(chunk, off)
out[name] = preset
if off != len(chunk):
raise ValueError("presets blob mismatch")
return out
def _decode_select_blob(chunk):
if not chunk:
return {}
off = 0
count = chunk[off]
off += 1
out = {}
for _ in range(count):
dl = chunk[off]
off += 1
device = chunk[off : off + dl].decode("utf-8")
off += dl
pl = chunk[off]
off += 1
pname = chunk[off : off + pl].decode("utf-8")
off += pl
has_step = chunk[off]
off += 1
if has_step:
step = struct.unpack_from("<H", chunk, off)[0]
off += 2
out[device] = [pname, step]
else:
out[device] = [pname]
if off != len(chunk):
raise ValueError("select blob mismatch")
return out
def _decode_default_blob(chunk):
if not chunk:
return "", []
off = 0
nl = chunk[off]
off += 1
default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
off += nl
nt = chunk[off]
off += 1
targets = []
for _ in range(nt):
tl = chunk[off]
off += 1
targets.append(chunk[off : off + tl].decode("utf-8"))
off += tl
if off != len(chunk):
raise ValueError("default blob mismatch")
return default_name, targets
def parse_binary_envelope_v2(buf):
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_2:
return None
lp = buf[2]
ls = buf[3]
ld = buf[4]
need = HEADER_LEN + lp + ls + ld
if len(buf) != need:
return None
off = HEADER_LEN
presets_chunk = buf[off : off + lp]
off += lp
select_chunk = buf[off : off + ls]
off += ls
default_chunk = buf[off : off + ld]
data = {"v": "1"}
br = buf[1]
if br < 128:
data["b"] = _brightness_0_255_from_wire(br)
try:
if lp:
data["presets"] = _decode_presets_blob(presets_chunk)
if ls:
data["select"] = _decode_select_blob(select_chunk)
if ld:
dname, targets = _decode_default_blob(default_chunk)
data["default"] = dname
data["targets"] = targets
except (ValueError, UnicodeError, TypeError, struct.error):
return None
return data
def parse_binary_envelope_v1(buf):
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_1:
return None
lp = buf[2]
ls = buf[3]
ld = buf[4]
need = HEADER_LEN + lp + ls + ld
if len(buf) != need:
return None
off = HEADER_LEN
presets_chunk = buf[off : off + lp]
off += lp
select_chunk = buf[off : off + ls]
off += ls
default_chunk = buf[off : off + ld]
data = {"v": "1"}
br = buf[1]
if br < 128:
data["b"] = _brightness_0_255_from_wire(br)
if lp:
try:
data["presets"] = json.loads(presets_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if ls:
try:
data["select"] = json.loads(select_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if ld:
try:
extra = json.loads(default_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if isinstance(extra, dict):
for k, v in extra.items():
data[k] = v
return data
def parse_binary_envelope(buf):
d = parse_binary_envelope_v2(buf)
if d is not None:
return d
return parse_binary_envelope_v1(buf)

462
src/controller_messages.py Normal file
View File

@@ -0,0 +1,462 @@
"""Parse controller JSON (v1) and apply brightness, presets, OTA patterns, etc."""
import json
import socket
import network
import ubinascii
import device_groups as dg
from v1_wire import expand_v1
from binary_envelope import parse_binary_envelope
from utils import convert_and_reorder_colors
try:
import uos as os
except ImportError:
import os
def _log_rx(payload) -> None:
"""Serial log when led-controller sends a message into ``process_data``."""
try:
if isinstance(payload, (bytes, bytearray)):
n = len(payload)
if n == 0:
print("rx 0 B")
return
cap = 160
chunk = payload if n <= cap else payload[:cap]
try:
txt = bytes(chunk).decode("utf-8")
except Exception:
txt = str(chunk)
if n > cap:
txt = txt + "..."
print("rx", n, "B", txt)
else:
s = str(payload)
cap = 200
if len(s) <= cap:
print("rx", len(s), "C", s)
else:
print("rx", len(s), "C", s[:cap] + "...")
except Exception:
print("rx (logging failed)")
def process_data(payload, settings, presets, controller_ip=None, save=False):
"""Read one controller message; binary v2 envelope or JSON v1, then apply fields."""
_log_rx(payload)
data = None
if isinstance(payload, (bytes, bytearray)):
data = parse_binary_envelope(payload)
if data is None:
try:
data = json.loads(payload)
except (ValueError, TypeError):
return
else:
try:
data = json.loads(payload)
except (ValueError, TypeError):
return
if data.get("v", "") != "1":
return
data = expand_v1(data)
if save:
data["save"] = True
set_groups = bool(data.get("set_groups"))
groups = data.get("groups")
if set_groups and isinstance(groups, list):
dg.groups_replace(groups, settings)
print("groups set", dg.list_groups())
elif isinstance(groups, list) and groups:
if not any(dg.in_group(str(g)) for g in groups):
print("ignored: not in groups", groups)
return
if "device_config" in data:
apply_device_config(data, settings, presets)
if "b" in data:
apply_brightness(data, settings, presets)
if "presets" in data:
apply_presets(data, settings, presets)
if "clear_presets" in data:
apply_clear_presets(data, presets)
if ("select" in data or "s" in data) and "presets" not in data:
apply_select(data, settings, presets)
if "default" in data:
apply_default(data, settings, presets)
if "manifest" in data:
apply_patterns_ota(data, presets, controller_ip=controller_ip)
if "save" in data and ("presets" in data or "default" in data):
presets.save()
if "save" in data and "clear_presets" in data:
presets.save()
if "save" in data and "b" in data:
settings.save()
if "save" in data and "device_config" in data:
settings.save()
_flush_pending_select(settings, presets)
_VALID_DEVICE_COLOR_ORDERS = frozenset({"rgb", "rbg", "grb", "gbr", "brg", "bgr"})
_STARTUP_MODES = frozenset({"default", "last", "off"})
_MAX_DEVICE_LEDS = 2048
def apply_startup_pattern(settings, presets):
"""Apply power-on behaviour from ``startup_mode`` (default / last / off)."""
mode = str(settings.get("startup_mode", "default")).lower().strip()
if mode not in _STARTUP_MODES:
mode = "default"
if mode == "off":
if presets.select("off"):
return
presets.fill((0, 0, 0))
return
if mode == "last":
lp = settings.get("last_preset") or ""
if isinstance(lp, str) and lp.strip() and lp.strip() in presets.presets:
if presets.select(lp.strip()):
return
dp = settings.get("default", "")
if dp and dp in presets.presets:
if not presets.select(dp):
print("Startup preset failed (invalid pattern?):", dp)
def apply_device_config(data, settings, presets):
"""Apply fields from v1 ``device_config``; reload presets when strip length or colour order changes."""
dc = data.get("device_config")
if not isinstance(dc, dict):
return
strip_changed = False
meta_changed = False
if "name" in dc:
n = dc["name"]
if isinstance(n, str) and n.strip():
settings["name"] = n.strip()
meta_changed = True
if "num_leds" in dc:
try:
n = int(dc["num_leds"])
if 1 <= n <= _MAX_DEVICE_LEDS:
settings["num_leds"] = n
presets.update_num_leds(settings["led_pin"], n)
strip_changed = True
except (TypeError, ValueError):
pass
if "color_order" in dc:
co = str(dc["color_order"]).lower().strip()
if co in _VALID_DEVICE_COLOR_ORDERS:
settings["color_order"] = co
settings.color_order = settings.get_color_order(co)
strip_changed = True
if "startup_mode" in dc:
sm = str(dc["startup_mode"]).lower().strip()
if sm in _STARTUP_MODES:
settings["startup_mode"] = sm
meta_changed = True
if not strip_changed and not meta_changed:
return
if strip_changed:
prev = presets.selected
try:
presets.load(settings)
except Exception as e:
print("device_config: presets.load failed:", e)
if prev and prev in presets.presets:
presets.select(prev)
elif settings.get("default") and settings["default"] in presets.presets:
presets.select(settings["default"])
def record_last_preset(settings, preset_name):
"""Persist the last selected preset id (single entry in flash)."""
if not isinstance(preset_name, str) or not preset_name:
return
settings["last_preset"] = preset_name.strip()
settings.save()
def apply_brightness(data, settings, presets):
try:
presets.b = max(0, min(255, int(data["b"])))
settings["brightness"] = presets.b
except (TypeError, ValueError):
pass
_pending_select = None
def _run_select(presets, settings, preset_name, step=None):
if presets.select(preset_name, step=step):
record_last_preset(settings, preset_name)
return True
return False
def _flush_pending_select(settings, presets):
global _pending_select
if _pending_select is None:
return
preset_name, step = _pending_select
if preset_name not in presets.presets and preset_name not in ("on", "off"):
return
_pending_select = None
if not _run_select(presets, settings, preset_name, step):
print("select failed (pending):", preset_name)
def apply_presets(data, settings, presets):
global _pending_select
presets_map = data["presets"]
for id, preset_data in presets_map.items():
if not preset_data:
continue
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
if color_key is not None:
try:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], settings
)
except (TypeError, ValueError, KeyError) as err:
print("preset color convert failed:", id, err)
if "bg" in preset_data:
try:
bg_color = convert_and_reorder_colors([preset_data["bg"]], settings)
if bg_color:
preset_data["bg"] = bg_color[0]
except (TypeError, ValueError, KeyError):
pass
presets.edit(id, preset_data)
# Same message often carries select; apply now while presets are loaded.
if "select" in data or "s" in data:
apply_select(data, settings, presets)
else:
_flush_pending_select(settings, presets)
def _select_list_for_this_device(select_val, settings):
"""Resolve select to ``[preset_id, step?]`` (wire list or legacy name map)."""
if isinstance(select_val, list) and select_val:
return select_val
if isinstance(select_val, str) and str(select_val).strip():
return [str(select_val).strip()]
if not isinstance(select_val, dict) or not select_val:
return None
if "preset" in select_val:
preset_name = select_val.get("preset")
if preset_name is None:
return None
out = [str(preset_name)]
if "step" in select_val:
out.append(select_val["step"])
return out
device_name = str(settings.get("name") or "").strip()
select_list = select_val.get(device_name)
if select_list:
return select_list
try:
sta = network.WLAN(network.STA_IF)
mac_hex = ubinascii.hexlify(sta.config("mac")).decode().lower()
except Exception:
mac_hex = ""
if mac_hex:
for key in select_val:
k = str(key).lower().replace(":", "").replace("-", "")
if mac_hex in k:
return select_val[key]
if len(select_val) == 1:
return next(iter(select_val.values()))
return None
def apply_select(data, settings, presets):
global _pending_select
select_val = data.get("select")
if select_val is None:
select_val = data.get("s")
select_list = _select_list_for_this_device(select_val, settings)
if not select_list:
print("select ignored:", repr(select_val))
return
preset_name = str(select_list[0]).strip()
if not preset_name:
return
step = select_list[1] if len(select_list) > 1 else None
if preset_name not in presets.presets and preset_name not in ("on", "off"):
try:
presets.load(settings)
except Exception:
pass
if preset_name not in presets.presets and preset_name not in ("on", "off"):
_pending_select = (preset_name, step)
print("select deferred (preset not loaded yet):", preset_name)
return
if _run_select(presets, settings, preset_name, step):
_pending_select = None
else:
print("select failed:", preset_name)
def apply_clear_presets(data, presets):
clear_value = data.get("clear_presets")
if isinstance(clear_value, bool):
should_clear = clear_value
elif isinstance(clear_value, int):
should_clear = bool(clear_value)
elif isinstance(clear_value, str):
should_clear = clear_value.lower() in ("true", "1", "yes", "on")
else:
should_clear = False
if not should_clear:
return
presets.delete_all()
def apply_default(data, settings, presets):
targets = data.get("targets") or []
default_name = data["default"]
if (
settings["name"] in targets
and isinstance(default_name, str)
and default_name in presets.presets
):
settings["default"] = default_name
settings.save()
def _parse_http_url(url):
"""Parse http://host[:port]/path into (host, port, path)."""
if not isinstance(url, str):
raise ValueError("url must be a string")
if not url.startswith("http://"):
raise ValueError("only http:// URLs are supported")
remainder = url[7:]
slash_idx = remainder.find("/")
if slash_idx == -1:
host_port = remainder
path = "/"
else:
host_port = remainder[:slash_idx]
path = remainder[slash_idx:]
if ":" in host_port:
host, port_s = host_port.rsplit(":", 1)
port = int(port_s)
else:
host = host_port
port = 80
if not host:
raise ValueError("missing host")
return host, port, path
def _http_get_raw(url, timeout_s=10.0):
host, port, path = _parse_http_url(url)
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
).encode("utf-8")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((host, int(port)))
sock.send(req)
data = b""
while True:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
finally:
try:
sock.close()
except Exception:
pass
sep = b"\r\n\r\n"
if sep not in data:
raise OSError("invalid HTTP response")
head, body = data.split(sep, 1)
status_line = head.split(b"\r\n", 1)[0]
if b" 200 " not in status_line:
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
return body
def _http_get_json(url, timeout_s=10.0):
body = _http_get_raw(url, timeout_s=timeout_s)
return json.loads(body.decode("utf-8"))
def _http_get_text(url, timeout_s=10.0, controller_ip=None):
# Support relative URLs from controller messages.
if isinstance(url, str) and url.startswith("/"):
if not controller_ip:
raise OSError("controller IP unavailable for relative URL")
url = "http://%s%s" % (controller_ip, url)
try:
body = _http_get_raw(url, timeout_s=timeout_s)
return body.decode("utf-8")
except Exception:
# Fallback for mDNS/unresolvable host: retry against current controller IP.
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
raise
_host, _port, path = _parse_http_url(url)
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
body = _http_get_raw(fallback, timeout_s=timeout_s)
return body.decode("utf-8")
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def apply_patterns_ota(data, presets, controller_ip=None):
manifest_payload = data.get("manifest")
if not manifest_payload:
return
try:
if isinstance(manifest_payload, dict):
manifest = manifest_payload
elif isinstance(manifest_payload, str):
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
else:
print("patterns_ota: invalid manifest payload type")
return
files = manifest.get("files", [])
if not isinstance(files, list) or not files:
print("patterns_ota: no files in manifest")
return
try:
os.mkdir("patterns")
except OSError:
pass
updated = 0
for item in files:
if not isinstance(item, dict):
continue
name = item.get("name")
url = item.get("url")
inline_code = item.get("code")
if not _safe_pattern_filename(name):
continue
if isinstance(inline_code, str):
code = inline_code
elif isinstance(url, str):
code = _http_get_text(url, timeout_s=20.0, controller_ip=controller_ip)
else:
continue
with open("patterns/" + name, "w") as f:
f.write(code)
updated += 1
if updated > 0:
presets.reload_patterns()
except Exception as e:
print("patterns_ota failed:", e)

28
src/device_groups.py Normal file
View File

@@ -0,0 +1,28 @@
"""Group membership for GROUP_CMD filtering; persisted in settings.json."""
_groups = []
def load_from_settings(settings):
global _groups
g = settings.get("groups") if settings is not None else None
if isinstance(g, list):
_groups = [str(x) for x in g if str(x).strip()]
else:
_groups = []
def groups_replace(group_ids, settings=None, *, persist=True):
global _groups
_groups = [str(g) for g in group_ids]
if persist and settings is not None:
settings["groups"] = list(_groups)
settings.save()
def in_group(group_id):
return str(group_id) in _groups
def list_groups():
return list(_groups)

187
src/espnow_transport.py Normal file
View File

@@ -0,0 +1,187 @@
"""ESP-NOW receive loop and boot announce."""
import asyncio
import urandom
import ubinascii
import espnow
import network
import device_groups as dg
from espnow_wire import (
BROADCAST_MAC,
MSG_ANNOUNCE,
MSG_CMD,
MSG_GROUP_CMD,
MSG_GROUPS,
MSG_PING_REQ,
cmd_envelope,
pack_announce,
pack_ping_rsp,
parse_group_cmd,
parse_groups,
parse_ping_req,
wire_msg_type,
)
from controller_messages import process_data
from settings import WIFI_CHANNEL_DEFAULT
_PING_DELAY_MS_MIN = 50
_PING_DELAY_MS_MAX = 500
_esp = None
_groups_received = False
_debug = False
def _dlog(*parts):
if _debug:
print(*parts)
def init_espnow(settings):
global _esp, _debug
_debug = bool(settings.get("debug", False))
try:
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
except (TypeError, ValueError):
ch = WIFI_CHANNEL_DEFAULT
ch = max(1, min(11, ch))
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception as e:
print("espnow sta channel set failed:", e)
_esp = espnow.ESPNow()
_esp.active(True)
try:
_esp.add_peer(BROADCAST_MAC)
_dlog("espnow add bcast ok")
except Exception as e:
print("espnow add bcast failed:", e)
try:
actual_ch = sta.config("channel")
except Exception:
actual_ch = "?"
print("espnow init ch", ch, "sta_ch", actual_ch, "debug", _debug)
return _esp
def _send_ping_rsp(host, settings, ping_id, delay_ms):
import utime
utime.sleep_ms(delay_ms)
if _esp is None or not host or len(host) != 6:
return
pkt = pack_ping_rsp(ping_id, settings.get("name", "led"))
try:
try:
_esp.add_peer(host)
_dlog("espnow ping add_peer ok", ubinascii.hexlify(host).decode())
except Exception as e:
_dlog("espnow ping add_peer skip", repr(e))
_esp.send(host, pkt)
print("espnow ping rsp", ping_id, delay_ms, "ms", ubinascii.hexlify(host).decode())
except Exception as e:
print("espnow ping rsp failed:", e, "host", ubinascii.hexlify(host).decode(), "len", len(pkt))
async def _send_ping_rsp_delayed(host, settings, ping_id):
span = _PING_DELAY_MS_MAX - _PING_DELAY_MS_MIN
delay_ms = _PING_DELAY_MS_MIN + (urandom.getrandbits(10) % (span + 1))
await asyncio.sleep(delay_ms / 1000)
_send_ping_rsp(host, settings, ping_id, delay_ms)
def _schedule_ping_rsp(host, settings, ping_id):
span = _PING_DELAY_MS_MAX - _PING_DELAY_MS_MIN
delay_ms = _PING_DELAY_MS_MIN + (urandom.getrandbits(10) % (span + 1))
try:
import _thread
_thread.start_new_thread(_send_ping_rsp, (host, settings, ping_id, delay_ms))
except ImportError:
asyncio.create_task(_send_ping_rsp_delayed(host, settings, ping_id))
def send_boot_announce(settings):
if _esp is None:
return
pkt = pack_announce(
settings.get("name", "led"),
settings.get("num_leds", 1),
color_order=settings.get("color_order", "rgb"),
startup_mode=settings.get("startup_mode", "default"),
brightness=settings.get("brightness", 32),
)
try:
_esp.send(BROADCAST_MAC, pkt)
print("espnow announce", len(pkt), "B")
except Exception as e:
print("espnow announce failed:", e)
def _handle_packet(host, pkt, settings, presets):
global _groups_received
mt = wire_msg_type(pkt)
if mt == MSG_GROUPS:
ids = parse_groups(pkt)
if ids is not None:
dg.groups_replace(ids, settings)
_groups_received = True
print("groups", ids)
return
if mt == MSG_GROUP_CMD:
parsed = parse_group_cmd(pkt)
if parsed is None:
return
gid, env = parsed
if not dg.in_group(gid):
return
from espnow_wire import _envelope_size
need = _envelope_size(env)
save = len(env) > need and env[need] == 1
body = env[:need] if save else env
if body:
process_data(body, settings, presets, save=save)
return
if mt == MSG_CMD:
env, save = cmd_envelope(pkt)
if env:
process_data(env, settings, presets, save=save)
return
if mt == MSG_PING_REQ:
ping_id = parse_ping_req(pkt)
if ping_id is not None and host and len(host) == 6:
_schedule_ping_rsp(host, settings, ping_id)
return
if mt == MSG_ANNOUNCE:
return
async def espnow_receive_loop(settings, presets, wdt=None):
global _groups_received
while True:
if _esp is None:
await asyncio.sleep(0.1)
continue
host, msg = _esp.recv(0)
if not host:
if not _groups_received:
await asyncio.sleep(5)
send_boot_announce(settings)
else:
await asyncio.sleep(0.02)
if wdt:
wdt.feed()
continue
try:
_handle_packet(host, msg, settings, presets)
except Exception as e:
print("espnow rx error:", e)
if wdt:
wdt.feed()

135
src/espnow_wire.py Normal file
View File

@@ -0,0 +1,135 @@
"""ESP-NOW wire format (MicroPython). See docs/espnow-binary-protocol.md in led-controller."""
import struct
WIRE_MAGIC = 0x4C
MAX_ESPNOW_PAYLOAD = 250
MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_PING_REQ = 0x05
MSG_PING_RSP = 0x06
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
COLOR_ORDER_TO_ENUM = {
"rgb": 0,
"rbg": 1,
"grb": 2,
"gbr": 3,
"brg": 4,
"bgr": 5,
}
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
def _pack_header(msg_type, body):
pkt = bytes([WIRE_MAGIC, msg_type]) + body
if len(pkt) > MAX_ESPNOW_PAYLOAD:
raise ValueError("packet too large")
return pkt
def pack_announce(
name,
num_leds,
color_order="rgb",
startup_mode="default",
brightness=32,
device_type=0,
):
name_b = name.encode("utf-8")
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
body = (
bytes([len(name_b)])
+ name_b
+ struct.pack("<H", int(num_leds))
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
)
return _pack_header(MSG_ANNOUNCE, body)
def parse_groups(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_GROUPS:
return None
body = payload[2:]
else:
body = payload
if not body:
return []
off = 0
count = body[off]
off += 1
out = []
for _ in range(count):
gl = body[off]
off += 1
out.append(body[off : off + gl].decode("utf-8"))
off += gl
return out
def parse_group_cmd(payload):
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
return None
body = payload[2:]
gl = body[0]
gid = body[1 : 1 + gl].decode("utf-8")
env = body[1 + gl :]
return gid, env
HEADER_LEN = 5
def _envelope_size(env):
if len(env) < HEADER_LEN:
return len(env)
lp, ls, ld = env[2], env[3], env[4]
return HEADER_LEN + lp + ls + ld
def cmd_envelope(payload):
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
return None, False
env = payload[2:]
if not env:
return None, False
need = _envelope_size(env)
if need > len(env):
return None, False
save = len(env) > need and env[need] == 1
return env[:need], save
def pack_ping_req(ping_id):
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF)
return _pack_header(MSG_PING_REQ, body)
def parse_ping_req(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_REQ:
return None
body = payload[2:]
else:
body = payload
if len(body) < 4:
return None
return struct.unpack("<I", body[:4])[0]
def pack_ping_rsp(ping_id, name):
name_b = name.encode("utf-8")
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF) + bytes([len(name_b)]) + name_b
return _pack_header(MSG_PING_RSP, body)
def wire_msg_type(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
return payload[1]
return None

101
src/file_hashes.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Deploy hash manifest at flash root (file_hashes.json).
Updated by led-cli after directory uploads; used to skip unchanged files on
the next deploy. Format: {"version": 1, "algorithm": "sha256", "files": {...}}
"""
import json
import os
MANIFEST_VERSION = 1
MANIFEST_FILENAME = "file_hashes.json"
HASH_ALGO = "sha256"
_SKIP_NAMES = frozenset({MANIFEST_FILENAME, "__pycache__"})
_SKIP_SUFFIXES = (".pyc", ".pyo")
def _normalize_path(path):
return path.replace("\\", "/").lstrip("/")
def load():
"""Return path -> sha256 hex map, or {} if missing or invalid."""
try:
with open(MANIFEST_FILENAME, "r") as f:
doc = json.load(f)
except OSError:
return {}
if not isinstance(doc, dict):
return {}
files = doc.get("files")
return files if isinstance(files, dict) else {}
def save(files):
"""Write manifest (path keys use forward slashes, no leading slash)."""
if not isinstance(files, dict):
files = {}
doc = {
"version": MANIFEST_VERSION,
"algorithm": HASH_ALGO,
"files": files,
}
with open(MANIFEST_FILENAME, "w") as f:
json.dump(doc, f)
def _hash_file(path):
import hashlib
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(256)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def _walk_dir(base, prefix, out):
try:
names = os.listdir(base)
except OSError:
return
for name in names:
if name in _SKIP_NAMES or name.endswith(_SKIP_SUFFIXES):
continue
full = base + "/" + name if base else name
key = _normalize_path((prefix + "/" + name) if prefix else name)
try:
mode = os.stat(full)[0]
except OSError:
continue
if mode & 0x4000:
_walk_dir(full, key, out)
else:
out[key] = _hash_file(full)
def rebuild():
"""Rebuild manifest from root .py files plus patterns/ and lib/ trees."""
files = {}
try:
for name in os.listdir("."):
if name in _SKIP_NAMES or name.endswith(_SKIP_SUFFIXES):
continue
try:
mode = os.stat(name)[0]
except OSError:
continue
if mode & 0x4000:
if name in ("patterns", "lib"):
_walk_dir(name, name, files)
else:
files[_normalize_path(name)] = _hash_file(name)
except OSError:
pass
save(files)
return files

View File

@@ -1,4 +1,8 @@
"""LED hello payload and UDP broadcast discovery (controller IP via echo on port 8766).
"""LED hello JSON line and UDP broadcast on port 8766.
Used so led-controller can register the device (name, MAC, IP) when ``wait_reply`` is
false; the controller may then connect to the device's WebSocket. With
``wait_reply`` true, blocks for an echo and returns the controller IP (legacy discovery).
Wi-Fi must already be connected; this module does not use Settings or call connect().
"""
@@ -40,7 +44,13 @@ def ipv4_broadcast(ip, netmask):
im = [int(x) for x in netmask.split(".")]
if len(ia) != 4 or len(im) != 4:
return None
return ".".join(str(ia[i] | (255 - im[i])) for i in range(4))
# STA often reports 255.255.255.255; "broadcast" would equal the host IP — useless for LAN.
if netmask == "255.255.255.255":
return None
bcast = ".".join(str(ia[i] | (255 - im[i])) for i in range(4))
if bcast == ip:
return None
return bcast
def udp_discovery_targets(ip, mask):
@@ -52,6 +62,14 @@ def udp_discovery_targets(ip, mask):
return out
def _udp_discovery_targets_single(ip, mask):
"""One destination: subnet broadcast if known, else limited broadcast."""
b = ipv4_broadcast(ip, mask)
if b:
return [(b, DISCOVERY_UDP_PORT)]
return [("255.255.255.255", DISCOVERY_UDP_PORT)]
def broadcast_hello_udp(
sta,
device_name="",
@@ -59,16 +77,21 @@ def broadcast_hello_udp(
wait_reply=True,
recv_timeout_s=DEFAULT_RECV_TIMEOUT_S,
wdt=None,
dual_destinations=True,
):
"""
Send pack_hello_line via directed then 255.255.255.255 on DISCOVERY_UDP_PORT.
Send pack_hello_line on DISCOVERY_UDP_PORT.
STA must already be connected with a valid IPv4 (caller brings up Wi-Fi).
If dual_destinations (default), send subnet broadcast then 255.255.255.255 so
discovery works on awkward APs — the controller may receive two packets.
If dual_destinations is False, send only one (subnet broadcast or limited),
e.g. after TCP connect so the Pi does not run duplicate resync handlers.
If wait_reply, wait for first UDP echo. Returns controller IP string or None.
"""
ip, mask, _gw, _dns = sta.ifconfig()
msg = pack_hello_line(sta, device_name)
print("hello:", msg)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
@@ -89,14 +112,17 @@ def broadcast_hello_udp(
pass
discovered = None
for dest_ip, dest_port in udp_discovery_targets(ip, mask):
targets = (
udp_discovery_targets(ip, mask)
if dual_destinations
else _udp_discovery_targets_single(ip, mask)
)
for dest_ip, dest_port in targets:
if wdt is not None:
wdt.feed()
label = "%s:%s" % (dest_ip, dest_port)
target = (dest_ip, dest_port)
try:
sock.sendto(msg, target)
print("sent hello ->", target)
except OSError as e:
print("sendto failed:", e)
continue
@@ -105,20 +131,12 @@ def broadcast_hello_udp(
if wdt is not None:
wdt.feed()
try:
data, addr = sock.recvfrom(2048)
print("reply from", addr, ":", data)
_data, addr = sock.recvfrom(2048)
remote_ip = addr[0]
if data != msg:
print("(warning: reply payload differs from hello; still using source IP.)")
discovered = remote_ip
print("Discovered controller at", remote_ip)
break
except OSError as e:
print("recv (no reply):", e, "via", label)
if dest_ip == "255.255.255.255":
print(
"(hint: many APs drop Wi-Fi client broadcast; try wired server or AP without client isolation.)"
)
except OSError:
pass
sock.close()
return discovered
@@ -142,18 +160,12 @@ def discover_controller_udp(device_name="", wdt=None):
print("hello: STA has no IP address.")
raise SystemExit(1)
print("STA IP:", ip, "mask:", mask)
discovered = broadcast_hello_udp(
sta,
device_name,
wait_reply=True,
wdt=wdt,
)
if discovered:
print("discover done; controller =", repr(discovered))
else:
print("discover done; controller not found")
return discovered

View File

@@ -1,68 +0,0 @@
"""Minimal HTTP/1.1 POST JSON client for driver long-poll (MicroPython)."""
import json
import socket
def _send_all(sock, data):
n = 0
while n < len(data):
m = sock.send(data[n:])
if m <= 0:
raise OSError("socket send failed")
n += m
def _read_http_json_body(sock, max_headers=8192):
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(256)
if not chunk:
break
buf += chunk
if len(buf) > max_headers:
raise OSError("response headers too large")
if b"\r\n\r\n" not in buf:
raise OSError("incomplete response headers")
head, rest = buf.split(b"\r\n\r\n", 1)
cl = None
for line in head.split(b"\r\n"):
if line.lower().startswith(b"content-length:"):
try:
cl = int(line.split(b":", 1)[1].strip())
except (ValueError, IndexError):
cl = None
if cl is None:
body = rest
else:
body = rest
while len(body) < cl:
chunk = sock.recv(min(2048, cl - len(body)))
if not chunk:
break
body += chunk
return json.loads(body.decode("utf-8"))
def http_driver_poll(host, port, payload_dict, timeout_s=40.0):
"""
POST ``/driver/v1/poll`` with JSON body; return parsed JSON (expects ``{"lines": [...]}``).
"""
path = "/driver/v1/poll"
body_bytes = json.dumps(payload_dict).encode("utf-8")
host_s = str(host)
req_head = (
"POST %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
% (path, host_s, len(body_bytes))
).encode("utf-8")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((host_s, int(port)))
_send_all(sock, req_head + body_bytes)
return _read_http_json_body(sock)
finally:
try:
sock.close()
except Exception:
pass

107
src/http_routes.py Normal file
View File

@@ -0,0 +1,107 @@
import json
from controller_messages import process_data
from microdot.websocket import WebSocketError, with_websocket
try:
import uos as os
except ImportError:
import os
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def register_routes(app, settings, presets, runtime_state):
@app.route("/ws")
@with_websocket
async def ws_handler(request, ws):
runtime_state.ws_connected()
controller_ip = None
try:
client_addr = getattr(request, "client_addr", None)
if isinstance(client_addr, (tuple, list)) and client_addr:
controller_ip = client_addr[0]
elif isinstance(client_addr, str):
controller_ip = client_addr
except Exception:
controller_ip = None
try:
while True:
data = await ws.receive()
if not data:
break
process_data(data, settings, presets, controller_ip=controller_ip)
except WebSocketError as e:
print("WS client disconnected:", e)
except OSError as e:
print("WS client dropped (OSError):", e)
finally:
runtime_state.ws_disconnected()
@app.post("/patterns/upload")
async def upload_pattern(request):
"""Receive one pattern file body from led-controller and reload patterns."""
raw_name = request.args.get("name")
reload_raw = request.args.get("reload", "1")
reload_patterns = str(reload_raw).strip().lower() not in ("0", "false", "no", "off")
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {"Content-Type": "application/json"}
body = request.body
if not isinstance(body, (bytes, bytearray)) or not body:
return json.dumps({"error": "code is required"}), 400, {"Content-Type": "application/json"}
try:
code = body.decode("utf-8")
except UnicodeError:
return json.dumps({"error": "body must be utf-8 text"}), 400, {"Content-Type": "application/json"}
if not code.strip():
return json.dumps({"error": "code is required"}), 400, {"Content-Type": "application/json"}
name = raw_name.strip()
if not name.endswith(".py"):
name += ".py"
if not _safe_pattern_filename(name) or name in ("__init__.py", "main.py"):
return json.dumps({"error": "invalid pattern filename"}), 400, {"Content-Type": "application/json"}
try:
os.mkdir("patterns")
except OSError:
pass
path = "patterns/" + name
try:
with open(path, "w") as f:
f.write(code)
if reload_patterns:
presets.reload_patterns()
except OSError as e:
print("patterns/upload failed:", e)
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return json.dumps(
{
"message": "pattern uploaded",
"name": name,
"reloaded": reload_patterns,
}
), 201, {"Content-Type": "application/json"}
@app.post("/presets/upload")
async def upload_presets(request):
"""Receive v1 JSON with ``presets`` and apply/save on the driver."""
body = request.body
if not isinstance(body, (bytes, bytearray)) or not body:
return json.dumps({"error": "body is required"}), 400, {"Content-Type": "application/json"}
try:
process_data(body, settings, presets)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return json.dumps({"message": "presets applied"}), 200, {"Content-Type": "application/json"}

View File

@@ -1,390 +1,70 @@
import print_timestamp # noqa: F401
from settings import Settings
from machine import WDT
import utime
import network
from presets import Presets
from utils import convert_and_reorder_colors
import machine
import asyncio
import gc
import json
import time
import select
import socket
import ubinascii
from hello import discover_controller_udp
try:
import uos as os
except ImportError:
import os
import network
import espnow
import device_groups as dg
from presets import Presets
from controller_messages import apply_startup_pattern, process_data
from espnow_transport import _handle_packet, init_espnow
from espnow_wire import BROADCAST_MAC, WIRE_MAGIC
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
CONTROLLER_TCP_PORT = 8765
controller_ip = None
wdt = machine.WDT(timeout=10000)
wdt.feed()
machine.freq(160000000)
settings = Settings()
dg.load_from_settings(settings)
print(settings)
gc.collect()
presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
presets.b = settings.get("brightness", 255)
default_preset = settings.get("default", "")
if default_preset and default_preset in presets.presets:
presets.select(default_preset)
print(f"Selected startup preset: {default_preset}")
presets.debug = bool(settings.get("debug", False))
gc.collect()
wdt = WDT(timeout=10000)
wdt.feed()
apply_startup_pattern(settings, presets)
esp = init_espnow(settings)
print(network.WLAN(network.STA_IF).config("channel"))
# --- Controller JSON (bytes or str): parse v1, then apply -------------------------
def process_data(payload):
"""Read one controller message; json.loads (bytes or str), then apply fields."""
try:
data = json.loads(payload)
print(payload)
if data.get("v", "") != "1":
return
except (ValueError, TypeError):
return
if "b" in data:
apply_brightness(data)
if "presets" in data:
apply_presets(data)
if "select" in data:
apply_select(data)
if "default" in data:
apply_default(data)
if "manifest" in data:
apply_patterns_ota(data)
if "save" in data and ("presets" in data or "default" in data):
presets.save()
def apply_brightness(data):
try:
presets.b = max(0, min(255, int(data["b"])))
settings["brightness"] = presets.b
except (TypeError, ValueError):
pass
def apply_presets(data):
presets_map = data["presets"]
for id, preset_data in presets_map.items():
if not preset_data:
continue
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
if color_key is not None:
try:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], settings
)
except (TypeError, ValueError, KeyError):
continue
presets.edit(id, preset_data)
print(f"Edited preset {id}: {preset_data.get('name', '')}")
def apply_select(data):
select_map = data["select"]
device_name = settings["name"]
select_list = select_map.get(device_name, [])
if not select_list:
return
preset_name = select_list[0]
step = select_list[1] if len(select_list) > 1 else None
presets.select(preset_name, step=step)
def apply_default(data):
targets = data.get("targets") or []
default_name = data["default"]
if (
settings["name"] in targets
and isinstance(default_name, str)
and default_name in presets.presets
):
settings["default"] = default_name
def _parse_http_url(url):
"""Parse http://host[:port]/path into (host, port, path)."""
if not isinstance(url, str):
raise ValueError("url must be a string")
if not url.startswith("http://"):
raise ValueError("only http:// URLs are supported")
remainder = url[7:]
slash_idx = remainder.find("/")
if slash_idx == -1:
host_port = remainder
path = "/"
else:
host_port = remainder[:slash_idx]
path = remainder[slash_idx:]
if ":" in host_port:
host, port_s = host_port.rsplit(":", 1)
port = int(port_s)
else:
host = host_port
port = 80
if not host:
raise ValueError("missing host")
return host, port, path
def _http_get_raw(url, timeout_s=10.0):
host, port, path = _parse_http_url(url)
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
).encode("utf-8")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((host, int(port)))
sock.send(req)
data = b""
while True:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
finally:
try:
sock.close()
except Exception:
pass
sep = b"\r\n\r\n"
if sep not in data:
raise OSError("invalid HTTP response")
head, body = data.split(sep, 1)
status_line = head.split(b"\r\n", 1)[0]
if b" 200 " not in status_line:
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
return body
def _http_get_json(url, timeout_s=10.0):
body = _http_get_raw(url, timeout_s=timeout_s)
return json.loads(body.decode("utf-8"))
def _http_get_text(url, timeout_s=10.0):
global controller_ip
# Support relative URLs from controller messages.
if isinstance(url, str) and url.startswith("/"):
if not controller_ip:
raise OSError("controller IP unavailable for relative URL")
url = "http://%s%s" % (controller_ip, url)
try:
body = _http_get_raw(url, timeout_s=timeout_s)
return body.decode("utf-8")
except Exception:
# Fallback for mDNS/unresolvable host: retry against current controller IP.
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
raise
_host, _port, path = _parse_http_url(url)
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
body = _http_get_raw(fallback, timeout_s=timeout_s)
return body.decode("utf-8")
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def apply_patterns_ota(data):
manifest_payload = data.get("manifest")
if not manifest_payload:
return
try:
if isinstance(manifest_payload, dict):
manifest = manifest_payload
elif isinstance(manifest_payload, str):
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
else:
print("patterns_ota: invalid manifest payload type")
return
files = manifest.get("files", [])
if not isinstance(files, list) or not files:
print("patterns_ota: no files in manifest")
return
try:
os.mkdir("patterns")
except OSError:
pass
updated = 0
for item in files:
if not isinstance(item, dict):
continue
name = item.get("name")
url = item.get("url")
inline_code = item.get("code")
if not _safe_pattern_filename(name):
continue
if isinstance(inline_code, str):
code = inline_code
elif isinstance(url, str):
code = _http_get_text(url, timeout_s=20.0)
else:
continue
with open("patterns/" + name, "w") as f:
f.write(code)
updated += 1
if updated > 0:
presets.reload_patterns()
print("patterns_ota: updated", updated, "pattern file(s)")
else:
print("patterns_ota: no valid files downloaded")
except Exception as e:
print("patterns_ota failed:", e)
# --- TCP framing (bytes) → process_data -------------------------------------------
def tcp_append_and_drain_lines(buf, chunk):
"""Return (new_buf, list of non-empty stripped line byte strings)."""
buf += chunk
lines = []
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
line = line.strip()
if line:
lines.append(line)
return buf, lines
# --- Network + hello --------------------------------------------------------------
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
sta_if.config(pm=network.WLAN.PM_NONE)
mac = sta_if.config("mac")
hello_payload = {
hello = json.dumps({
"v": "1",
"device_name": settings.get("name", ""),
"mac": ubinascii.hexlify(mac).decode().lower(),
"name": settings.get("name", "led"),
"type": "led",
}
hello_bytes = json.dumps(hello_payload).encode("utf-8")
})
print(hello)
if settings["transport_type"] == "espnow":
from espnow import ESPNow # import only in this branch (avoids load when using Wi-Fi)
esp.send(BROADCAST_MAC, hello)
print("espnow hello", len(hello), "B")
sta_if.disconnect()
sta_if.config(channel=settings.get("wifi_channel", 1))
e = ESPNow()
e.active(True)
e.add_peer(BROADCAST_MAC)
e.add_peer(mac)
e.send(BROADCAST_MAC, hello_bytes)
while True:
if e.any():
_peer, msg = e.recv()
if msg:
process_data(msg)
presets.tick()
wdt.feed()
elif settings["transport_type"] == "wifi":
sta_if.connect(settings["ssid"], settings["password"])
while not sta_if.isconnected():
time.sleep(1)
print(f"WiFi connected {sta_if.ifconfig()[0]}")
controller_ip = discover_controller_udp(
device_name=settings.get("name", ""),
wdt=wdt,
)
if not controller_ip:
raise SystemExit("No controller IP discovered for Wi-Fi transport")
def _on_espnow_message(host, msg):
if not msg:
return
if msg[0] == WIRE_MAGIC:
_handle_packet(host, msg, settings, presets)
return
if msg[0:1] == b"{":
process_data(msg, settings, presets)
def pick_controller_ip(current):
ip = discover_controller_udp(
device_name=settings.get("name", ""),
wdt=wdt,
)
if ip and ip != current:
print("Controller IP updated to", ip)
return ip if ip else current
reconnect_ms = 1000
next_connect_at = 0
client = None
poller = None
buf = b""
while True:
now = utime.ticks_ms()
if client is None and utime.ticks_diff(now, next_connect_at) >= 0:
c = None
try:
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
c.connect((controller_ip, CONTROLLER_TCP_PORT))
c.setblocking(False)
p = select.poll()
p.register(c, select.POLLIN)
client = c
poller = p
buf = b""
print("TCP connected")
except Exception:
if c is not None:
try:
c.close()
except Exception:
pass
controller_ip = pick_controller_ip(controller_ip)
next_connect_at = utime.ticks_add(now, reconnect_ms)
if client is not None and poller is not None:
try:
events = poller.poll(0)
except Exception:
events = []
reconnect_needed = False
for fd, event in events:
if (event & select.POLLHUP) or (event & select.POLLERR):
reconnect_needed = True
break
if event & select.POLLIN:
try:
chunk = client.recv(512)
except OSError:
reconnect_needed = True
break
if not chunk:
reconnect_needed = True
break
buf, lines = tcp_append_and_drain_lines(buf, chunk)
for raw_line in lines:
process_data(raw_line)
if reconnect_needed:
print("TCP disconnected, reconnecting...")
try:
poller.unregister(client)
except Exception:
pass
try:
client.close()
except Exception:
pass
client = None
poller = None
buf = b""
controller_ip = pick_controller_ip(controller_ip)
next_connect_at = utime.ticks_add(now, reconnect_ms)
presets.tick()
wdt.feed()
while True:
wdt.feed()
while esp.any():
host, msg = esp.recv(0)
if not host or not msg:
continue
print(host, len(msg), "B")
try:
_on_espnow_message(host, msg)
print(msg)
except Exception as e:
print("espnow rx error:", e)
presets.tick()

34
src/mem_stats.py Normal file
View File

@@ -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())

View File

@@ -1,6 +1,5 @@
from .blink import Blink
from .rainbow import Rainbow
from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle
"""Pattern modules are registered only via Presets._load_dynamic_patterns().
This file is ignored as a pattern (see presets.py). Keep it free of imports so
adding a pattern does not require editing this package.
"""

95
src/patterns/aurora.py Normal file
View File

@@ -0,0 +1,95 @@
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_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
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):
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 = max(0, min(255, w + shimmer))
self.driver.n[self.driver.led_i(preset, i)] = (
(c[0] * w) // 255,
(c[1] * w) // 255,
(c[2] * w) // 255,
)
self.driver.n.write()
phase = (phase + self.driver.signed(preset, 1)) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
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[self.driver.led_i(preset, i)] = lerp3(bg, peak, mixf)
self.driver.n.write()
phase = (phase + self.driver.signed(preset, 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)

29
src/patterns/bar_graph.py Normal file
View File

@@ -0,0 +1,29 @@
import utime
class BarGraph:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(0, 255, 0), (255, 80, 0)]
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:
level = max(0, min(100, int(preset.n1) if int(preset.n1) >= 0 else 50))
target = (self.driver.num_leds * level) // 100
lit = self.driver.apply_brightness(colors[0], preset.b)
unlit = self.driver.apply_brightness(
preset.background_or(colors),
preset.b,
)
for i in range(self.driver.num_leds):
self.driver.n[i] = lit if i < target else unlit
self.driver.n.write()
last_update = utime.ticks_add(last_update, delay_ms)
if not preset.a:
yield
return
yield

View File

@@ -9,6 +9,7 @@ class Blink:
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
# Use provided colors, or default to white if none
colors = preset.c if preset.c else [(255, 255, 255)]
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
color_index = 0
state = True # True = on, False = off
last_update = utime.ticks_ms()
@@ -25,9 +26,9 @@ class Blink:
# Advance to next color for the next "on" phase
color_index += 1
else:
# "Off" phase: turn all LEDs off
self.driver.fill((0, 0, 0))
# Inactive phase uses the preset background color.
self.driver.fill(bg_color)
state = not state
last_update = current_time
last_update = utime.ticks_add(last_update, delay_ms)
# Yield once per tick so other logic can run
yield

67
src/patterns/blizzard.py Normal file
View File

@@ -0,0 +1,67 @@
import random
import utime
class Blizzard:
"""Dense falling flakes with sideways drift (compare `snowfall` for gentler flakes)."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (200, 230, 255), (180, 210, 255)]
# Higher n1 → more spawns (0255 threshold vs random)
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 90)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
# n3: 128 = no bias; <128 drift one way, >128 the other (scaled to small steps)
wraw = int(preset.n3)
if wraw <= 0:
wind = 0
else:
wind = max(-4, min(4, (wraw - 128) // 20))
flakes = []
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
nled = self.driver.num_leds
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(nled):
self.driver.n[i] = bg
if random.randint(0, 255) < density:
flakes.append(
[
nled - 1,
random.randint(0, len(colors) - 1),
0 if wind == 0 else random.randint(-1, 1),
]
)
nf = []
for pos, ci, wj in flakes:
p = pos
lateral = wind + (wj if wj else 0)
p -= self.driver.signed(preset, speed)
p += self.driver.signed(preset, lateral)
if p < -2 or p >= nled + 2:
continue
pi = max(0, min(nled - 1, int(p)))
self.driver.n[self.driver.led_i(preset, pi)] = self.driver.apply_brightness(
colors[ci], preset.b
)
nf.append([p, ci, wj])
flakes = nf
self.driver.n.write()
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,56 @@
import random
import utime
class CandleGlow:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 140, 40), (255, 200, 120), (255, 90, 20)]
n_candles = max(1, min(self.driver.num_leds, int(preset.n1) if int(preset.n1) > 0 else 4))
width = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
flicker = max(1, min(255, int(preset.n3) if int(preset.n3) > 0 else 90))
n_led = self.driver.num_leds
centers = tuple(random.randint(0, max(0, n_led - 1)) for _ in range(n_candles))
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(n_led):
self.driver.n[i] = bg
base_lo = 180 - flicker // 2
if base_lo < 40:
base_lo = 40
for ci, c in enumerate(centers):
warmth = colors[ci % len(colors)]
pulse = base_lo + random.randint(0, flicker)
if pulse > 255:
pulse = 255
for off in range(-width, width + 1):
idx = c + off
if 0 <= idx < n_led:
dist = abs(off)
fall = ((width - dist + 1) * 256) // (width + 1)
fac = (fall * pulse) // 256
px = (
(warmth[0] * fac) // 255,
(warmth[1] * fac) // 255,
(warmth[2] * fac) // 255,
)
lit = self.driver.apply_brightness(px, 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]),
)
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -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, abs(self.driver.signed(preset, int(preset.n3) if int(preset.n3) > 0 else 1)))
phase = self.driver.step % (on_len + off_len)
last = utime.ticks_ms()
while True:
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[self.driver.led_i(preset, i)] = c if m < on_len else bg_color
self.driver.n.write()
phase = (phase + self.driver.signed(preset, step)) % (on_len + off_len)
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
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
@@ -26,16 +62,17 @@ class Chase:
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
n3 = self.driver.signed(preset, int(preset.n3)) # Step movement on even steps
n4 = self.driver.signed(preset, int(preset.n4)) # Step movement on odd steps
segment_length = n1 + n2
# Calculate position from step_count
step_count = self.driver.step
step_count = int(self.driver.step) % 2
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
@@ -53,7 +90,7 @@ class Chase:
# If auto is False, run a single step and then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
self.driver.n.fill(bg_color)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
@@ -64,14 +101,15 @@ class Chase:
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
self.driver.n[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[i] = color1
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)
# Increment step for next beat
self.driver.step = step_count + 1
self.driver.step = (step_count + 1) % 2
# Allow tick() to advance the generator once
yield
@@ -98,7 +136,7 @@ class Chase:
position += max_pos
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
self.driver.n.fill(bg_color)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
@@ -109,16 +147,18 @@ class Chase:
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
self.driver.n[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[i] = color1
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)
# Increment step
step_count += 1
step_count = (step_count + 1) % 2
self.driver.step = step_count
last_update = current_time
last_update = utime.ticks_add(last_update, transition_duration)
transition_duration = max(10, int(preset.d))
# Yield once per tick so other logic can run
yield

View File

@@ -31,10 +31,10 @@ class Circle:
base0 = base1 = (255, 255, 255)
elif len(colors) == 1:
base0 = colors[0]
base1 = (0, 0, 0)
base1 = preset.background_or(colors)
else:
base0 = colors[0]
base1 = colors[1]
base1 = preset.background_or(colors)
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b)
@@ -46,7 +46,7 @@ class Circle:
if phase == "off":
self.driver.n.fill(color1)
else:
self.driver.n.fill((0, 0, 0))
self.driver.n.fill(color1)
# Calculate segment length
segment_length = (head - tail) % self.driver.num_leds
@@ -62,7 +62,9 @@ class Circle:
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.driver.num_leds
last_head_move = current_time
last_head_move = utime.ticks_add(last_head_move, head_delay)
head_rate = max(1, int(preset.n1))
head_delay = 1000 // head_rate
# Tail behavior based on phase
if phase == "growing":
@@ -73,7 +75,9 @@ class Circle:
# Shrinking phase: move tail forward at n3 LEDs per second
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
tail = (tail + 1) % self.driver.num_leds
last_tail_move = current_time
last_tail_move = utime.ticks_add(last_tail_move, tail_delay)
tail_rate = max(1, int(preset.n3))
tail_delay = 1000 // tail_rate
# Check if we've reached min length
current_length = (head - tail) % self.driver.num_leds

View File

@@ -0,0 +1,33 @@
import utime
class ClockSweep:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (60, 60, 60)]
width = max(1, int(preset.n1) if int(preset.n1) > 0 else 1)
marker = max(0, int(preset.n2) if int(preset.n2) > 0 else 0)
pos = self.driver.step % max(1, self.driver.num_leds)
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)
fg = self.driver.apply_brightness(colors[0], preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg
if marker > 0 and i % marker == 0:
self.driver.n[i] = ((bg[0]*2)//3, (bg[1]*2)//3, (bg[2]*2)//3)
for w in range(width):
self.driver.n[(pos + w) % self.driver.num_leds] = fg
self.driver.n.write()
pos = (pos + 1) % max(1, self.driver.num_leds)
self.driver.step = pos
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,100 @@
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 _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, preset, 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[self.driver.led_i(preset, i)] = self.driver.apply_brightness(
blended, brightness
)
self.driver.n.write()
def _render_rainbow(self, preset, phase, brightness):
num_leds = self.driver.num_leds
for i in range(num_leds):
rc_index = (i * 256 // max(1, num_leds)) + phase
self.driver.n[self.driver.led_i(preset, 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(preset, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, 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(preset, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
if not preset.a:
self._render_gradient(preset, colors, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, 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_gradient(preset, colors, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield

210
src/patterns/flame.py Normal file
View File

@@ -0,0 +1,210 @@
import random
import utime
# Default warm palette: ember → orange → yellow → pale hot (RGB)
_DEFAULT_PALETTE = (
(90, 8, 8),
(200, 40, 12),
(255, 120, 30),
(255, 220, 140),
)
def _clamp(x, lo, hi):
if x < lo:
return lo
if x > hi:
return hi
return x
def _lerp_chan(a, b, t):
return a + ((b - a) * t >> 8)
def _lerp_rgb(c0, c1, t):
return (
_lerp_chan(c0[0], c1[0], t),
_lerp_chan(c0[1], c1[1], t),
_lerp_chan(c0[2], c1[2], t),
)
def _palette_sample(palette, pos256):
n = len(palette)
if n == 0:
return (255, 160, 60)
if n == 1:
return palette[0]
span = (n - 1) * pos256
seg = span >> 8
if seg >= n - 1:
return palette[n - 1]
frac = span & 0xFF
return _lerp_rgb(palette[seg], palette[seg + 1], frac)
def _triangle_255(elapsed_ms, period_ms):
period_ms = max(period_ms, 400)
p = elapsed_ms % period_ms
half = period_ms >> 1
if half <= 0:
return 128
if p < half:
return (p * 255) // half
return ((period_ms - p) * 255) // (period_ms - half)
class Flame:
def __init__(self, driver):
self.driver = driver
def _build_palette(self, preset):
colors = preset.c
if not colors:
return list(_DEFAULT_PALETTE)
out = []
for c in colors:
if isinstance(c, (list, tuple)) and len(c) == 3:
out.append(
(
_clamp(int(c[0]), 0, 255),
_clamp(int(c[1]), 0, 255),
_clamp(int(c[2]), 0, 255),
)
)
return out if out else list(_DEFAULT_PALETTE)
def _draw_frame(self, preset, palette, ticks_now, breath_el_ms, rise, cluster_jit, breath_ms, lo, hi, spark_state):
"""spark_state: (active: bool, start_ticks, duration_ms). ticks_now for sparks; breath_el_ms for slow wave."""
num = self.driver.num_leds
denom = num - 1 if num > 1 else 1
breathe = _triangle_255(breath_el_ms, breath_ms)
base_level = lo + (((hi - lo) * breathe) >> 8)
micro = 232 + random.randint(0, 35)
level = (base_level * micro) >> 8
level = _clamp(level, lo, hi)
spark_boost = 0
spark_white = (0, 0, 0)
active, s0, dur = spark_state
if active and dur > 0:
el = utime.ticks_diff(ticks_now, s0)
if el < 0:
el = 0
if el >= dur:
spark_boost = 0
else:
env = 255 - ((el * 255) // dur)
spark_boost = (env * 90) >> 8
spark_white = ((env * 55) >> 8, (env * 50) >> 8, (env * 40) >> 8)
for i in range(num):
h = (i * 256) // denom
flow = (h + rise + ((i // max(1, num >> 3)) * 17)) & 255
pos = (flow + cluster_jit[(i >> 2) & 7]) & 255
rgb = _palette_sample(palette, pos)
if spark_boost:
rgb = (
_clamp(rgb[0] + spark_white[0] + (spark_boost * 3 >> 2), 0, 255),
_clamp(rgb[1] + spark_white[1] + (spark_boost >> 1), 0, 255),
_clamp(rgb[2] + spark_white[2] + (spark_boost >> 2), 0, 255),
)
self.driver.n[i] = self.driver.apply_brightness(rgb, level)
self.driver.n.write()
def run(self, preset):
"""Salt-lamp / hearth-style flame: warm gradient, breathing, jitter, drift, rare sparks."""
palette = self._build_palette(preset)
lo = max(0, min(255, int(preset.n1)))
hi = max(0, min(255, int(preset.b)))
if lo > hi:
lo, hi = hi, lo
bp = int(preset.n2)
breath_ms = max(800, bp if bp > 0 else 2500)
gap_lo = int(preset.n3)
gap_hi = int(preset.n4)
# n3 < 0 disables sparks; n3=n4=0 uses ~1030 s gaps (hearth pops).
if gap_lo < 0:
sparks_on = False
else:
sparks_on = True
if gap_lo == 0 and gap_hi == 0:
gap_lo, gap_hi = 10000, 30000
else:
gap_lo = max(gap_lo, 500)
if gap_hi < gap_lo:
gap_hi = gap_lo
delay_ms = max(16, int(preset.d))
rise = random.randint(0, 255)
cluster_jit = [random.randint(-18, 18) for _ in range(8)]
last_draw = utime.ticks_ms()
breath_origin = last_draw
last_cluster = last_draw
spark_active = False
spark_start = 0
spark_dur = 0
next_spark = utime.ticks_add(last_draw, random.randint(gap_lo, gap_hi)) if sparks_on else 0
if not preset.a:
now = utime.ticks_ms()
self._draw_frame(
preset,
palette,
now,
utime.ticks_diff(now, breath_origin),
rise,
cluster_jit,
breath_ms,
lo,
hi,
(False, 0, 0),
)
yield
return
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_draw) < delay_ms:
yield
continue
last_draw = utime.ticks_add(last_draw, delay_ms)
rise = (rise + random.randint(-10, 12)) & 255
if utime.ticks_diff(now, last_cluster) >= (delay_ms * 4):
last_cluster = now
cluster_jit = [random.randint(-18, 18) for _ in range(8)]
spark_state = (spark_active, spark_start, spark_dur)
if sparks_on:
if spark_active:
if utime.ticks_diff(now, spark_start) >= spark_dur:
spark_active = False
next_spark = utime.ticks_add(
now,
random.randint(gap_lo, gap_hi),
)
elif utime.ticks_diff(now, next_spark) >= 0:
spark_active = True
spark_start = now
spark_dur = random.randint(180, 360)
self._draw_frame(
preset,
palette,
now,
utime.ticks_diff(now, breath_origin),
rise,
cluster_jit,
breath_ms,
lo,
hi,
(spark_active, spark_start, spark_dur),
)
yield

40
src/patterns/flicker.py Normal file
View File

@@ -0,0 +1,40 @@
import random
import utime
class Flicker:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Random brightness between n1 (min) and b (max); delay d ms between updates."""
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
last_update = utime.ticks_ms()
def brightness_bounds():
lo = max(0, min(255, int(preset.n1)))
hi = max(0, min(255, int(preset.b)))
if lo > hi:
lo, hi = hi, lo
return lo, hi
if not preset.a:
lo, hi = brightness_bounds()
level = random.randint(lo, hi)
base = colors[color_index % len(colors)]
self.driver.fill(self.driver.apply_brightness(base, level))
yield
return
while True:
current_time = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
lo, hi = brightness_bounds()
if utime.ticks_diff(current_time, last_update) >= delay_ms:
level = random.randint(lo, hi)
base = colors[color_index % len(colors)]
self.driver.fill(self.driver.apply_brightness(base, level))
color_index += 1
last_update = utime.ticks_add(last_update, delay_ms)
yield

62
src/patterns/icicles.py Normal file
View File

@@ -0,0 +1,62 @@
import utime
class Icicles:
"""Icicles hanging from anchor points; tips brighten toward max length then shrink."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(240, 248, 255), (160, 210, 255), (255, 255, 255)]
spacing = max(1, int(preset.n1) if int(preset.n1) > 0 else 12)
nled = self.driver.num_leds
max_len = max(
2,
min(
int(preset.n2) if int(preset.n2) > 0 else min(14, max(3, nled // 4)),
max(2, nled),
),
)
span = max_len * 2
phase_step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
phase = 0
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
bg_rgb = preset.background_or(colors)
bg = self.driver.apply_brightness(bg_rgb, preset.b)
for i in range(nled):
self.driver.n[i] = bg
aidx = 0
for anchor in range(0, nled, spacing):
tri_i = (phase + aidx * 5) % span
ic_len = tri_i if tri_i <= max_len else span - tri_i
tip_c = colors[aidx % len(colors)]
tip = self.driver.apply_brightness(tip_c, preset.b)
for k in range(ic_len):
idx = anchor + k
if idx >= nled:
break
br = ((k + 1) * 255) // max(1, ic_len)
self.driver.n[self.driver.led_i(preset, idx)] = (
(tip[0] * br + bg[0] * (255 - br)) // 255,
(tip[1] * br + bg[1] * (255 - br)) // 255,
(tip[2] * br + bg[2] * (255 - br)) // 255,
)
aidx += 1
self.driver.n.write()
phase = (phase + self.driver.signed(preset, phase_step)) % span
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

176
src/patterns/meteor.py Normal file
View File

@@ -0,0 +1,176 @@
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[self.driver.led_i(preset, head)] = lit
self.driver.n.write()
head += self.driver.signed(preset, 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[self.driver.led_i(preset, i1)] = (
(c1[0] * s) // 255,
(c1[1] * s) // 255,
(c1[2] * s) // 255,
)
i2 = p2 + t
if 0 <= i2 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[self.driver.led_i(preset, i2)] = (
(c2[0] * s) // 255,
(c2[1] * s) // 255,
(c2[2] * s) // 255,
)
self.driver.n.write()
p1 += self.driver.signed(preset, speed)
p2 -= self.driver.signed(preset, speed)
if p1 - tail > self.driver.num_leds and p2 + tail < 0:
p1 = 0
p2 = self.driver.num_leds - 1 - gap
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[self.driver.led_i(preset, i)] = bg_color
else:
scale = ((width - dist) * 255) // max(1, width)
self.driver.n[self.driver.led_i(preset, 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 += self.driver.signed(preset, 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))
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
p1, p2 = nled - 1, gap
else:
p1, p2 = 0, nled - 1 - gap
last = utime.ticks_ms()
while True:
p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last)
if stepped and not preset.a:
yield
return
yield
if mode == 2:
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, center, direction, pause_frames = 0, max(0, nled - 1), -1, 0
else:
color_index, center, direction, pause_frames = 0, 0, 1, 0
last_update = utime.ticks_ms()
while True:
color_index, center, direction, pause_frames, last_update, stepped = (
self._run_scanner(
preset, colors, color_index, center, direction, pause_frames, last_update
)
)
if stepped and not preset.a:
yield
return
yield
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, head, direction = 0, max(0, nled - 1), -1
else:
color_index, head, direction = 0, 0, 1
last_update = utime.ticks_ms()
while True:
color_index, head, direction, last_update, stepped = self._run_meteor(
preset, colors, color_index, head, direction, last_update
)
if stepped and not preset.a:
yield
return
yield

31
src/patterns/orbit.py Normal file
View File

@@ -0,0 +1,31 @@
import utime
class Orbit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (0, 180, 255), (255, 0, 120)]
orbits = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
speed = 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:
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 k in range(orbits):
idx = ((phase * (k + 1)) // 8 + (k * self.driver.num_leds // max(1, orbits))) % max(1, self.driver.num_leds)
self.driver.n[idx] = self.driver.apply_brightness(colors[k % len(colors)], preset.b)
self.driver.n.write()
phase = (phase + speed) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,81 @@
import utime
class PaletteMorph:
def __init__(self, driver):
self.driver = driver
def _blend(self, c1, c2, t):
return (
c1[0] + ((c2[0] - c1[0]) * t) // 255,
c1[1] + ((c2[1] - c1[1]) * t) // 255,
c1[2] + ((c2[2] - c1[2]) * t) // 255,
)
def run(self, preset):
"""Living color field (non-scrolling palette warp).
Different from `colour_cycle`: this does not scroll a fixed gradient.
Instead, each LED breathes/warps through the palette with local phase
offsets so the strip looks alive.
n1: morph duration (ms)
n2: warp rate
n3: spatial turbulence amount
"""
colors = preset.c if preset.c else [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
if len(colors) < 2:
while True:
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
yield
morph = max(50, int(preset.n1) if int(preset.n1) > 0 else 1200)
warp_rate = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
turbulence = max(1, int(preset.n3) if int(preset.n3) > 0 else 24)
base_idx = 0
start = utime.ticks_ms()
phase = self.driver.step % 256
last_update = start
while True:
now = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = utime.ticks_add(last_update, delay_ms)
age = utime.ticks_diff(now, start)
if age < morph:
t = (age * 255) // morph
else:
t = 255
# Global morph anchor between neighboring palette colors.
a = colors[base_idx % len(colors)]
b = colors[(base_idx + 1) % len(colors)]
anchor = self._blend(a, b, t)
for i in range(self.driver.num_leds):
# Non-linear local warp per LED to create "living" motion.
pos = (i * 256) // max(1, self.driver.num_leds)
wobble = ((pos * turbulence) // 32 + phase + (t // 2)) & 255
breath = 255 - abs(128 - wobble) * 2
local = (pos + (breath // 3) + (t // 4)) % 256
idx = (base_idx + ((local * len(colors)) // 256)) % len(colors)
frac = (local * len(colors)) & 255
c1 = colors[idx]
c2 = colors[(idx + 1) % len(colors)]
grad = self._blend(c1, c2, frac)
# Blend with anchor to keep coherent palette morphing.
out = self._blend(grad, anchor, 80)
self.driver.n[i] = self.driver.apply_brightness(out, preset.b)
self.driver.n.write()
if age >= morph:
base_idx = (base_idx + 1) % len(colors)
start = now
if not preset.a:
yield
return
phase = (phase + warp_rate) & 255
self.driver.step = phase
yield

111
src/patterns/particles.py Normal file
View File

@@ -0,0 +1,111 @@
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, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 1)))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
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[self.driver.led_i(preset, pos)] = self.driver.apply_brightness(
colors[ci], preset.b
)
pos -= self.driver.signed(preset, speed)
if pos >= -1:
nf.append([pos, ci])
self.driver.n.write()
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, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 2)))
tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10)
max_stars = 4
d = max(1, int(preset.d))
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)
pix = self.driver.led_i(preset, idx)
o = self.driver.n[pix]
self.driver.n[pix] = (
max(o[0], lit[0]),
max(o[1], lit[1]),
max(o[2], lit[2]),
)
h -= self.driver.signed(preset, 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

View File

@@ -0,0 +1,19 @@
"""Strip install direction: n5 bit 0 reverses along-strip motion (upside-down wiring)."""
def is_reversed(preset):
return bool(int(getattr(preset, "n5", 0) or 0) & 1)
def led_i(driver, preset, logical_index):
"""Map a logical strip index (0 = pattern start) to a physical pixel index."""
n = int(driver.num_leds)
i = int(logical_index)
if 0 <= i < n and is_reversed(preset):
return n - 1 - i
return i
def signed(preset, value):
v = int(value)
return -v if is_reversed(preset) else v

View File

@@ -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

39
src/patterns/plasma.py Normal file
View File

@@ -0,0 +1,39 @@
import utime
class Plasma:
def __init__(self, driver):
self.driver = driver
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 run(self, preset):
scale = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
contrast = max(1, int(preset.n3) if int(preset.n3) > 0 else 2)
t = 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:
for i in range(self.driver.num_leds):
v = ((i * scale + t) & 255)
v2 = (((i * scale // max(1, contrast)) - (t * 2)) & 255)
c = self._wheel((v + v2) & 255)
self.driver.n[i] = self.driver.apply_brightness(c, preset.b)
self.driver.n.write()
t = (t + speed) % 256
self.driver.step = t
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -6,23 +6,25 @@ class Pulse:
self.driver = driver
def run(self, preset):
self.driver.off()
# Get colors from preset
colors = preset.c
if not colors:
colors = [(255, 255, 255)]
bg_base = preset.background_or(colors)
self.driver.fill(self.driver.apply_brightness(bg_base, preset.b))
color_index = 0
manual = not preset.a
color_index = self.driver.step % max(1, len(colors))
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
bg_color = self.driver.apply_brightness(bg_base, preset.b)
# Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.d))
delay_ms = 0 if manual else max(0, int(preset.d))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
@@ -49,14 +51,16 @@ class Pulse:
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.driver.fill((0, 0, 0))
self.driver.fill(bg_color)
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not preset.a:
# End of cycle: advance colour for the next run, then loop or stop.
nclr = max(1, len(colors))
color_index = (color_index + 1) % nclr
self.driver.step = color_index
if manual:
self.driver.fill(bg_color)
break
# Skip drawing this tick, start next cycle
cycle_start = now
yield
continue

172
src/patterns/radiate.py Normal file
View File

@@ -0,0 +1,172 @@
import utime
# When ``driver.debug`` is True (``settings["debug"]``), log at most this often (ms).
_RADIATE_DBG_INTERVAL_MS = 2500
class Radiate:
def __init__(self, driver):
self.driver = driver
self._color_step = 0
def run(self, preset):
"""Radiate from nodes every n1 LEDs, retriggering every delay (d).
- n1: node spacing in LEDs
- n2: outbound travel time in ms
- n3: return travel time in ms
- d: retrigger interval in ms
"""
colors = preset.c if preset.c else [(255, 255, 255)]
base_off = preset.background_or(colors)
spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
max_dist = spacing // 2
lit_color = self.driver.apply_brightness(colors[self._color_step % max(1, len(colors))], preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
now = utime.ticks_ms()
last_trigger = now
active_pulses = [now]
last_dbg = now
dbg_banner = False
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))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
max_dist = spacing // 2
on_color = colors[self._color_step % max(1, len(colors))]
lit_color = self.driver.apply_brightness(on_color, preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
pulse_lifetime = outward_ms + return_ms
now = utime.ticks_ms()
age = utime.ticks_diff(now, cycle_start)
if age < 1:
age = 1
if age <= outward_ms:
front = (age * max_dist + outward_ms - 1) // outward_ms
elif age <= outward_ms + return_ms:
back_age = age - outward_ms
remaining = return_ms - back_age
front = (remaining * max_dist + return_ms - 1) // return_ms
else:
front = 0
lit_count = 0
for i in range(self.driver.num_leds):
offset = (i + (spacing // 2)) % spacing
dist = min(offset, spacing - offset)
lit = dist <= front
self.driver.n[i] = lit_color if lit else off_color
if lit:
lit_count += 1
self.driver.n.write()
if dbg:
if not dbg_banner:
dbg_banner = True
print(
"[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)
)
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:
self._color_step += 1
return
while True:
now = utime.ticks_ms()
dbg = bool(getattr(self.driver, "debug", False))
delay_ms = max(1, int(preset.d))
spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
pulse_lifetime = outward_ms + return_ms
max_dist = spacing // 2
on_color = colors[self._color_step % max(1, len(colors))]
lit_color = self.driver.apply_brightness(on_color, preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
if preset.a and utime.ticks_diff(now, last_trigger) >= delay_ms:
# Keep one pulse train at a time; replacing instead of appending
# prevents overlap from keeping color[0] continuously visible.
active_pulses = [now]
last_trigger = utime.ticks_add(last_trigger, delay_ms)
self._color_step += 1
# Drop pulses once their out-and-back lifetime ends.
kept = []
for start in active_pulses:
age = utime.ticks_diff(now, start)
if age < pulse_lifetime:
kept.append(start)
active_pulses = kept
lit_count = 0
for i in range(self.driver.num_leds):
# Nearest node distance for a repeating node grid every `spacing` LEDs.
offset = (i + (spacing // 2)) % spacing
dist = min(offset, spacing - offset)
lit = False
for start in active_pulses:
age = utime.ticks_diff(now, start)
# Auto: skip the exact trigger tick (age==0) so nodes are not stuck on.
if age <= 0:
continue
if age <= outward_ms:
# Integer-ceiling progression so peak can be reached even
# when tick timing skips the exact outward_ms boundary.
front = (age * max_dist + outward_ms - 1) // outward_ms
elif age <= outward_ms + return_ms:
back_age = age - outward_ms
remaining = return_ms - back_age
front = (remaining * max_dist + return_ms - 1) // return_ms
else:
continue
if dist <= front:
lit = True
break
self.driver.n[i] = lit_color if lit else off_color
if lit:
lit_count += 1
self.driver.n.write()
if dbg:
if not dbg_banner:
dbg_banner = True
print(
"[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)
)
pulse_age = -1
if active_pulses:
pulse_age = utime.ticks_diff(now, active_pulses[0])
if utime.ticks_diff(now, last_dbg) >= _RADIATE_DBG_INTERVAL_MS:
print(
"[radiate] pulses=%d first_age=%d lit=%d lifetime=%d"
% (len(active_pulses), pulse_age, lit_count, pulse_lifetime)
)
last_dbg = now
yield

View File

@@ -0,0 +1,41 @@
import random
import utime
class RainDrops:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(120, 180, 255)]
rate = max(1, int(preset.n1) if int(preset.n1) > 0 else 32)
width = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
drops = []
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
if random.randint(0, 255) < rate:
drops.append([random.randint(0, max(0, self.driver.num_leds - 1)), 0])
nd = []
for pos, age in drops:
for off in range(-width, width + 1):
idx = pos + off
if 0 <= idx < self.driver.num_leds:
s = 255 - min(255, abs(off) * 255 // max(1, width + 1) + age * 40)
base = self.driver.apply_brightness(colors[age % len(colors)], preset.b)
self.driver.n[idx] = ((base[0]*s)//255, (base[1]*s)//255, (base[2]*s)//255)
age += 1
if age < 8:
nd.append([pos, age])
drops = nd
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -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 = current_time
# Yield once per tick so other logic can run
yield

72
src/patterns/rime.py Normal file
View File

@@ -0,0 +1,72 @@
import random
import utime
class Rime:
"""Slow frost build-up on a chilly background — gentle random brightening then decay."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(220, 235, 255), (255, 255, 255), (185, 220, 255)]
num = self.driver.num_leds
if num <= 0:
while True:
yield
return
# n1: spawn tendency (like twinkle upper range)
chill = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 36))
# n2: decay per refresh (subtract from glow buffer)
melt = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 12))
# n3: how many LEDs can flash brighter per refresh (cap)
spark_cap = max(1, min(num, int(preset.n3) if int(preset.n3) > 0 else 3))
glow = [0] * num
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
base_bg = preset.background_or(colors)
bg = self.driver.apply_brightness(base_bg, preset.b)
for i in range(num):
if glow[i] > melt:
glow[i] -= melt
else:
glow[i] = 0
spawned = 0
tries = spark_cap + num // 8
for _ in range(tries):
if spawned >= spark_cap:
break
if random.randint(0, 255) >= chill:
continue
j = random.randint(0, num - 1)
glow[j] = min(255, glow[j] + random.randint(80, 200))
spawned += 1
palette = colors
for i in range(num):
g = glow[i]
fg = palette[i % len(palette)]
hi = self.driver.apply_brightness(fg, preset.b)
mix = max(0, min(255, g))
self.driver.n[i] = (
(hi[0] * mix + bg[0] * (255 - mix)) // 255,
(hi[1] * mix + bg[1] * (255 - mix)) // 255,
(hi[2] * mix + bg[2] * (255 - mix)) // 255,
)
self.driver.n.write()
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

147
src/patterns/sparkle.py Normal file
View File

@@ -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

View File

@@ -0,0 +1,45 @@
import utime
class StrobeBurst:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255)]
state = "flash_on"
flash_idx = 0
state_start = utime.ticks_ms()
while True:
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
gap = max(1, int(preset.n2) if int(preset.n2) > 0 else 60)
cooldown = max(1, int(preset.n3) if int(preset.n3) > 0 else 400)
on_ms = max(1, int(preset.d) // 2)
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
now = utime.ticks_ms()
if state == "flash_on":
self.driver.fill(c)
if utime.ticks_diff(now, state_start) >= on_ms:
state = "flash_off"
state_start = utime.ticks_add(state_start, on_ms)
elif state == "flash_off":
self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= gap:
flash_idx += 1
if flash_idx >= count:
if not preset.a:
return
state = "cooldown"
flash_idx = 0
state_start = utime.ticks_add(state_start, gap)
else:
state = "flash_on"
state_start = utime.ticks_add(state_start, gap)
else:
self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= cooldown:
state = "flash_on"
state_start = utime.ticks_add(state_start, cooldown)
yield

160
src/patterns/twinkle.py Normal file
View File

@@ -0,0 +1,160 @@
import random
import utime
# Default cool palette (icy blues, violet, mint) when preset has no colours.
_DEFAULT_COOL = (
(120, 200, 255),
(80, 140, 255),
(180, 120, 255),
(100, 220, 240),
(160, 200, 255),
(90, 180, 220),
)
class Twinkle:
def __init__(self, driver):
self.driver = driver
def _palette(self, preset):
colors = preset.c
if not colors:
return list(_DEFAULT_COOL)
out = []
for c in colors:
if isinstance(c, (list, tuple)) and len(c) == 3:
out.append(
(
max(0, min(255, int(c[0]))),
max(0, min(255, int(c[1]))),
max(0, min(255, int(c[2]))),
)
)
return out if out else list(_DEFAULT_COOL)
def run(self, preset):
"""Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs."""
palette = self._palette(preset)
num = self.driver.num_leds
bg_color = self.driver.apply_brightness(preset.background_or(palette), preset.b)
if num <= 0:
while True:
yield
return
def activity_rate():
r = int(preset.n1)
if r <= 0:
r = 48
return max(1, min(255, r))
def density255():
"""Higher → more LEDs lit on average when a twinkle step fires (0 = default mid)."""
d = int(preset.n2)
if d <= 0:
d = 128
return max(0, min(255, d))
def cluster_len_bounds():
"""n3 = min adjacent LEDs per twinkle, n4 = max (both 0 → 1..4)."""
lo = int(preset.n3)
hi = int(preset.n4)
if lo <= 0 and hi <= 0:
lo, hi = 1, min(4, num)
else:
if lo <= 0:
lo = 1
if hi <= 0:
hi = lo
if hi < lo:
lo, hi = hi, lo
lo = max(1, min(lo, num))
hi = max(lo, min(hi, num))
return lo, hi
def random_cluster_len():
lo, hi = cluster_len_bounds()
# When min and max match, every lit/dim run is exactly that many LEDs (still capped by strip length).
if lo == hi:
return lo
return random.randint(lo, hi)
def cluster_base_index(start, k):
"""Shift run left so a length-k segment fits; keeps full k when num >= k."""
k = min(max(0, int(k)), num)
if k <= 0:
return 0
return max(0, min(int(start), num - k))
dens = density255()
on = [random.randint(0, 255) < dens for _ in range(num)]
colour_i = [random.randint(0, len(palette) - 1) for _ in range(num)]
last_update = utime.ticks_ms()
if not preset.a:
for i in range(num):
if on[i]:
base = palette[colour_i[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else:
self.driver.n[i] = bg_color
self.driver.n.write()
yield
return
while True:
now = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(now, last_update) >= delay_ms:
rate = activity_rate()
dens = density255()
# Snapshot for decisions; apply all darks then all lights so
# overlaps in the same tick favour lit runs (lights win).
prev_on = on[:]
prev_ci = colour_i[:]
next_on = list(prev_on)
next_ci = list(prev_ci)
light_i = []
dark_i = []
for i in range(num):
if random.randint(0, 255) < rate:
r = random.randint(0, 255)
if not prev_on[i]:
if r < dens:
light_i.append(i)
else:
if r < (255 - dens):
dark_i.append(i)
def light_adjacent(start):
k = random_cluster_len()
b = cluster_base_index(start, k)
for dj in range(k):
idx = b + dj
next_on[idx] = True
next_ci[idx] = random.randint(0, len(palette) - 1)
def dark_adjacent(start):
k = random_cluster_len()
b = cluster_base_index(start, k)
for dj in range(k):
idx = b + dj
next_on[idx] = False
for i in dark_i:
dark_adjacent(i)
for i in light_i:
light_adjacent(i)
for i in range(num):
if next_on[i]:
base = palette[next_ci[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else:
self.driver.n[i] = bg_color
self.driver.n.write()
on = next_on
colour_i = next_ci
last_update = utime.ticks_add(last_update, delay_ms)
yield

View File

@@ -12,6 +12,7 @@ class Preset:
self.n4 = 0
self.n5 = 0
self.n6 = 0
self.bg = (0, 0, 0)
# Override defaults with provided data
self.edit(data)
@@ -25,10 +26,24 @@ class Preset:
"delay": "d",
"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", "n1", "n2", "n3", "n4", "n5", "n6"}
allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"}
for key, value in data.items():
if key == "reverse":
try:
if isinstance(value, bool):
self.n5 = 1 if value else 0
elif isinstance(value, (int, float)):
self.n5 = 1 if int(value) else 0
elif isinstance(value, str):
lowered = value.lower()
self.n5 = 1 if lowered in ("true", "1", "yes", "on") else 0
except (TypeError, ValueError):
pass
continue
key = aliases.get(key, key)
if key not in allowed_fields:
continue
@@ -56,6 +71,21 @@ class Preset:
elif key == "c":
if isinstance(value, (list, tuple)):
self.c = value
elif key == "bg":
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
try:
self.bg = (
int(value[1:3], 16),
int(value[3:5], 16),
int(value[5:7], 16),
)
except (TypeError, ValueError):
continue
elif isinstance(value, (list, tuple)) and len(value) == 3:
try:
self.bg = tuple(max(0, min(255, int(x))) for x in value)
except (TypeError, ValueError):
continue
else:
setattr(self, key, value)
return True
@@ -100,6 +130,15 @@ class Preset:
def auto(self, value):
self.a = value
def background_or(self, colors=None, default=(0, 0, 0)):
bg = getattr(self, "bg", None)
if isinstance(bg, (list, tuple)) and len(bg) == 3:
try:
return tuple(max(0, min(255, int(x))) for x in bg)
except (TypeError, ValueError):
return default
return default
def to_dict(self):
return {
"p": self.p,
@@ -107,6 +146,7 @@ class Preset:
"b": self.b,
"c": self.c,
"a": self.a,
"bg": self.bg,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,

View File

@@ -1 +0,0 @@
{"14": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 102, 0]], "b": 255, "n2": 1000, "n1": 2000, "p": "pulse", "n3": 2000, "d": 800}, "15": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 500}, "5": {"n5": 0, "n4": 1, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 5, "n1": 5, "p": "chase", "n3": 1, "d": 200}, "4": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 500}, "7": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[255, 165, 0], [128, 0, 128]], "b": 255, "n2": 10, "n1": 2, "p": "circle", "n3": 2, "d": 200}, "11": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "12": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "6": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 255, 0]], "b": 255, "n2": 500, "n1": 1000, "p": "pulse", "n3": 1000, "d": 500}, "3": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 2, "p": "rainbow", "n3": 0, "d": 100}, "2": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 0, "n2": 0, "n1": 0, "p": "off", "n3": 0, "d": 100}, "1": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "10": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[230, 242, 255]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "13": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 1, "p": "rainbow", "n3": 0, "d": 150}, "9": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 245, 230]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "8": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 1000}}

View File

@@ -4,11 +4,14 @@ from preset import Preset
from utils import convert_and_reorder_colors
import json
import sys
import utime
try:
import uos as os
except ImportError:
import os
MAX_PRESETS = 32
class Presets:
def __init__(self, pin, num_leds):
@@ -29,6 +32,7 @@ class Presets:
self.patterns = {
"off": self.off,
"on": self.on,
"blink": self.blink,
}
self.patterns.update(self._load_dynamic_patterns())
@@ -40,7 +44,7 @@ class Presets:
return loaded
for filename in files:
if not filename.endswith(".py") or filename == "__init__.py":
if not filename.endswith(".py") or filename in ("__init__.py", "main.py"):
continue
module_basename = filename[:-3]
module_name = "patterns." + module_basename
@@ -68,8 +72,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:
@@ -95,24 +120,43 @@ class Presets:
order = settings if settings is not None else "rgb"
self.presets = {}
for name, preset_data in data.items():
if len(self.presets) >= MAX_PRESETS:
print("Preset limit reached on load:", MAX_PRESETS)
break
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
if color_key is not None:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], order
)
self.presets[name] = Preset(preset_data)
if self.presets:
print("Loaded presets:")
#for name in sorted(self.presets.keys()):
# print(f" {name}: {self.presets[name].to_dict()}")
return True
def edit(self, name, data):
"""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: 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:
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)
return False
# Create new preset
self.presets[name] = Preset(data)
return True
@@ -123,6 +167,12 @@ class Presets:
return True
return False
def delete_all(self):
self.presets = {}
self.generator = None
self.selected = None
return True
def tick(self):
if self.generator is None:
return
@@ -145,14 +195,31 @@ class Presets:
if preset_name in self.presets:
preset = self.presets[preset_name]
if preset.p in self.patterns:
if preset.p == "off":
self.generator = None
self.step = 0
self.fill((0, 0, 0))
self.selected = preset_name
return True
# If re-selecting the same preset before the main loop has tick()'d the
# previous frame, run one pending tick so step stays in sync.
if (
preset_name == self.selected
and self.generator is not None
):
self.tick()
# Set step value if explicitly provided
if step is not None:
self.step = step
elif preset.p == "off" or self.selected != preset_name:
elif self.selected != preset_name:
self.step = 0
self.generator = self.patterns[preset.p](preset)
self.selected = preset_name # Store the preset name, not the object
self.tick()
return True
print("select failed: pattern not found for preset", preset_name, "pattern=", preset.p)
return False
print("select failed: preset not found", preset_name)
# If preset doesn't exist or pattern not found, indicate failure
return False
@@ -160,6 +227,21 @@ class Presets:
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
def is_reversed(self, preset):
from patterns.pattern_direction import is_reversed as _is_reversed
return _is_reversed(preset)
def led_i(self, preset, logical_index):
from patterns.pattern_direction import led_i as _led_i
return _led_i(self, preset, logical_index)
def signed(self, preset, value):
from patterns.pattern_direction import signed as _signed
return _signed(preset, value)
def apply_brightness(self, color, brightness_override=None):
# Combine per-preset brightness (override) with global brightness self.b
local = brightness_override if brightness_override is not None else 255
@@ -179,4 +261,44 @@ class Presets:
def on(self, preset):
colors = preset.c
color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.b))
lit = self.apply_brightness(color, preset.b)
while True:
self.fill(lit)
yield
def blink(self, preset):
"""Built-in blink (used by controller identify); no patterns/ deploy required."""
colors = preset.c if preset.c else [(255, 255, 255)]
bg_color = self.apply_brightness(preset.background_or(colors), preset.b)
color_index = 0
delay_ms = max(1, int(preset.d))
period = delay_ms * 2
now = utime.ticks_ms()
# Phase-lock to wall time so group identify (broadcast select) stays in sync even
# when devices process the packet on different main-loop iterations.
phase = now % period if period else 0
state = phase < delay_ms
last_update = utime.ticks_add(now, -phase)
if state:
base = colors[color_index % len(colors)]
self.fill(self.apply_brightness(base, preset.b))
color_index += 1
else:
self.fill(bg_color)
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
if state:
base = colors[color_index % len(colors)]
self.fill(self.apply_brightness(base, preset.b))
color_index += 1
else:
self.fill(bg_color)
state = not state
last_update = utime.ticks_add(last_update, delay_ms)
yield
def run_tick(presets):
"""Advance one animation frame (standalone tests / mpremote demos)."""
presets.tick()

17
src/print_timestamp.py Normal file
View File

@@ -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

12
src/runtime_state.py Normal file
View File

@@ -0,0 +1,12 @@
class RuntimeState:
def __init__(self):
self.hello = True
self.ws_client_count = 0
def ws_connected(self):
self.ws_client_count += 1
self.hello = False
def ws_disconnected(self):
self.ws_client_count = max(0, self.ws_client_count - 1)
self.hello = self.ws_client_count == 0

View File

@@ -3,6 +3,8 @@ import ubinascii
import machine
import network
WIFI_CHANNEL_DEFAULT = 5
class Settings(dict):
SETTINGS_FILE = "/settings.json"
@@ -12,28 +14,35 @@ class Settings(dict):
self.color_order = self.get_color_order(self["color_order"])
def set_defaults(self):
self["led_pin"] = 10
self["num_leds"] = 119
self["num_leds"] = 200
self["color_order"] = "rgb"
self["name"] = "a"
self["color_order"] = "grb"
sta = network.WLAN(network.STA_IF)
sta.active(True)
#use led-mac for name
mac = sta.config("mac")
mac = ubinascii.hexlify(mac).decode().lower()
self["name"] = "led-" + mac
self["debug"] = False
self["default"] = "on"
self["last_preset"] = ""
# Power-on: "default" | "last" | "off"
self["startup_mode"] = "default"
self["brightness"] = 32
self["transport_type"] = "wifi"
self["wifi_channel"] = 1
# Wi-Fi + TCP to controller: set ssid and password. Use transport_type "espnow"
# for ESP-NOW (requires espnow firmware).
self["ssid"] = ""
self["password"] = ""
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
self["groups"] = []
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -42,7 +51,17 @@ class Settings(dict):
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
old_recent = self.pop("recent_presets", None)
if isinstance(old_recent, list) and old_recent and not self.get("last_preset"):
for x in reversed(old_recent):
if isinstance(x, str) and x.strip():
self["last_preset"] = x.strip()
break
if x is not None:
s = str(x).strip()
if s:
self["last_preset"] = s
break
except Exception as e:
print(f"Error loading settings")
self.set_defaults()

51
src/startup.py Normal file
View File

@@ -0,0 +1,51 @@
import gc
import machine
import network
import utime
from presets import Presets
from settings import Settings
from controller_messages import apply_startup_pattern
def initialize_runtime():
machine.freq(160000000)
settings = Settings()
wdt = machine.WDT(timeout=10000)
wdt.feed()
gc.collect()
presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
presets.b = settings.get("brightness", 255)
presets.debug = bool(settings.get("debug", False))
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)
sta_if.connect(settings["ssid"], settings["password"])
while not sta_if.isconnected():
utime.sleep(1)
wdt.feed()
try:
led_ip = sta_if.ifconfig()[0]
except Exception:
led_ip = "?"
print("led-driver IP:", led_ip, " led-controller IP:", "(not connected)")
return settings, presets, wdt, sta_if

36
src/v1_wire.py Normal file
View File

@@ -0,0 +1,36 @@
"""Expand short v1 wire keys to long names (MicroPython)."""
K_PRESETS = "p"
K_SELECT = "s"
K_GROUPS = "g"
K_SET_GROUPS = "sg"
K_SAVE = "sv"
K_DEFAULT = "df"
K_DEVICE_CONFIG = "dc"
K_CLEAR_PRESETS = "cp"
K_MANIFEST = "mf"
_SHORT_TO_LONG = {
K_PRESETS: "presets",
K_SELECT: "select",
K_GROUPS: "groups",
K_SET_GROUPS: "set_groups",
K_SAVE: "save",
K_DEFAULT: "default",
K_DEVICE_CONFIG: "device_config",
K_CLEAR_PRESETS: "clear_presets",
K_MANIFEST: "manifest",
}
def expand_v1(data):
if not isinstance(data, dict):
return data
out = dict(data)
for short_key, long_key in _SHORT_TO_LONG.items():
if short_key in data and long_key not in out:
out[long_key] = data[short_key]
if short_key in out:
del out[short_key]
return out

129
src/wifi_sta.py Normal file
View File

@@ -0,0 +1,129 @@
"""STA connect helpers aligned with tests/test_wifi.py (status polling, fatal codes)."""
import gc
import machine
import utime
import network
_CONNECT_TIMEOUT_S = 45
_RETRY_DELAY_S = 2
def _wifi_status_label(code):
names = {
getattr(network, "STAT_IDLE", 0): "idle",
getattr(network, "STAT_CONNECTING", 1): "connecting",
getattr(network, "STAT_WRONG_PASSWORD", -3): "wrong_password",
getattr(network, "STAT_NO_AP_FOUND", -2): "no_ap_found",
getattr(network, "STAT_CONNECT_FAIL", -1): "connect_fail",
getattr(network, "STAT_GOT_IP", 3): "got_ip",
}
return names.get(code, str(code))
# Only abort the wait loop immediately on wrong password. NO_AP_FOUND / CONNECT_FAIL are often
# transient while the radio is still scanning (ESP32-C3 may report them before the AP appears).
_ABORT_WAIT_IMMEDIATE = (
getattr(network, "STAT_WRONG_PASSWORD", -3),
)
def _one_association_campaign(sta_if, ssid, password, wdt):
"""disconnect → connect → wait until connected, wrong password, or timeout. Returns True if connected."""
try:
sta_if.disconnect()
except Exception:
pass
utime.sleep_ms(200)
try:
sta_if.connect(ssid, password)
except Exception as ex:
print("wifi_sta: connect raised:", ex)
return False
start = utime.time()
last_status = None
while not sta_if.isconnected():
status = sta_if.status()
if status != last_status:
print("wifi_sta: status", status, _wifi_status_label(status))
last_status = status
if status in _ABORT_WAIT_IMMEDIATE:
return False
if utime.time() - start >= _CONNECT_TIMEOUT_S:
print("wifi_sta: association timeout")
return False
utime.sleep(1)
if wdt is not None:
wdt.feed()
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:
print("wifi_sta: no ssid in settings")
return False
attempt = 0
while True:
attempt += 1
print("wifi_sta: boot attempt", attempt, "ssid=", repr(ssid))
if _one_association_campaign(sta_if, ssid, password, wdt):
try:
print("wifi_sta: connected", sta_if.ifconfig()[0])
except Exception:
print("wifi_sta: connected")
return True
print("wifi_sta: retry in", _RETRY_DELAY_S, "s")
for _ in range(_RETRY_DELAY_S):
utime.sleep(1)
if wdt is not None:
wdt.feed()
def try_reconnect(sta_if, ssid, password, wdt):
"""Runtime: single association campaign after link loss; non-looping."""
if not ssid:
return False
print("wifi_sta: reconnect")
ok = _one_association_campaign(sta_if, ssid, password, wdt)
if ok:
try:
print("wifi_sta: connected", sta_if.ifconfig()[0])
except Exception:
print("wifi_sta: connected")
return ok

View File

@@ -1,190 +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, duration_ms):
"""Run pattern for specified duration."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
wdt.feed()
p.tick()
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):
p.tick()
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:
p.tick()
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:
p.tick()
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):
p.tick()
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")
p.tick()
utime.sleep_ms(100)
print("\n" + "=" * 50)
print("All tests completed!")
print("=" * 50)
if __name__ == "__main__":
main()

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets
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)
# Create blink preset (use short-key fields: p=pattern, b=brightness, d=delay, c=colors)
p.edit("test_blink", {
"p": "blink",
"b": 64,
"d": 200,
"c": [(255, 0, 0), (0, 0, 255)],
})
p.select("test_blink")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 1500:
wdt.feed()
p.tick()
utime.sleep_ms(10)
if __name__ == "__main__":
main()

View File

@@ -1,151 +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):
"""Helper: run current pattern for given ms using tick()."""
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()
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
p.tick()
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")
p.tick()
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")
p.tick()
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()

View File

@@ -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 <port> run tests/all.py
Or via dev helper::
python dev.py <port> test
"""
import json
import os
import sys
import utime
from machine import WDT
from settings import Settings
from presets import Presets
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:
@@ -23,10 +59,24 @@ class _TestContext:
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
self.wdt.feed()
self.presets.tick()
run_tick(self.presets)
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,16 +254,82 @@ 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:
files = os.listdir(pattern_dir)
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 skip:
continue
path = pattern_dir + "/" + filename
try:
with open(path, "r") as f:
src = f.read()
except OSError:
offenders.append(filename + " (unreadable)")
continue
if (
"utime.sleep(" in src
or "utime.sleep_ms(" in src
or "time.sleep(" in src
or "time.sleep_ms(" in src
):
offenders.append(filename)
assert not offenders, "blocking sleep found in patterns: %s" % ", ".join(offenders)
def test_default_requires_existing_preset():
@@ -193,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"
@@ -213,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",
@@ -240,8 +394,12 @@ 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,
test_save_and_load_roundtrip,

169
tests/bridge_ws_blink.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Send blink preset + select to a driver via the ESP-NOW bridge WebSocket.
Pairs with the on-device demo ``tests/patterns/blink.py``: same preset slot,
pattern, and colours; this script reaches the driver over ESP-NOW through
``espnow-sender`` (devices envelope, not legacy broadcast JSON).
Run from the **led-controller** repo (needs ``websockets`` in Pipenv)::
pipenv run python led-driver/tests/bridge_ws_blink.py
pipenv run python led-driver/tests/bridge_ws_blink.py \\
--url ws://192.168.4.1/ws --mac 18:8b:0e:15:60:a8
From **led-driver** (if Pipenv/env is the parent project)::
pipenv run python tests/bridge_ws_blink.py --dry-run
"""
from __future__ import annotations
import argparse
import asyncio
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, Optional
# led-driver/tests -> led-driver -> led-controller
LED_DRIVER_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = LED_DRIVER_ROOT.parent
def _load_bridge_url(explicit: Optional[str]) -> str:
if explicit and explicit.strip():
return explicit.strip()
for path in (PROJECT_ROOT / "settings.json", LED_DRIVER_ROOT / "settings.json"):
if not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
url = str(data.get("bridge_ws_url") or "").strip()
if url:
return url
except (OSError, json.JSONDecodeError, TypeError):
pass
return "ws://192.168.4.1/ws"
def _format_mac(mac: str) -> str:
s = re.sub(r"[^0-9a-fA-F]", "", str(mac or "").strip().lower())
if len(s) != 12 or not re.fullmatch(r"[0-9a-f]{12}", s):
raise ValueError("MAC must be 12 hex digits (e.g. 188b0e1560a8)")
return ":".join(s[i : i + 2] for i in range(0, 12, 2))
def build_blink_envelope(
mac: str,
*,
preset_id: str = "2",
delay_ms: int = 200,
brightness: int = 64,
) -> Dict[str, Any]:
"""v1 devices envelope: preset body + list select (same shape as the Pi)."""
body = {
"p": {
preset_id: {
"p": "blink",
"b": max(0, min(255, int(brightness))),
"d": max(1, int(delay_ms)),
"c": ["#FF0000", "#0000FF"],
"a": True,
}
},
"s": [str(preset_id)],
}
return {"v": "1", "dv": {_format_mac(mac): body}}
async def _send(url: str, envelope: Dict[str, Any], hold_s: float) -> None:
import websockets
packet = json.dumps(envelope, separators=(",", ":")).encode("utf-8")
print(f"connecting to {url}")
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
print(f"connected, sending {len(packet)} B")
print(packet.decode("utf-8"))
await ws.send(packet)
if hold_s > 0:
print(f"holding connection {hold_s}s …")
await asyncio.sleep(hold_s)
print("done")
def main() -> int:
parser = argparse.ArgumentParser(
description="Send blink preset+select to one driver via bridge WebSocket.",
)
parser.add_argument(
"--url",
default=None,
help="Bridge WebSocket URL (default: settings.json bridge_ws_url or ws://192.168.4.1/ws)",
)
parser.add_argument(
"--mac",
default="188b0e1560a8",
help="Driver MAC (12 hex, colons optional). Default: registry example id.",
)
parser.add_argument(
"--preset-id",
default="2",
help="Wire preset slot id (default: 2, matches zone push)",
)
parser.add_argument(
"--delay-ms",
type=int,
default=200,
help="Blink delay in ms (default: 200)",
)
parser.add_argument(
"--brightness",
type=int,
default=64,
help="Preset brightness 0255 (default: 64)",
)
parser.add_argument(
"--hold",
type=float,
default=2.0,
help="Seconds to keep WebSocket open after send (default: 2)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print envelope only; do not connect",
)
args = parser.parse_args()
url = _load_bridge_url(args.url)
try:
envelope = build_blink_envelope(
args.mac,
preset_id=args.preset_id,
delay_ms=args.delay_ms,
brightness=args.brightness,
)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"url={url!r} mac={_format_mac(args.mac)!r}")
if args.dry_run:
print(json.dumps(envelope, indent=2))
return 0
try:
asyncio.run(_send(url, envelope, args.hold))
except KeyboardInterrupt:
print("interrupted")
return 130
except Exception as e:
print(f"failed: {e!r}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,116 @@
"""Device-side radio diagnostic test (MicroPython).
Checks:
1) STA/AP bring-up on channel 5
2) ESP-NOW init and broadcast peer add
3) Broadcast TX test packet send
4) RX wait window to see any incoming ESP-NOW frames
"""
import espnow
import machine
import network
import time
import ubinascii
CHANNEL = 5
RX_WINDOW_MS = 3000
WDT_TIMEOUT_MS = 10000
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
TEST_PAYLOAD = b"\x4c\x05\x01\x00\x00\x00"
def _mac_hex(mac_bytes):
try:
return ubinascii.hexlify(mac_bytes).decode()
except Exception:
return "?"
def run_diag(channel=CHANNEL, rx_window_ms=RX_WINDOW_MS):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
wdt.feed()
print("diag start")
print("cpu freq", machine.freq())
sta = network.WLAN(network.STA_IF)
ap = network.WLAN(network.AP_IF)
# Clean start
try:
sta.active(False)
ap.active(False)
time.sleep_ms(100)
except Exception as e:
print("wifi reset failed", repr(e))
# STA setup
try:
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
print("sta ok ch", sta.config("channel"), "mac", _mac_hex(sta.config("mac")))
except Exception as e:
print("sta setup failed", repr(e))
# AP setup
try:
ap.active(True)
try:
ap.config(essid="diag-ap", channel=channel, hidden=True)
except TypeError:
ap.config(essid="diag-ap", channel=channel)
print("ap ok ch", ap.config("channel"), "mac", _mac_hex(ap.config("mac")))
except Exception as e:
print("ap setup failed", repr(e))
wdt.feed()
# ESP-NOW setup
try:
e = espnow.ESPNow()
e.active(True)
print("espnow active ok")
except Exception as e_err:
print("espnow init failed", repr(e_err))
return
# Add broadcast peer
try:
e.add_peer(BROADCAST_MAC, channel=channel)
print("add bcast peer ok")
except TypeError:
try:
e.add_peer(BROADCAST_MAC)
print("add bcast peer ok (no channel arg)")
except Exception as e_err:
print("add bcast peer failed", repr(e_err))
except Exception as e_err:
print("add bcast peer failed", repr(e_err))
# TX test
try:
ok = e.send(BROADCAST_MAC, TEST_PAYLOAD, True)
print("tx bcast", ok, "len", len(TEST_PAYLOAD))
except Exception as e_err:
print("tx bcast failed", repr(e_err))
# RX window
print("rx window ms", rx_window_ms)
t_end = time.ticks_add(time.ticks_ms(), rx_window_ms)
rx_count = 0
while time.ticks_diff(t_end, time.ticks_ms()) > 0:
wdt.feed()
host, msg = e.recv(100)
if host:
rx_count += 1
print("rx", rx_count, _mac_hex(host), "len", len(msg))
time.sleep_ms(5)
print("diag done rx_count", rx_count)
if __name__ == "__main__":
run_diag()

View File

@@ -0,0 +1,40 @@
"""Device test: receive ESP-NOW packets on channel 5 (MicroPython)."""
import espnow
import machine
import network
import ubinascii
import time
CHANNEL = 5
TIMEOUT_MS = 1000
WDT_TIMEOUT_MS = 10000
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def recv_loop(channel=CHANNEL, timeout_ms=TIMEOUT_MS):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
print("recv ready ch", channel)
while True:
wdt.feed()
host, msg = e.recv(timeout_ms)
if host:
mac_hex = ubinascii.hexlify(host).decode()
print("rx", mac_hex, "len", len(msg), "hex", ubinascii.hexlify(msg).decode())
else:
print("rx timeout")
time.sleep_ms(10)
if __name__ == "__main__":
recv_loop()

View File

@@ -0,0 +1,47 @@
"""Device test: send one ESP-NOW packet on channel 5 (MicroPython)."""
import espnow
import machine
import network
import ubinascii
CHANNEL = 5
DEST_HEX = "ffffffffffff"
PAYLOAD_HEX = "4c0501000000"
WDT_TIMEOUT_MS = 10000
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def _add_peer(esp, dest, channel):
try:
esp.add_peer(dest, channel=channel)
except TypeError:
esp.add_peer(dest)
except OSError:
pass
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
wdt.feed()
dest = ubinascii.unhexlify(dest_hex)
pkt = ubinascii.unhexlify(payload_hex)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
_add_peer(e, dest, channel)
wdt.feed()
ok = e.send(dest, pkt, True)
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
return ok
if __name__ == "__main__":
send_once()

40
tests/patterns/aurora.py Normal file
View File

@@ -0,0 +1,40 @@
#!/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_aurora", {
"p": "aurora",
"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_aurora")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_bar_graph", {
"p": "bar_graph",
"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_bar_graph")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

74
tests/patterns/blink.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Standalone blink pattern demo (WDT-fed tick loop).
Run on device::
mpremote connect <port> run tests/patterns/blink.py
"""
import sys
import utime
def _bootstrap_import_path():
"""Find ``settings`` / ``presets`` on device or when run via mpremote."""
try:
import uos as os
except ImportError:
import os
candidates = []
try:
here = __file__.rsplit("/", 1)[0]
if here:
candidates.append(here)
tests = here.rsplit("/", 1)[0]
if tests:
candidates.append(tests)
root = tests.rsplit("/", 1)[0]
if root:
candidates.append(root)
candidates.append(root + "/src")
except NameError:
pass
for p in (".", "..", "/", "src", "/src"):
candidates.append(p)
for p in candidates:
if p and p not in sys.path:
sys.path.insert(0, p)
_bootstrap_import_path()
from machine import WDT # noqa: E402
from settings import Settings # noqa: E402
from presets import Presets # noqa: E402
def _run_ms(presets, wdt, duration_ms, sleep_ms=10):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
wdt.feed()
presets.tick()
utime.sleep_ms(sleep_ms)
def main():
settings = Settings()
presets = Presets(settings.get("led_pin", 10), settings.get("num_leds", 30))
wdt = WDT(timeout=10000)
presets.edit(
"test_blink",
{
"p": "blink",
"b": 64,
"d": 200,
"c": [(255, 0, 0), (0, 0, 255)],
},
)
presets.select("test_blink")
_run_ms(presets, wdt, 1500)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_breathing_dual", {
"p": "breathing_dual",
"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_breathing_dual")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def run_for(p, wdt, ms):
@@ -10,7 +10,7 @@ def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)
@@ -123,7 +123,7 @@ def main():
print(" Advancing pattern with 10 beats (select + tick)...")
for i in range(10):
p.select("chase_manual") # Simulate beat - restarts generator
p.tick() # Advance one step
run_tick(p) # Advance one step
utime.sleep_ms(500) # Pause to see the pattern
wdt.feed()
print(f" Beat {i+1}: step={p.step}")
@@ -141,7 +141,7 @@ def main():
p.step = 0
initial_step = p.step
p.select("chase_manual2")
p.tick()
run_tick(p)
final_step = p.step
print(f" Step updated from {initial_step} to {final_step} (expected: 1)")
if final_step == 1:

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def run_for(p, wdt, ms):
@@ -10,7 +10,7 @@ def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

View File

@@ -0,0 +1,40 @@
#!/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_clock_sweep", {
"p": "clock_sweep",
"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_clock_sweep")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_comet_dual", {
"p": "comet_dual",
"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_comet_dual")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_fireflies", {
"p": "fireflies",
"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_fireflies")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
#!/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)
print("Test gradient_scroll")
p.edit("gradient_test", {
"p": "gradient_scroll",
"b": 220,
"d": 60,
"c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
"n1": 2,
"a": True,
})
p.select("gradient_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_heartbeat", {
"p": "heartbeat",
"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_heartbeat")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/marquee.py Normal file
View File

@@ -0,0 +1,40 @@
#!/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_marquee", {
"p": "marquee",
"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_marquee")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/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)
print("Test meteor_rain")
p.edit("meteor_test", {
"p": "meteor_rain",
"b": 200,
"d": 40,
"c": [(255, 80, 0), (0, 120, 255)],
"n1": 10,
"n2": 1,
"n3": 200,
"a": True,
})
p.select("meteor_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def main():
@@ -20,7 +20,7 @@ def main():
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 200:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def main():
@@ -29,7 +29,7 @@ def main():
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 800:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)
# OFF phase
@@ -37,7 +37,7 @@ def main():
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 100:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

40
tests/patterns/orbit.py Normal file
View File

@@ -0,0 +1,40 @@
#!/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_orbit", {
"p": "orbit",
"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_orbit")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_palette_morph", {
"p": "palette_morph",
"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_palette_morph")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/plasma.py Normal file
View File

@@ -0,0 +1,40 @@
#!/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_plasma", {
"p": "plasma",
"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_plasma")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def run_for(p, wdt, ms):
@@ -10,7 +10,7 @@ def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

View File

@@ -0,0 +1,40 @@
#!/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_rain_drops", {
"p": "rain_drops",
"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_rain_drops")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/scanner.py Normal file
View File

@@ -0,0 +1,40 @@
#!/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)
print("Test scanner")
p.edit("scanner_test", {
"p": "scanner",
"b": 255,
"d": 30,
"c": [(255, 0, 0)],
"n1": 4,
"n2": 2,
"a": True,
})
p.select("scanner_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_segment_chase", {
"p": "segment_chase",
"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_segment_chase")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_snowfall", {
"p": "snowfall",
"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_snowfall")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_sparkle_trail", {
"p": "sparkle_trail",
"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_sparkle_trail")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/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_strobe_burst", {
"p": "strobe_burst",
"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_strobe_burst")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from presets import Presets, run_tick
def run_for(p, wdt, ms):
@@ -10,7 +10,7 @@ def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

Some files were not shown because too many files have changed in this diff Show More