Compare commits

..

10 Commits

Author SHA1 Message Date
292c5bde01 Rework roll pattern to use gradient palette and add preset-based tests
Made-with: Cursor
2026-03-06 01:39:40 +13:00
a0687cff57 Switch chase to double-buffered ring chase
Made-with: Cursor
2026-03-06 01:00:25 +13:00
5f457b3ae7 Refine calibration, chase, and spin patterns and add spin test
Increase calibration brightness, update chase to use the new logical ring abstraction, and make spin start from a cleared frame with symmetric arm speed, alongside a dedicated on-device spin test script.

Made-with: Cursor
2026-03-05 23:41:25 +13:00
3e58f4e97e Add segments and double_circle patterns with shared presets
Introduce double_circle and segments-based patterns on the Pico, refactor the Presets engine to expose a logical ring over all strips, and migrate presets/test code from the old point pattern to segments while switching to a top-level presets.json.

Made-with: Cursor
2026-03-05 23:41:13 +13:00
47c19eecf1 Clean up ESP32 buttons and UART tests
Remove unused buttons.json and legacy UART UART test scripts, and update the web UI preset dropdown for new Pico patterns.

Made-with: Cursor
2026-03-05 23:40:28 +13:00
47c17dba36 Add mpremote tests for scaling, roll, and point
Made-with: Cursor
2026-03-05 20:25:57 +13:00
e75723e2e7 Refactor presets scaling and clean up patterns
Made-with: Cursor
2026-03-05 20:25:28 +13:00
52a5f0f8c4 Add Pico presets engine, patterns, and tests.
Wire the Pico to UART-driven preset selection, add pattern modules and presets data, remove old p2p/settings code, and update tests and LED driver.

Made-with: Cursor
2026-03-03 19:28:11 +13:00
646b988cdd Add ESP32 web control and UART bridge.
Replace ESPNOW passthrough with a Microdot-based web UI and WebSocket-to-UART bridge for preset selection.

Made-with: Cursor
2026-03-03 19:28:08 +13:00
615431d6c5 Add Pipenv environment and dry-run notes.
Document commit strategy and add Pipenv config for local development.

Made-with: Cursor
2026-03-03 19:28:06 +13:00
59 changed files with 7406 additions and 600 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
settings.json
settings.json
__pycache__/

194
DRY_RUN_COMMITS.md Normal file
View File

@@ -0,0 +1,194 @@
# Dry run: two-commit plan
## Current state
- **Staged:** 9 deleted files under old `src/` (boot, dma, main, patterns, settings, static/main.css, web, wifi, ws2812).
- **Tracked elsewhere:** `pico/lib/`, `pico/test/leds.py`, `pico/test/rainbow.py` (already in repo).
- **Unstaged / untracked:** Rest of restructure (more deletions, new `pico/src/`, `esp32/`, `main.py`, etc.).
---
## Commit 1: Folder restructure
**Goal:** Remove old flat layout; no new code from led-driver yet.
### 1. Unstage current changes (so we can stage only restructure)
```bash
git restore --staged .
```
### 2. Stage only restructure changes
**Deletions (remove old layout):**
```bash
# Old top-level src/ (already partially staged; include all)
git add -u src/
# Old lib (microdot, utemplate)
git add -u lib/microdot/
git add -u lib/utemplate/
# Old box + test
git rm box.scad box.stl test.py 2>/dev/null || true
git add -u box.scad box.stl test.py
```
Or in one go (stage all deletions under src/, lib/, and the files):
```bash
git restore --staged .
git add -u src/ lib/ box.scad box.stl test.py
```
**Optional for restructure:** If you want commit 1 to also introduce the new tree shape (empty or minimal), you could add only the new directories with a single placeholder each—but Git doesnt track empty dirs, so the “restructure” commit is usually just the removals.
**Files that would be in commit 1 (deletions only):**
| Path | Change |
|------|--------|
| `src/boot.py` | deleted |
| `src/dma.py` | deleted |
| `src/main.py` | deleted |
| `src/patterns.py` | deleted |
| `src/settings.py` | deleted |
| `src/static/main.css` | deleted |
| `src/static/main.js` | deleted |
| `src/templates/index.html` | deleted |
| `src/templates/index_html.py` | deleted |
| `src/web.py` | deleted |
| `src/wifi.py` | deleted |
| `src/ws2812.py` | deleted |
| `lib/microdot/__init__.py` | deleted |
| `lib/microdot/helpers.py` | deleted |
| `lib/microdot/microdot.py` | deleted |
| `lib/microdot/utemplate.py` | deleted |
| `lib/microdot/websocket.py` | deleted |
| `lib/utemplate/__init__.py` | deleted |
| `lib/utemplate/compiled.py` | deleted |
| `lib/utemplate/recompile.py` | deleted |
| `lib/utemplate/source.py` | deleted |
| `box.scad` | deleted |
| `box.stl` | deleted |
| `test.py` | deleted |
**Suggested commit message:**
```
Restructure: remove old flat src/lib layout
- Remove src/, lib/microdot, lib/utemplate, box.*, test.py
- Pico/ESP32 layout and led-driver code in follow-up commit
```
### 3. (Dry run) Show what would be committed
```bash
git status
git diff --cached --stat
```
---
## Commit 2: Import code from led-driver
**Goal:** Add pico/ and esp32/ app code and top-level entrypoint (from led-driver).
### 1. Stage new and modified files
```bash
# New layout: pico app and tests
git add pico/src/
git add pico/test/patterns/
git add pico/test/test_espnow_receive.py
git add -u pico/test/leds.py
git add -u pico/test/rainbow.py
# ESP32 app and tests
git add esp32/
# Top-level entrypoint
git add main.py
# Tooling / docs (if you want them in this commit)
git add dev.py README.md
# Optional
git add Pipfile Pipfile.lock
```
### 2. Files that would be in commit 2
**New files (led-driver / app):**
| Path |
|------|
| `main.py` |
| `pico/src/main.py` |
| `pico/src/p2p.py` |
| `pico/src/preset.py` |
| `pico/src/presets.py` |
| `pico/src/settings.py` |
| `pico/src/utils.py` |
| `pico/src/patterns/__init__.py` |
| `pico/src/patterns/blink.py` |
| `pico/src/patterns/chase.py` |
| `pico/src/patterns/circle.py` |
| `pico/src/patterns/pulse.py` |
| `pico/src/patterns/rainbow.py` |
| `pico/src/patterns/transition.py` |
| `esp32/src/main.py` |
| `esp32/test/test_uart_send_json.py` |
| `esp32/test/test_uart_tx.py` |
| `pico/test/patterns/auto_manual.py` |
| `pico/test/patterns/blink.py` |
| … (other pico/test/patterns/*) |
**Modified:**
| Path |
|------|
| `pico/test/leds.py` |
| `pico/test/rainbow.py` |
| `dev.py` |
| `README.md` (optional) |
**Optional (tooling):**
| Path |
|------|
| `Pipfile` |
| `Pipfile.lock` |
**Suggested commit message:**
```
Import led-driver app: pico/ and esp32/ layout
- pico/src: main, presets, settings, patterns (UART JSON)
- esp32/src: main (UART sender)
- main.py top-level entrypoint
- pico/test: pattern tests, test_espnow_receive
- dev.py: deploy by device (pico | esp32)
```
### 3. (Dry run) Show what would be committed
```bash
git status
git diff --cached --stat
```
---
## Summary
| Step | Action | Commit message |
|------|--------|----------------|
| 1 | `git restore --staged .` then stage only `src/` `lib/` `box.scad` `box.stl` `test.py` deletions | Restructure: remove old flat src/lib layout |
| 2 | Commit | (above) |
| 3 | Stage `pico/src/`, `pico/test/` changes, `esp32/`, `main.py`, optionally `dev.py` `README.md` `Pipfile*` | Import led-driver app: pico/ and esp32/ layout |
| 4 | Commit | (above) |
No commits are made in this dry run; run the `git add` and `git status` / `git diff --cached --stat` commands to confirm before running `git commit`.

15
Pipfile Normal file
View File

@@ -0,0 +1,15 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
esptool = "*"
mpremote = "*"
pyserial = "*"
requests = "*"
[dev-packages]
[requires]
python_version = "3.13"

617
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,617 @@
{
"_meta": {
"hash": {
"sha256": "eb62a51e8d40130b1e758dc972ead3111fe880e1638e2b7e6037a44b4831dbeb"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"bitarray": {
"hashes": [
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199",
"sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a",
"sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e",
"sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3",
"sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e",
"sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4",
"sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861",
"sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5",
"sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521",
"sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d",
"sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55",
"sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9",
"sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce",
"sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a",
"sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9",
"sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e",
"sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b",
"sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30",
"sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6",
"sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80",
"sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11",
"sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f",
"sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25",
"sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77",
"sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0",
"sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125",
"sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671",
"sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de",
"sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860",
"sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe",
"sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d",
"sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc",
"sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df",
"sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a",
"sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8",
"sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9",
"sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe",
"sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607",
"sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf",
"sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee",
"sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa",
"sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954",
"sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a",
"sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d",
"sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428",
"sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e",
"sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54",
"sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5",
"sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24",
"sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f",
"sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a",
"sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b",
"sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70",
"sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7",
"sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a",
"sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada",
"sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541",
"sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b",
"sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4",
"sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2",
"sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd",
"sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646",
"sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89",
"sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa",
"sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd",
"sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1",
"sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb",
"sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220",
"sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c",
"sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310",
"sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2",
"sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e",
"sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c",
"sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a",
"sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a",
"sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594",
"sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8",
"sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52",
"sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20",
"sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8",
"sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6",
"sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9",
"sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02",
"sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425",
"sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d",
"sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2",
"sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7",
"sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4",
"sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096",
"sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d",
"sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149",
"sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b",
"sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35",
"sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773",
"sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d",
"sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6",
"sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741",
"sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f",
"sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8",
"sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f",
"sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148",
"sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8",
"sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97",
"sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe"
],
"version": "==3.8.0"
},
"bitstring": {
"hashes": [
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a",
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a"
],
"markers": "python_version >= '3.8'",
"version": "==4.3.1"
},
"certifi": {
"hashes": [
"sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c",
"sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"
],
"markers": "python_version >= '3.7'",
"version": "==2026.1.4"
},
"cffi": {
"hashes": [
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
],
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
"version": "==2.0.0"
},
"charset-normalizer": {
"hashes": [
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.4"
},
"click": {
"hashes": [
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.1"
},
"cryptography": {
"hashes": [
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.5"
},
"esptool": {
"hashes": [
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.1.0"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"intelhex": {
"hashes": [
"sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4",
"sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093"
],
"version": "==2.3.0"
},
"markdown-it-py": {
"hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"
],
"markers": "python_version >= '3.10'",
"version": "==4.0.0"
},
"mdurl": {
"hashes": [
"sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8",
"sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"
],
"markers": "python_version >= '3.7'",
"version": "==0.1.2"
},
"mpremote": {
"hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
],
"index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.27.0"
},
"platformdirs": {
"hashes": [
"sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6",
"sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36"
],
"markers": "python_version >= '3.10'",
"version": "==4.7.0"
},
"pycparser": {
"hashes": [
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
],
"markers": "implementation_name != 'PyPy'",
"version": "==3.0"
},
"pygments": {
"hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
],
"markers": "python_version >= '3.8'",
"version": "==2.19.2"
},
"pyserial": {
"hashes": [
"sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb",
"sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"
],
"index": "pypi",
"version": "==3.5"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"reedsolo": {
"hashes": [
"sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd",
"sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732"
],
"version": "==1.7.0"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"rich": {
"hashes": [
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69",
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8"
],
"markers": "python_full_version >= '3.8.0'",
"version": "==14.3.2"
},
"rich-click": {
"hashes": [
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
],
"markers": "python_version >= '3.8'",
"version": "==1.9.7"
},
"urllib3": {
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
}
},
"develop": {}
}

View File

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

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 _

File diff suppressed because it is too large Load Diff

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

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

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)

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)

12
esp32/src/boot.py Normal file
View File

@@ -0,0 +1,12 @@
#create an accesstpoit called led-hoop with password hoop-1234
#enable password protection
import network
ap_if = network.WLAN(network.AP_IF)
ap_mac = ap_if.config('mac')
ap_if.active(True)
ap_if.config(essid="led-hoop", password="hoop-1234")
ap_if.active(False)
ap_if.active(True)
print(ap_if.ifconfig())

View File

@@ -1,34 +1,109 @@
"""
XIAO ESP32-C6: ESPNOW -> UART passthrough to Pico.
Receives messages via ESPNOW, forwards them unchanged to UART (GPIO17).
UART at 921600 baud. LED on GPIO15 blinks on activity.
"""
import network
import espnow
import machine
import time
from microdot import Microdot, send_file, Response
from microdot.utemplate import Template
from microdot.websocket import with_websocket
import json
from machine import Pin, UART, WDT
import asyncio
# UART: TX on GPIO17 -> Pico RX, max baud for throughput
UART_BAUD = 921600
uart = machine.UART(1, baudrate=UART_BAUD, tx=17)
led = machine.Pin(15, machine.Pin.OUT)
# Load button config: {"buttons": [{"id": "...", "preset": "..."}, ...]}
def _load_buttons():
try:
with open("buttons.json") as f:
raw = json.load(f)
return raw.get("buttons", [])
except (OSError, KeyError, ValueError):
return []
# WLAN must be active for ESPNOW (no need to connect)
sta = network.WLAN(network.WLAN.IF_STA)
sta.active(True)
sta.disconnect()
e = espnow.ESPNow()
e.active(True)
# No peers needed to receive; add_peer() only for send()
def _save_buttons(buttons):
try:
with open("buttons.json", "w") as f:
json.dump({"buttons": buttons}, f)
return True
except OSError:
return False
# Recv timeout 0 = non-blocking
print("ESP32: ESPNOW -> UART passthrough, %d baud" % UART_BAUD)
while True:
mac, msg = e.irecv(0)
if msg:
uart.write(msg)
led.value(1)
else:
led.value(0)
time.sleep_ms(1)
BUTTONS = _load_buttons()
uart = UART(1, baudrate=921600, tx=Pin(16, Pin.OUT))
app = Microdot()
Response.default_content_type = 'text/html'
# Device id used in select payload (e.g. Pico name)
DEVICE_ID = "1"
# All connected WebSocket clients (for broadcasting button updates)
_ws_clients = set()
@app.route('/')
async def index_handler(request):
return Template('/index.html').render(buttons=BUTTONS, device_id=DEVICE_ID)
@app.route("/api/buttons", methods=["GET"])
async def api_get_buttons(request):
return {"buttons": BUTTONS}
@app.route("/api/buttons", methods=["POST"])
async def api_save_buttons(request):
global BUTTONS
try:
data = request.json or {}
buttons = data.get("buttons", [])
if not isinstance(buttons, list):
return {"ok": False, "error": "buttons must be a list"}, 400
if _save_buttons(buttons):
BUTTONS = buttons
return {"ok": True}
return {"ok": False, "error": "save failed"}, 500
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@app.route("/static/<path:path>")
async def static_handler(request, path):
if '..' in path:
# Directory traversal is not allowed
return 'Not found', 404
return send_file('static/' + path)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
_ws_clients.add(ws)
print("WebSocket connection established")
try:
while True:
data = await ws.receive()
if data:
# Forward WebSocket message to UART (line-delimited for Pico)
payload = data if isinstance(data, bytes) else data.encode("utf-8")
uart.write(payload + b"\n")
print(data)
# Broadcast to all other clients so their UIs stay in sync
for other in list(_ws_clients):
if other is not ws and not other.closed:
try:
await other.send(data)
except Exception:
pass
else:
break
finally:
_ws_clients.discard(ws)
print("WebSocket connection closed")
async def main():
server = asyncio.create_task(app.start_server("0.0.0.0", 80))
await server
if __name__ == "__main__":
asyncio.run(main())

48
esp32/src/squence.txt Normal file
View File

@@ -0,0 +1,48 @@
start
grab
spin1
lift
flare
hook
roll1
invertsplit
pose1
pose1
roll2
backbalance1
beat1
pose3
roll3
crouch
pose4
roll4
backbendsplit
backbalance2
backbalance3
beat2
straddle
beat3
frontbalance1
pose5
pose6
elbowhang
elbowhangspin
spin2
dismount
spin3
fluff
spin4
flare2
elbowhang
elbowhangsplit2
invert
roll5
backbend
pose7
roll6
seat
kneehang
legswoop
split
foothang
end

617
esp32/src/static/main.js Normal file
View File

@@ -0,0 +1,617 @@
var deviceId = document.body.getAttribute('data-device-id') || '1';
var ws = null;
var currentEditIndex = -1; // -1 means "new button"
function getButtonsFromDom() {
var btns = document.querySelectorAll('#buttonsContainer .btn');
return Array.prototype.map.call(btns, function (el) {
var obj = {
id: el.getAttribute('data-id') || el.textContent.trim(),
preset: el.getAttribute('data-preset') || ''
};
var p = el.getAttribute('data-p');
if (p) obj.p = p;
var d = el.getAttribute('data-d');
if (d !== null && d !== '') obj.d = parseInt(d, 10) || 0;
var b = el.getAttribute('data-b');
if (b !== null && b !== '') obj.b = parseInt(b, 10) || 0;
var c = el.getAttribute('data-c');
if (c) {
try {
var parsed = JSON.parse(c);
if (Array.isArray(parsed)) obj.c = parsed;
} catch (e) {}
}
for (var i = 1; i <= 8; i++) {
var key = 'n' + i;
var v = el.getAttribute('data-' + key);
if (v !== null && v !== '') {
obj[key] = parseInt(v, 10) || 0;
}
}
return obj;
});
}
function renderButtons(buttons) {
var container = document.getElementById('buttonsContainer');
container.innerHTML = '';
buttons.forEach(function (btn, idx) {
var el = document.createElement('button');
el.className = 'btn';
el.type = 'button';
el.setAttribute('data-preset', btn.preset);
el.setAttribute('data-id', btn.id);
el.setAttribute('data-index', String(idx));
// Optional preset config stored per button
if (btn.p !== undefined) el.setAttribute('data-p', btn.p);
if (btn.d !== undefined) el.setAttribute('data-d', String(btn.d));
if (btn.b !== undefined) el.setAttribute('data-b', String(btn.b));
if (btn.c !== undefined) {
try {
el.setAttribute('data-c', JSON.stringify(btn.c));
} catch (e) {}
}
for (var i = 1; i <= 8; i++) {
var key = 'n' + i;
if (btn[key] !== undefined) {
el.setAttribute('data-' + key, String(btn[key]));
}
}
el.draggable = true;
el.textContent = btn.id;
container.appendChild(el);
});
attachButtonListeners();
}
function attachButtonListeners() {
var container = document.getElementById('buttonsContainer');
if (!container) return;
var btns = container.querySelectorAll('.btn');
for (var i = 0; i < btns.length; i++) {
var el = btns[i];
el.setAttribute('data-index', String(i));
el.onclick = function(ev) {
if (longPressTriggered) {
ev.preventDefault();
return;
}
var btn = ev.currentTarget;
sendSelect(btn.getAttribute('data-preset'), btn);
};
el.oncontextmenu = function(ev) {
ev.preventDefault();
showButtonContextMenu(ev, ev.currentTarget);
};
(function(buttonEl) {
var startX, startY;
el.ontouchstart = function(ev) {
if (ev.touches.length !== 1) return;
longPressTriggered = false;
startX = ev.touches[0].clientX;
startY = ev.touches[0].clientY;
longPressTimer = setTimeout(function() {
longPressTimer = null;
longPressTriggered = true;
showButtonContextMenu({ clientX: startX, clientY: startY }, buttonEl);
}, 500);
};
el.ontouchmove = function(ev) {
if (longPressTimer && ev.touches.length === 1) {
var dx = ev.touches[0].clientX - startX;
var dy = ev.touches[0].clientY - startY;
if (dx * dx + dy * dy > 100) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
};
el.ontouchend = el.ontouchcancel = function() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
setTimeout(function() { longPressTriggered = false; }, 400);
};
})(el);
el.ondragstart = function(ev) {
ev.dataTransfer.setData('text/plain', ev.currentTarget.getAttribute('data-index'));
ev.dataTransfer.effectAllowed = 'move';
ev.currentTarget.classList.add('dragging');
};
el.ondragend = function(ev) {
ev.currentTarget.classList.remove('dragging');
};
el.ondragover = function(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
var target = ev.currentTarget;
if (target.classList.contains('dragging')) return;
target.classList.add('drop-target');
};
el.ondragleave = function(ev) {
ev.currentTarget.classList.remove('drop-target');
};
el.ondrop = function(ev) {
ev.preventDefault();
ev.currentTarget.classList.remove('drop-target');
var fromIdx = parseInt(ev.dataTransfer.getData('text/plain'), 10);
var toIdx = parseInt(ev.currentTarget.getAttribute('data-index'), 10);
if (fromIdx === toIdx) return;
var buttons = getButtonsFromDom();
var item = buttons.splice(fromIdx, 1)[0];
buttons.splice(toIdx, 0, item);
renderButtons(buttons);
saveButtons(buttons);
};
}
}
// Button editor (per-button preset config)
function saveButtons(buttons, callback) {
fetch('/api/buttons', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ buttons: buttons })
}).then(function(r) { return r.json(); }).then(function(data) {
if (callback) callback(data);
else showToast(data.ok ? 'Saved' : (data.error || 'Save failed'));
}).catch(function() {
if (callback) callback({ ok: false });
else showToast('Save failed');
});
}
function saveCurrentButtons() {
var buttons = getButtonsFromDom();
saveButtons(buttons, function(data) {
showToast(data.ok ? 'Saved' : (data.error || 'Save failed'));
});
}
var toastTimer = null;
function showToast(message) {
var el = document.getElementById('toast');
if (!el) {
el = document.createElement('div');
el.id = 'toast';
el.className = 'toast';
document.body.appendChild(el);
}
el.textContent = message;
el.classList.add('toast-visible');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function() {
el.classList.remove('toast-visible');
toastTimer = null;
}, 2000);
}
function addButton(button) {
var buttons = getButtonsFromDom();
buttons.push(button);
renderButtons(buttons);
saveButtons(buttons);
}
var contextMenuEl = null;
var longPressTimer = null;
var longPressTriggered = false;
function openNewButtonEditor() {
currentEditIndex = -1;
var title = document.getElementById('buttonEditorTitle');
if (title) title.textContent = 'New button';
fillButtonEditorFields({
id: '',
preset: '',
p: 'off',
d: 0,
b: 0,
c: [[0, 0, 0]]
});
openButtonEditor();
}
function openExistingButtonEditor(index) {
currentEditIndex = index;
var buttons = getButtonsFromDom();
var btn = buttons[index];
var title = document.getElementById('buttonEditorTitle');
if (title) title.textContent = 'Edit button';
fillButtonEditorFields(btn);
openButtonEditor();
}
function openButtonEditor() {
var editor = document.getElementById('buttonEditor');
if (!editor) return;
editor.classList.add('open');
document.body.classList.add('button-editor-open');
}
function closeButtonEditor() {
var editor = document.getElementById('buttonEditor');
if (!editor) return;
editor.classList.remove('open');
document.body.classList.remove('button-editor-open');
}
function fillButtonEditorFields(btn) {
document.getElementById('be-label').value = btn.id || '';
document.getElementById('be-preset').value = btn.preset || btn.id || '';
var pattern = btn.p || btn.preset || 'off';
var patternSelect = document.getElementById('be-pattern');
if (patternSelect) {
patternSelect.value = pattern;
}
document.getElementById('be-delay').value = btn.d != null ? String(btn.d) : '';
document.getElementById('be-brightness').value = btn.b != null ? String(btn.b) : '';
var colors = btn.c;
var colorsStr = '';
if (Array.isArray(colors) && colors.length) {
colorsStr = colors.map(function (rgb) {
return (rgb[0] || 0) + ',' + (rgb[1] || 0) + ',' + (rgb[2] || 0);
}).join('; ');
}
document.getElementById('be-colors').value = colorsStr;
for (var i = 1; i <= 8; i++) {
var key = 'n' + i;
var el = document.getElementById('be-' + key);
if (el) el.value = btn[key] != null ? String(btn[key]) : '';
}
}
function buildButtonFromEditor() {
function toInt(val, fallback) {
var n = parseInt(val, 10);
return isNaN(n) ? fallback : n;
}
var label = (document.getElementById('be-label').value || '').trim();
var presetName = (document.getElementById('be-preset').value || '').trim() || label || 'preset';
var patternEl = document.getElementById('be-pattern');
var pattern = patternEl ? (patternEl.value || '').trim() : 'off';
var delayVal = document.getElementById('be-delay').value;
var brightVal = document.getElementById('be-brightness').value;
var colorsRaw = (document.getElementById('be-colors').value || '').trim();
var d = toInt(delayVal, 0);
var b = toInt(brightVal, 0);
var colors = [];
if (colorsRaw) {
colorsRaw.split(';').forEach(function (chunk) {
var parts = chunk.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (parts.length === 3) {
var r = toInt(parts[0], 0);
var g = toInt(parts[1], 0);
var bl = toInt(parts[2], 0);
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
bl = Math.max(0, Math.min(255, bl));
colors.push([r, g, bl]);
}
});
}
if (!colors.length) colors = [[0, 0, 0]];
var btn = {
id: label || presetName,
preset: presetName,
p: pattern,
d: d,
b: b,
c: colors
};
for (var i = 1; i <= 8; i++) {
var key = 'n' + i;
var el = document.getElementById('be-' + key);
if (!el) continue;
var v = el.value;
if (v !== '') {
btn[key] = toInt(v, 0);
}
}
return btn;
}
function saveButtonFromEditor() {
var btn = buildButtonFromEditor();
var buttons = getButtonsFromDom();
if (currentEditIndex >= 0 && currentEditIndex < buttons.length) {
buttons[currentEditIndex] = btn;
} else {
buttons.push(btn);
}
renderButtons(buttons);
saveButtons(buttons);
// Also send this preset to the Pico and save it there
if (ws && ws.readyState === WebSocket.OPEN && btn.preset) {
var presetData = {
p: btn.p,
d: btn.d,
b: btn.b,
c: btn.c
};
for (var i = 1; i <= 8; i++) {
var key = 'n' + i;
if (btn[key] !== undefined) {
presetData[key] = btn[key];
}
}
ws.send(JSON.stringify({
preset_edit: {
name: btn.preset,
data: presetData
}
}));
ws.send(JSON.stringify({ preset_save: true }));
}
closeButtonEditor();
}
function showButtonContextMenu(evOrCoords, buttonEl) {
hideContextMenu();
var x = evOrCoords.clientX != null ? evOrCoords.clientX : evOrCoords.x;
var y = evOrCoords.clientY != null ? evOrCoords.clientY : evOrCoords.y;
var buttons = getButtonsFromDom();
var idx = parseInt(buttonEl.getAttribute('data-index'), 10);
var btn = buttons[idx];
contextMenuEl = document.createElement('div');
contextMenuEl.className = 'context-menu';
contextMenuEl.style.left = x + 'px';
contextMenuEl.style.top = y + 'px';
contextMenuEl.innerHTML = '<button type="button" class="context-menu-item" data-action="edit">Edit</button><button type="button" class="context-menu-item" data-action="delete">Delete</button>';
document.body.appendChild(contextMenuEl);
document.addEventListener('click', hideContextMenuOnce);
contextMenuEl.querySelector('[data-action="edit"]').onclick = function() {
hideContextMenu();
openExistingButtonEditor(idx);
};
contextMenuEl.querySelector('[data-action="delete"]').onclick = function() {
hideContextMenu();
buttons.splice(idx, 1);
renderButtons(buttons);
saveButtons(buttons);
};
}
function hideContextMenu() {
if (contextMenuEl && contextMenuEl.parentNode) contextMenuEl.parentNode.removeChild(contextMenuEl);
contextMenuEl = null;
document.removeEventListener('click', hideContextMenuOnce);
}
function hideContextMenuOnce() {
hideContextMenu();
}
function connect() {
var proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/ws");
ws.onclose = function() { setTimeout(connect, 2000); };
ws.onmessage = function(ev) {
try {
var msg = JSON.parse(ev.data);
if (msg && msg.select != null) {
var preset = typeof msg.select === 'string' ? msg.select : (Array.isArray(msg.select) ? msg.select[1] : null);
if (preset != null) {
document.querySelectorAll('.buttons .btn').forEach(function(b) { b.classList.remove('selected'); });
if (preset !== 'off') {
var el = document.querySelector('.buttons .btn[data-preset="' + preset + '"]');
if (el) {
el.classList.add('selected');
scrollSelectedIntoView();
updateSelectedObserver();
}
}
}
}
} catch (e) {}
};
}
function sendSelect(preset, el) {
var msg = JSON.stringify({ select: preset });
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg);
document.querySelectorAll('.buttons .btn').forEach(function(b) { b.classList.remove('selected'); });
if (el) el.classList.add('selected');
updateSelectedObserver();
}
function togglePresetEditor() {
var editor = document.getElementById('presetEditor');
if (!editor) return;
var isOpen = editor.classList.contains('open');
if (isOpen) {
editor.classList.remove('open');
document.body.classList.remove('preset-editor-open');
} else {
editor.classList.add('open');
document.body.classList.add('preset-editor-open');
}
}
function buildPresetPayloadFromForm() {
var name = (document.getElementById('pe-name').value || '').trim();
var patternEl = document.getElementById('pe-pattern');
var pattern = patternEl ? (patternEl.value || '').trim() : 'off';
var delayVal = document.getElementById('pe-delay').value;
var brightVal = document.getElementById('pe-brightness').value;
var colorsRaw = (document.getElementById('pe-colors').value || '').trim();
function toInt(val, fallback) {
var n = parseInt(val, 10);
return isNaN(n) ? fallback : n;
}
var d = toInt(delayVal, 0);
var b = toInt(brightVal, 0);
var colors = [];
if (colorsRaw) {
colorsRaw.split(';').forEach(function (chunk) {
var parts = chunk.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (parts.length === 3) {
var r = toInt(parts[0], 0);
var g = toInt(parts[1], 0);
var bl = toInt(parts[2], 0);
r = Math.max(0, Math.min(255, r));
g = Math.max(0, Math.min(255, g));
bl = Math.max(0, Math.min(255, bl));
colors.push([r, g, bl]);
}
});
}
if (!colors.length) colors = [[0, 0, 0]];
var data = { p: pattern, d: d, b: b, c: colors };
['n1', 'n2', 'n3', 'n4', 'n5', 'n6', 'n7', 'n8'].forEach(function (key) {
var el = document.getElementById('pe-' + key);
if (!el) return;
var v = el.value;
if (v !== '') {
data[key] = toInt(v, 0);
}
});
return { name: name, data: data };
}
function sendPresetToPico() {
var payload = buildPresetPayloadFromForm();
if (!payload.name) {
showToast('Preset name is required');
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ preset_edit: payload }));
ensurePresetInList(payload.name);
showToast('Preset sent (not yet saved)');
} else {
showToast('Not connected');
}
}
function savePresetsOnPico() {
var payload = buildPresetPayloadFromForm();
if (!payload.name) {
showToast('Preset name is required');
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ preset_edit: payload }));
ws.send(JSON.stringify({ preset_save: true }));
ensurePresetInList(payload.name);
showToast('Preset saved to Pico');
} else {
showToast('Not connected');
}
}
function deletePresetOnPico() {
var name = (document.getElementById('pe-name').value || '').trim();
if (!name) {
showToast('Preset name is required');
return;
}
var msg = { preset_delete: name };
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
removePresetFromList(name);
ws.send(JSON.stringify(msg));
showToast('Delete command sent');
} else {
showToast('Not connected');
}
}
function toggleMenu() {
var menu = document.getElementById('menu');
menu.classList.toggle('open');
}
function closeMenu() {
document.getElementById('menu').classList.remove('open');
}
document.addEventListener('click', function(ev) {
var menu = document.getElementById('menu');
var menuBtn = document.querySelector('.menu-btn');
if (menu.classList.contains('open') && menuBtn && !menu.contains(ev.target) && !menuBtn.contains(ev.target)) {
closeMenu();
}
});
function scrollSelectedIntoView() {
var el = document.querySelector('.buttons .btn.selected');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
var scrollObserver = null;
var observedSelectedEl = null;
function setupScrollObserver() {
var container = document.getElementById('buttonsContainer');
if (!container || scrollObserver) return;
scrollObserver = new IntersectionObserver(
function (entries) {
entries.forEach(function (entry) {
if (entry.intersectionRatio < 0.5 && entry.target.classList.contains('selected')) {
scrollSelectedIntoView();
}
});
},
{ root: container, rootMargin: '0px', threshold: [0, 0.25, 0.5, 0.75, 1] }
);
}
function updateSelectedObserver() {
var el = document.querySelector('.buttons .btn.selected');
if (el === observedSelectedEl) return;
if (scrollObserver) {
if (observedSelectedEl) scrollObserver.unobserve(observedSelectedEl);
observedSelectedEl = el;
if (el) {
setupScrollObserver();
scrollObserver.observe(el);
}
}
}
function nextPreset() {
var btns = document.querySelectorAll('.buttons .btn');
if (btns.length === 0) return;
var idx = -1;
for (var i = 0; i < btns.length; i++) {
if (btns[i].classList.contains('selected')) { idx = i; break; }
}
idx = (idx + 1) % btns.length;
var nextEl = btns[idx];
sendSelect(nextEl.getAttribute('data-preset'), nextEl);
scrollSelectedIntoView();
}
setupScrollObserver();
// Re-render buttons from server config (including per-button presets) once loaded.
fetch('/api/buttons')
.then(function (r) { return r.json(); })
.then(function (data) {
var buttons = Array.isArray(data.buttons) ? data.buttons : getButtonsFromDom();
renderButtons(buttons);
})
.catch(function () {
// Fallback: use buttons rendered by the template
renderButtons(getButtonsFromDom());
});
updateSelectedObserver();
connect();

256
esp32/src/static/styles.css Normal file
View File

@@ -0,0 +1,256 @@
body { font-family: sans-serif; margin: 0; padding: 0 0 6.5rem 0; }
body.button-editor-open {
overflow: hidden;
}
.header { position: relative; }
.menu-btn {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
cursor: pointer;
border: 1px solid #555;
border-radius: 0;
background: #333;
color: #fff;
text-align: left;
}
.menu {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
flex-direction: column;
border: 1px solid #555;
border-top: none;
background: #2a2a2a;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.menu.open { display: flex; }
.menu-item {
padding: 0.75rem 1rem;
font-size: 1rem;
cursor: pointer;
border: none;
border-bottom: 1px solid #444;
background: transparent;
color: #fff;
text-align: left;
}
.menu-item:last-child { border-bottom: none; }
.menu-item:hover { background: #444; }
.menu-item.off { background: #522; }
.buttons {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
max-height: calc(100vh - 10rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 768px) {
.buttons {
grid-template-columns: repeat(6, 1fr);
}
}
.btn {
padding: 0.5rem 0.25rem;
border: 1px solid #555;
border-radius: 0;
background: #444;
color: #fff;
font-size: 1rem;
cursor: pointer;
min-height: 4.15rem;
}
.btn.selected {
background: #2a7;
border-color: #3b8;
}
.btn.dragging { opacity: 0.5; }
.btn.drop-target { outline: 2px solid #3b8; outline-offset: -2px; }
.context-menu {
position: fixed;
z-index: 100;
background: #2a2a2a;
border: 1px solid #555;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
min-width: 8rem;
}
.context-menu-item {
display: block;
width: 100%;
padding: 0.5rem 1rem;
border: none;
background: transparent;
color: #fff;
font-size: 1rem;
text-align: left;
cursor: pointer;
}
.context-menu-item:hover { background: #444; }
.toast {
position: fixed;
bottom: 5rem;
left: 50%;
transform: translateX(-50%) translateY(2rem);
padding: 0.5rem 1rem;
background: #333;
color: #fff;
border-radius: 4px;
font-size: 0.9rem;
z-index: 1000;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
pointer-events: none;
}
.toast.toast-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.next-btn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
padding: 2rem 1rem;
padding-bottom: max(2rem, env(safe-area-inset-bottom));
font-size: 1.25rem;
cursor: pointer;
border: 2px solid #555;
border-radius: 0;
background: #333;
color: #fff;
}
.preset-editor {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
}
.preset-editor.open {
display: flex;
}
.preset-editor-inner {
width: 100%;
max-width: 28rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
background: #222;
border: 1px solid #555;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6);
border-radius: 6px;
padding: 1rem 1.25rem 0.75rem;
box-sizing: border-box;
}
.preset-editor-title {
margin: 0 0 0.75rem;
font-size: 1.25rem;
color: #fff;
}
.preset-editor-field {
display: flex;
flex-direction: column;
margin-bottom: 0.5rem;
gap: 0.25rem;
font-size: 0.9rem;
color: #ddd;
}
.preset-editor-field span {
opacity: 0.85;
}
.preset-editor-field input {
padding: 0.35rem 0.5rem;
border-radius: 4px;
border: 1px solid #555;
background: #111;
color: #fff;
font-size: 0.95rem;
}
.preset-editor-row {
display: flex;
gap: 0.5rem;
}
.preset-editor-field.small {
flex: 0 0 48%;
}
.preset-editor-field.small input {
padding: 0.25rem 0.4rem;
font-size: 0.85rem;
}
.preset-editor-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0.75rem 0 0.5rem;
}
.preset-editor-btn {
flex: 1;
padding: 0.5rem 0.75rem;
border-radius: 4px;
border: 1px solid #555;
background: #333;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
}
.preset-editor-btn.primary {
background: #2a7;
border-color: #3b8;
}
.preset-editor-btn.danger {
background: #722;
border-color: #a33;
}
.preset-editor-close {
width: 100%;
margin-top: 0.25rem;
padding: 0.4rem 0.75rem;
border-radius: 4px;
border: 1px solid #555;
background: #111;
color: #ccc;
font-size: 0.9rem;
cursor: pointer;
}
/* preset-list removed with old preset editor */

View File

@@ -0,0 +1,147 @@
{% args buttons, device_id %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Led Hoop</title>
<link rel="stylesheet" href="static/styles.css" />
</head>
<body data-device-id="{{ device_id }}">
<div class="header">
<button class="menu-btn" type="button" onclick="toggleMenu()" aria-label="Menu">☰ Menu</button>
<div class="menu" id="menu">
<button class="menu-item off" type="button" onclick="sendSelect('off', null); closeMenu();">Off</button>
<button class="menu-item" type="button" onclick="sendSelect('test', null); closeMenu();">Test</button>
<button class="menu-item" type="button" onclick="sendSelect('calibration', null); closeMenu();">Calibration</button>
<button class="menu-item" type="button" onclick="closeMenu(); openNewButtonEditor();">Add button</button>
<button class="menu-item" type="button" onclick="closeMenu(); saveCurrentButtons();">Save</button>
</div>
</div>
<div class="buttons" id="buttonsContainer">
{% for btn in buttons %}
<button class="btn" type="button" data-preset="{{ btn['preset'] }}" data-id="{{ btn['id'] }}"
draggable="true">{{ btn['id'] }}</button>
{% endfor %}
</div>
<div class="preset-editor" id="buttonEditor">
<div class="preset-editor-inner">
<h2 class="preset-editor-title" id="buttonEditorTitle">Button</h2>
<label class="preset-editor-field">
<span>Button label</span>
<input id="be-label" type="text" placeholder="e.g. grab" />
</label>
<label class="preset-editor-field">
<span>Preset name</span>
<input id="be-preset" type="text" placeholder="e.g. grab" />
</label>
<label class="preset-editor-field">
<span>Pattern (p)</span>
<select id="be-pattern">
<option value="spin">spin</option>
<option value="roll">roll</option>
<option value="grab">grab</option>
<option value="lift">lift</option>
<option value="flare">flare</option>
<option value="hook">hook</option>
<option value="invertsplit">invertsplit</option>
<option value="pose">pose</option>
<option value="backbalance">backbalance</option>
<option value="beat">beat</option>
<option value="crouch">crouch</option>
<option value="backbendsplit">backbendsplit</option>
<option value="straddle">straddle</option>
<option value="frontbalance">frontbalance</option>
<option value="elbowhang">elbowhang</option>
<option value="elbowhangspin">elbowhangspin</option>
<option value="dismount">dismount</option>
<option value="fluff">fluff</option>
<option value="elbowhangsplit">elbowhangsplit</option>
<option value="invert">invert</option>
<option value="backbend">backbend</option>
<option value="seat">seat</option>
<option value="kneehang">kneehang</option>
<option value="legswoop">legswoop</option>
<option value="split">split</option>
<option value="foothang">foothang</option>
<option value="segments">segments</option>
<option value="segments_transition">segments_transition</option>
<option value="double_circle">double_circle</option>
<option value="off">off</option>
<option value="on">on</option>
<option value="blink">blink</option>
<option value="rainbow">rainbow</option>
<option value="pulse">pulse</option>
<option value="transition">transition</option>
<option value="chase">chase</option>
<option value="circle">circle</option>
<option value="calibration">calibration</option>
<option value="test">test</option>
</select>
</label>
<div class="preset-editor-row">
<label class="preset-editor-field">
<span>Delay (d)</span>
<input id="be-delay" type="number" inputmode="numeric" />
</label>
<label class="preset-editor-field">
<span>Brightness (b)</span>
<input id="be-brightness" type="number" inputmode="numeric" min="0" max="255" />
</label>
</div>
<label class="preset-editor-field">
<span>Colors (c)</span>
<input id="be-colors" type="text" placeholder="r,g,b; r,g,b (0255)" />
</label>
<div class="preset-editor-row">
<label class="preset-editor-field small">
<span>n1</span>
<input id="be-n1" type="number" inputmode="numeric" />
</label>
<label class="preset-editor-field small">
<span>n2</span>
<input id="be-n2" type="number" inputmode="numeric" />
</label>
</div>
<div class="preset-editor-row">
<label class="preset-editor-field small">
<span>n3</span>
<input id="be-n3" type="number" inputmode="numeric" />
</label>
<label class="preset-editor-field small">
<span>n4</span>
<input id="be-n4" type="number" inputmode="numeric" />
</label>
</div>
<div class="preset-editor-row">
<label class="preset-editor-field small">
<span>n5</span>
<input id="be-n5" type="number" inputmode="numeric" />
</label>
<label class="preset-editor-field small">
<span>n6</span>
<input id="be-n6" type="number" inputmode="numeric" />
</label>
</div>
<div class="preset-editor-row">
<label class="preset-editor-field small">
<span>n7</span>
<input id="be-n7" type="number" inputmode="numeric" />
</label>
<label class="preset-editor-field small">
<span>n8</span>
<input id="be-n8" type="number" inputmode="numeric" />
</label>
</div>
<div class="preset-editor-actions">
<button type="button" class="preset-editor-btn primary" onclick="saveButtonFromEditor()">Save</button>
<button type="button" class="preset-editor-btn" onclick="closeButtonEditor()">Cancel</button>
</div>
</div>
</div>
<button class="next-btn" type="button" onclick="nextPreset()">Next</button>
<script src="static/main.js"></script>
</body>
</html>

View File

@@ -1,64 +0,0 @@
"""
ESP32-C6 test: send JSON messages to Pico over UART (GPIO17).
Settings use strips = [[pin, num_leds], ...]. Run with Pico connected on RX.
Run with mpremote (from repo root):
./esp32/run_test_uart_json.sh
# or
mpremote run esp32/test/test_uart_send_json.py
# or with port
mpremote connect /dev/ttyUSB0 run esp32/test/test_uart_send_json.py
"""
import machine
import time
import json
UART_TX_PIN = 17
UART_BAUD = 921600
LED_PIN = 15
def send_json(uart, obj):
line = json.dumps(obj) + "\n"
uart.write(line)
print("TX:", line.strip())
def main():
uart = machine.UART(1, baudrate=UART_BAUD, tx=UART_TX_PIN)
led = machine.Pin(LED_PIN, machine.Pin.OUT)
# 1) Settings: one strip, pin 2, 10 LEDs (list of lists)
send_json(uart, {
"v": 1,
"settings": {
"strips": [[2, 10]],
"brightness": 30,
},
})
led.value(1)
time.sleep(0.2)
led.value(0)
time.sleep(0.3)
# 2) led-controller format: light + settings.color (hex)
send_json(uart, {"light": "strip1", "settings": {"color": "#FF0000"}, "save": False})
time.sleep(0.5)
# 3) led-controller format: light + settings.r,g,b
send_json(uart, {"light": "strip1", "settings": {"r": 0, "g": 255, "b": 0}, "save": False})
time.sleep(0.5)
# 4) led-controller format: blue (hex)
send_json(uart, {"light": "strip1", "settings": {"color": "#0000FF"}, "save": False})
time.sleep(0.5)
# 5) Off (existing format)
send_json(uart, {"v": 1, "off": True})
time.sleep(0.3)
print("Done. Pico: settings -> red (hex) -> green (r,g,b) -> blue (hex) -> off.")
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
"""
ESP32-C6 UART TX + LED test. Sends a few commands on GPIO17, blinks LED on GPIO15.
Run on device: exec(open('test/test_uart_tx').read()) or import test.test_uart_tx
Does not require Pico connected.
"""
import machine
import time
UART_TX_PIN = 17
LED_PIN = 15
def main():
uart = machine.UART(1, baudrate=115200, tx=UART_TX_PIN)
led = machine.Pin(LED_PIN, machine.Pin.OUT)
def send(cmd):
uart.write(cmd + "\n")
print("TX:", cmd)
# Blink and send a short command sequence
commands = ["off", "fill 255 0 0", "fill 0 255 0", "fill 0 0 255", "off"]
for i, cmd in enumerate(commands):
led.value(1)
send(cmd)
time.sleep(0.3)
led.value(0)
time.sleep(0.2)
print("Done. Connect Pico to see strip follow commands.")
if __name__ == "__main__":
main()

View File

@@ -1,70 +1,72 @@
import array, time
from machine import Pin
import rp2
from time import sleep
import dma
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=8)
def ws2812():
T1 = 2
T2 = 5
T3 = 3
wrap_target()
label("bitloop")
out(x, 1) .side(0) [T3 - 1]
jmp(not_x, "do_zero") .side(1) [T1 - 1]
jmp("bitloop") .side(1) [T2 - 1]
label("do_zero")
nop() .side(0) [T2 - 1]
wrap()
class WS2812B:
def __init__(self, num_leds, pin, state_machine, brightness=0.1, invert=False):
self.sm = rp2.StateMachine(state_machine, ws2812, freq=8_000_000, sideset_base=Pin(pin))
self.sm.active(1)
self.ar = bytearray(num_leds*3)
self.num_leds = num_leds
self.brightness = brightness
self.invert = invert
self.pio_dma = dma.PIO_DMA_Transfer(state_machine+4, state_machine, 8, num_leds*3)
def show(self, array=None, offset=0):
if array is None:
array = self.ar
self.pio_dma.start_transfer(array, offset)
def set(self, i, color):
self.ar[i*3] = int(color[1]*self.brightness)
self.ar[i*3+1] = int(color[0]*self.brightness)
self.ar[i*3+2] = int(color[2]*self.brightness)
def fill(self, color):
for i in range(self.num_leds):
self.set(i, color)
def busy(self):
return self.pio_dma.busy()
BLACK = (0, 0, 0)
RED = (255, 0, 0)
YELLOW = (255, 150, 0)
GREEN = (0, 255, 0)
CYAN = (0, 255, 255)
BLUE = (0, 0, 255)
PURPLE = (180, 0, 255)
WHITE = (255, 255, 255)
COLORS = (BLACK, RED, YELLOW, GREEN, CYAN, BLUE, PURPLE, WHITE)
if __name__ == "__main__":
num_leds, pin, sm, brightness = 293, 2, 0, 0.1
ws0 = WS2812B(num_leds, pin, sm, brightness)
while True:
for color in ws0.COLORS:
ws0.fill(color)
ws0.show()
time.sleep(1)
import array, time
from machine import Pin
import rp2
from time import sleep
import dma
@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT, autopull=True, pull_thresh=8)
def ws2812():
T1 = 2
T2 = 5
T3 = 3
wrap_target()
label("bitloop")
out(x, 1) .side(0) [T3 - 1]
jmp(not_x, "do_zero") .side(1) [T1 - 1]
jmp("bitloop") .side(1) [T2 - 1]
label("do_zero")
nop() .side(0) [T2 - 1]
wrap()
class WS2812B:
def __init__(self, num_leds, pin, state_machine, brightness=0.1, invert=False):
self.sm = rp2.StateMachine(state_machine, ws2812, freq=8_000_000, sideset_base=Pin(pin))
self.sm.active(1)
self.ar = bytearray(num_leds*3)
self.num_leds = num_leds
self.brightness = brightness
self.invert = invert
self.pio_dma = dma.PIO_DMA_Transfer(state_machine+4, state_machine, 8, num_leds*3)
self.dma.start_transfer(self.ar)
def show(self, array=None, offset=0):
if array is None:
array = self.ar
self.pio_dma.start_transfer(array, offset)
def set(self, i, color):
self.ar[i*3] = int(color[1]*self.brightness)
self.ar[i*3+1] = int(color[0]*self.brightness)
self.ar[i*3+2] = int(color[2]*self.brightness)
def fill(self, color):
for i in range(self.num_leds):
self.set(i, color)
def busy(self):
return self.pio_dma.busy()
BLACK = (0, 0, 0)
RED = (255, 0, 0)
YELLOW = (255, 150, 0)
GREEN = (0, 255, 0)
CYAN = (0, 255, 255)
BLUE = (0, 0, 255)
PURPLE = (180, 0, 255)
WHITE = (255, 255, 255)
COLORS = (BLACK, RED, YELLOW, GREEN, CYAN, BLUE, PURPLE, WHITE)
if __name__ == "__main__":
num_leds, pin, sm, brightness = 293, 2, 0, 0.1
ws0 = WS2812B(num_leds, pin, sm, brightness)
while True:
for color in ws0.COLORS:
ws0.fill(color)
ws0.show()
time.sleep(1)

View File

@@ -1,99 +1,63 @@
import sys
# So "from ws2812 import WS2812B" finds pico/lib when run from device / or test/
if "lib" not in sys.path:
sys.path.insert(0, "lib")
if "../lib" not in sys.path:
sys.path.insert(0, "../lib")
from ws2812 import WS2812B
import time
from machine import UART, Pin
import json
from presets import Presets
import gc
# --- Rainbow pattern (outside ws2812): pregen double buffer, show via head offset ---
uart = UART(0, baudrate=921600, rx=Pin(1, Pin.IN))
def hue_to_rgb(hue):
"""Hue 0..360 -> (r, g, b). Simple HSV with S=V=1."""
h = hue % 360
x = 1 - abs((h / 60) % 2 - 1)
if h < 60:
r, g, b = 1, x, 0
elif h < 120:
r, g, b = x, 1, 0
elif h < 180:
r, g, b = 0, 1, x
elif h < 240:
r, g, b = 0, x, 1
elif h < 300:
r, g, b = x, 0, 1
else:
r, g, b = 1, 0, x
return (int(r * 255), int(g * 255), int(b * 255))
presets = Presets()
presets.load()
print(presets.presets.keys())
def make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len).
head must be in 0..strip_len-1 so DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = (i / n) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
b = int(b * brightness) & 0xFF
o = i * 3
double_buf[o] = g
double_buf[o + 1] = r
double_buf[o + 2] = b
strip_len = num_leds * 3
return (double_buf, strip_len)
presets.select("off")
#print memory usage
print(f"Memory usage: {gc.mem_free()/1024} kB free")
def show_rainbow(strip, double_buf, strip_len, head):
"""DMA reads directly from double_buf at head; no copy. head in 0..strip_len-1."""
strip.show(double_buf, head)
# --- Strips + rainbow buffers per strip ---
# Each strip can have a different length; buffers and phase are per-strip.
# Strip config must match pico/src/main.py pins.
STRIP_CONFIG = (
(7, 291),
(3, 290),
(6, 283),
(28, 278),
(29, 275),
(4, 270),
(0, 283),
(2, 290),
)
strips = []
sm = 0
for pin, num_leds in STRIP_CONFIG:
print(pin, num_leds)
ws = WS2812B(num_leds, pin, sm, brightness=0.2) # 1.0 so fill() is visible
strips.append(ws)
sm += 1
# Cumulative LED count before each strip; total ring size
cumulative_leds = [0]
for ws in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + ws.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
bytes_per_cycle = total_ring_leds * 3
# One rainbow double buffer per strip (length = 2 * num_leds for that strip)
now = time.ticks_ms()
rainbow_data = [make_rainbow_double(ws.num_leds, ws.brightness) for ws in strips]
# Global phase in bytes; each strip: head = (phase + cumulative_leds[i]*3) % strip_len[i]
print(time.ticks_diff(time.ticks_ms(), now), "ms")
rainbow_head = 0
step = 3
i = 0
while True:
now = time.ticks_ms()
for i, (strip, (double_buf, strip_len)) in enumerate(zip(strips, rainbow_data)):
head = (rainbow_head + cumulative_leds[i] * 3) % strip_len
show_rainbow(strip, double_buf, strip_len, head)
rainbow_head = (rainbow_head + step) % bytes_per_cycle
#print(time.ticks_diff(time.ticks_ms(), now), "ms")
time.sleep_ms(10)
presets.tick()
if uart.any():
data = uart.readline()
try:
data = json.loads(data)
except:
# Ignore malformed JSON lines
continue
# Select a preset by name (existing behaviour)
preset_name = data.get("select")
if preset_name is not None:
presets.select(preset_name)
presets.tick()
# Create or update a preset:
# {"preset_edit": {"name": "<name>", "data": {<preset_dict>}}}
edit_payload = data.get("preset_edit")
if isinstance(edit_payload, dict):
name = edit_payload.get("name")
preset_data = edit_payload.get("data") or {}
if isinstance(name, str) and isinstance(preset_data, dict):
# Log the incoming preset payload for debugging
print("PRESET_EDIT", name, preset_data)
presets.edit(name, preset_data)
# Delete a preset:
# {"preset_delete": "<name>"}
delete_name = data.get("preset_delete")
if isinstance(delete_name, str):
print("PRESET_DELETE", delete_name)
presets.delete(delete_name)
# Persist all presets to flash:
# {"preset_save": true}
if data.get("preset_save"):
print("PRESET_SAVE")
presets.save()
print(data)
gc.collect()
#print used and free memory
print(f"Memory usage: {gc.mem_alloc()/1024} kB used, {gc.mem_free()/1024} kB free")

View File

@@ -1,16 +0,0 @@
import asyncio
import aioespnow
import json
async def p2p(settings, patterns):
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
e.active(True)
async for mac, msg in e:
try:
data = json.loads(msg)
except:
print(f"Failed to load espnow data {msg}")
continue
if "names" not in data or settings.get("name") in data.get("names", []):
await settings.set_settings(data.get("settings", {}), patterns, data.get("save", False))

View File

@@ -4,3 +4,16 @@ from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle
from .double_circle import DoubleCircle
from .roll import Roll
from .calibration import Calibration
from .test import Test
from .scale_test import ScaleTest
from .grab import Grab
from .spin import Spin
from .lift import Lift
from .flare import Flare
from .hook import Hook
from .pose import Pose
from .segments import Segments
from .segments_transition import SegmentsTransition

View File

@@ -0,0 +1,39 @@
"""Calibration: strips 2 and 6 only. First 10 green, then alternating 10 blue / 10 red. 10% brightness."""
BRIGHTNESS = 1
BLOCK = 10
STRIPS_ON = (2, 6) # 0-based: 3rd and 7th strip only
GREEN = (0, 255, 0)
RED = (255, 0, 0)
BLUE = (0, 0, 255)
def _scale(color, factor):
return tuple(int(c * factor) for c in color)
class Calibration:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
green = _scale(GREEN, BRIGHTNESS)
red = _scale(RED, BRIGHTNESS)
blue = _scale(BLUE, BRIGHTNESS)
blue = (0,0,0)
on_set = set(STRIPS_ON)
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
if strip_idx not in on_set:
strip.fill((0, 0, 0))
strip.show()
continue
for i in range(n):
if i < BLOCK:
strip.set(i, green)
else:
block = (i - BLOCK) // BLOCK
strip.set(i, blue if block % 2 == 0 else red)
strip.show()

View File

@@ -1,123 +1,165 @@
import utime
def _make_chase_double(num_leds, cumulative_leds, total_ring_leds, color0, color1, n1, n2):
"""Pregenerate strip double buffer with repeating segments:
color0 for n1 pixels, then color1 for n2 pixels, around the full ring. GRB order."""
n = 2 * num_leds
buf = bytearray(n * 3)
pattern_len = n1 + n2
for b in range(n):
# Position of this pixel along the logical ring
pos = (2 * cumulative_leds - b) % total_ring_leds
seg_pos = pos % pattern_len
if seg_pos < n1:
r, g, b_ = color0
else:
r, g, b_ = color1
o = b * 3
buf[o] = g
buf[o + 1] = r
buf[o + 2] = b_
strip_len_bytes = num_leds * 3
return buf, strip_len_bytes
def _ensure_chase_buffers(driver, color0, color1, n1, n2, cache):
"""Build or refresh per-strip double buffers for the chase pattern."""
strips = driver.strips
key = (
color0,
color1,
int(n1),
int(n2),
tuple(s.num_leds for s in strips),
)
if cache.get("key") == key and cache.get("data") is not None:
return cache["data"], cache["cumulative_leds"], cache["total_ring_leds"]
if not strips:
cache["key"] = key
cache["data"] = []
cache["cumulative_leds"] = []
cache["total_ring_leds"] = 0
return cache["data"], cache["cumulative_leds"], cache["total_ring_leds"]
cumulative_leds = [0]
for s in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + s.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
chase_data = []
for idx, s in enumerate(strips):
buf, strip_len_bytes = _make_chase_double(
s.num_leds,
cumulative_leds[idx],
total_ring_leds,
color0,
color1,
n1,
n2,
)
chase_data.append((buf, strip_len_bytes))
cache["key"] = key
cache["data"] = chase_data
cache["cumulative_leds"] = cumulative_leds
cache["total_ring_leds"] = total_ring_leds
return chase_data, cumulative_leds, total_ring_leds
class Chase:
def __init__(self, driver):
self.driver = driver
self._buffers_cache = {}
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)"""
colors = preset.c
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating around the full ring.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)."""
colors = preset.c or []
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
base0 = colors[0]
base1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
base0 = colors[0]
base1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
# Apply preset/global brightness
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, 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)
n1 = max(1, int(getattr(preset, "n1", 1)) or 1) # LEDs of color 0
n2 = max(1, int(getattr(preset, "n2", 1)) or 1) # LEDs of color 1
n3 = int(getattr(preset, "n3", 0) or 0) # step on even steps
n4 = int(getattr(preset, "n4", 0) or 0) # step on odd steps
segment_length = n1 + n2
if n3 == 0 and n4 == 0:
# Nothing to move; default to simple forward motion
n3 = 1
n4 = 1
# Calculate position from step_count
step_count = self.driver.step
# 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
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
self.driver, color0, color1, n1, n2, self._buffers_cache
)
# Wrap position to keep it reasonable
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
strips = self.driver.strips
# If auto is False, run a single step and then stop
def show_frame(chase_pos):
for i, (strip, (buf, strip_len_bytes)) in enumerate(zip(strips, chase_data)):
# head in [0, strip_len_bytes) so DMA read head..head+strip_len_bytes stays in double buffer
head = ((chase_pos + cumulative_leds[i]) * 3) % strip_len_bytes
strip.show(buf, head)
# Helper to compute head position from current step_count
def head_from_step(step_count):
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
pos = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
pos = ((step_count + 1) // 2) * (n3 + n4)
if total_ring_leds <= 0:
return 0
pos %= total_ring_leds
if pos < 0:
pos += total_ring_leds
return pos
# Single-step mode: render one frame, then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
step_count = int(self.driver.step)
chase_pos = head_from_step(step_count)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
show_frame(chase_pos)
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
# Advance step for next trigger
self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
# Use transition_duration for timing and force the first update to happen immediately
transition_duration = max(10, int(preset.d))
# Auto mode: continuous loop driven by delay d
transition_duration = max(10, int(getattr(preset, "d", 50)) or 10)
last_update = utime.ticks_ms() - transition_duration
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Rebuild buffers if geometry/colors changed
chase_data, cumulative_leds, total_ring_leds = _ensure_chase_buffers(
self.driver, color0, color1, n1, n2, self._buffers_cache
)
# Wrap position
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
step_count = int(self.driver.step)
chase_pos = head_from_step(step_count)
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
show_frame(chase_pos)
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step
step_count += 1
self.driver.step = step_count
# Advance step for next frame
self.driver.step = step_count + 1
last_update = current_time
# Yield once per tick so other logic can run

View File

@@ -0,0 +1,84 @@
class DoubleCircle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
DoubleCircle: symmetric band around a center index on the logical ring.
- n1: center index on the logical ring (0-based, on reference strip 0)
- n2: radius of the band (max distance from center)
- n3: direction mode
0 → LEDs start ALL OFF and turn ON n4 LEDs at a time outward from n1 toward n1±n2
1 → LEDs start ALL ON within radius n2 and turn OFF n4 LEDs at a time inward toward n1
- n4: step size in LEDs per update
- c[0]: base color used for the band
"""
num_leds = self.driver.num_leds
if num_leds <= 0:
while True:
yield
colors = preset.c or []
base1 = colors[0] if len(colors) >= 1 else (255, 255, 255)
off = (0, 0, 0)
# Apply preset/global brightness
color_on = self.driver.apply_brightness(base1, preset.b)
color_off = off
# Center index and radius from preset; clamp center to ring length
center = int(getattr(preset, "n1", 0)) % num_leds
radius = max(1, int(getattr(preset, "n2", 0)) or 1)
mode = int(getattr(preset, "n3", 0) or 0) # 0 = grow band outward, 1 = shrink band inward
step_size = max(1, int(getattr(preset, "n4", 1)) or 1)
num_strips = len(self.driver.strips)
# Current "front" of the band, as a distance from center
# mode 0: grow band outward (0 → radius)
# mode 1: shrink band inward (radius → 0)
if mode == 0:
current = 0
else:
current = radius
while True:
# Draw current frame based on current radius
for i in range(num_leds):
# Shortest circular distance from i to center
forward = (i - center) % num_leds
backward = (center - i) % num_leds
dist = forward if forward < backward else backward
if dist > radius:
c = color_off
else:
if mode == 0:
# Grow outward: lit if within current radius
c = color_on if dist <= current else color_off
else:
# Shrink inward: lit if within current radius (band contracts toward center)
c = color_on if dist <= current else color_off
for strip_idx in range(num_strips):
self.driver.set(strip_idx, i, c)
self.driver.show_all()
# Update current radius for next frame
if mode == 0:
if current >= radius:
# Finished growing; hold final frame
while True:
yield
current = min(radius, current + step_size)
else:
if current <= 0:
# Finished shrinking; hold final frame
while True:
yield
current = max(0, current - step_size)
yield

View File

@@ -0,0 +1,63 @@
import utime
class Flare:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Flare: on the strip used by the first roll head,
make the strip fade up to brightness over the delay time.
- c[0]: color1 for the first n1 LEDs
- c[1]: color2 for the rest of the strip
- n1: number of LEDs from the start of the strip that use color1
- d: fade-in duration in ms (time to reach full preset brightness b)
"""
strips = self.driver.strips
# Which strip to flare: last roll head, clamped to valid range
strip_idx = getattr(self.driver, "last_roll_head", 0)
if strip_idx < 0 or strip_idx >= len(strips):
strip_idx = 0
strip = strips[strip_idx]
n = strip.num_leds
colors = preset.c
base_c1 = colors[0] if len(colors) > 0 else (255, 255, 255)
base_c2 = colors[1] if len(colors) > 1 else (0, 0, 0)
count_c1 = max(0, min(int(preset.n1), n))
fade_ms = max(1, int(preset.d) or 1)
target_b = int(preset.b) if hasattr(preset, "b") else 255
start_time = utime.ticks_ms()
done = False
while True:
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if not done:
if elapsed >= fade_ms:
factor = 1.0
done = True
else:
factor = elapsed / fade_ms if fade_ms > 0 else 1.0
else:
factor = 1.0
# Effective per-preset brightness scaled over time
current_b = int(target_b * factor)
# Apply global + local brightness to both colors
c1 = self.driver.apply_brightness(base_c1, current_b)
c2 = self.driver.apply_brightness(base_c2, current_b)
for i in range(n):
strip.set(i, c1 if i < count_c1 else c2)
strip.show()
yield

26
pico/src/patterns/grab.py Normal file
View File

@@ -0,0 +1,26 @@
"""Grab: from center of each strip, 10 LEDs each side (21 total) in purple."""
SPAN = 10 # LEDs on each side of center
PURPLE = (180, 0, 255)
class Grab:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
mid = self.driver.strip_midpoints[strip_idx]
strip.fill((0, 0, 0))
start = max(0, mid - SPAN)
end = min(n, mid + SPAN + 1)
for i in range(start, end):
strip.set(i, preset.c[0])
for strip in strips:
strip.show()
while True:
yield

31
pico/src/patterns/hook.py Normal file
View File

@@ -0,0 +1,31 @@
"""Hook: light strips n1 to n2 with a segment span long, offset from dead center."""
class Hook:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
midpoints = self.driver.strip_midpoints
n1 = max(0, int(preset.n1))
n2 = max(n1, int(preset.n2))
span = max(0, int(preset.n3))
offset = int(preset.n4) # positive = toward one end
color = preset.c[0] if preset.c else (0, 0, 0)
for strip_idx, strip in enumerate(strips):
strip.fill((0, 0, 0))
if n1 <= strip_idx <= n2:
mid = midpoints[strip_idx]
n = strip.num_leds
center = mid + offset
start = max(0, center - span)
end = min(n, center + span + 1)
for i in range(start, end):
strip.set(i, color)
for strip in strips:
strip.show()
while True:
yield

116
pico/src/patterns/lift.py Normal file
View File

@@ -0,0 +1,116 @@
"""Lift: opposite of Spin — arms contract from the ends toward the center. Preset color, n1 = rate."""
import utime
SPAN = 10 # LEDs on each side of center (match Grab)
LUT_SIZE = 256
class Lift:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
active_indices = (0, 4)
c0 = preset.c[0] if preset.c else (0, 0, 0)
c1 = preset.c[1] if len(preset.c) > 1 else c0
lut = []
for k in range(LUT_SIZE):
t = k / (LUT_SIZE - 1) if LUT_SIZE > 1 else 1
r = int(c0[0] + (c1[0] - c0[0]) * t)
g = int(c0[1] + (c1[1] - c0[1]) * t)
b = int(c0[2] + (c1[2] - c0[2]) * t)
lut.append((r, g, b))
midpoints = self.driver.strip_midpoints
rate = max(1, int(preset.n1) or 1)
delay_ms = max(1, int(preset.d) or 1)
margin = max(0, int(preset.n2) or 0)
left = {}
right = {}
for idx in active_indices:
if 0 <= idx < len(strips):
strip = strips[idx]
n = strip.num_leds
mid = midpoints[idx]
left[idx] = margin
right[idx] = n - margin
last_update = utime.ticks_ms()
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = now
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
strip = strips[idx]
n = strip.num_leds
mid = midpoints[idx]
step = max(1, rate // 2) if idx == 0 else rate
new_left = min(mid - SPAN, left[idx] + step)
new_right = max(mid + SPAN + 1, right[idx] - step)
left_len = max(0, (mid - SPAN) - new_left)
right_len = max(0, new_right - (mid + SPAN + 1))
bright = strip.brightness
ar = strip.ar
# Clear arm regions to black so contracted pixels turn off
for i in range(margin, mid - SPAN):
if 0 <= i < n:
base = i * 3
ar[base] = ar[base + 1] = ar[base + 2] = 0
for i in range(mid + SPAN + 1, n - margin):
if 0 <= i < n:
base = i * 3
ar[base] = ar[base + 1] = ar[base + 2] = 0
for j, i in enumerate(range(new_left, mid - SPAN)):
if 0 <= i < n:
t = 1 - j / (left_len - 1) if left_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
for j, i in enumerate(range(mid + SPAN + 1, new_right)):
if 0 <= i < n:
t = j / (right_len - 1) if right_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
left[idx] = new_left
right[idx] = new_right
strip.show()
# Check if all arms have contracted to center - run once, then hold
all_done = True
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
mid = midpoints[idx]
if left[idx] < mid - SPAN or right[idx] > mid + SPAN + 1:
all_done = False
break
if all_done:
while True:
yield
return
yield

75
pico/src/patterns/pose.py Normal file
View File

@@ -0,0 +1,75 @@
class Pose:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Pose pattern: simple static bands that turn the hoop on
within the specified n ranges, across ALL strips.
Uses the preset's n values as inclusive ranges over the
logical ring (driver.n):
- n1n2: color c[0]
- n3n4: color c[1]
- n5n6: color c[2]
- n7n8: color c[3]
"""
# Base colors (up to 4), missing ones default to black
colors = list(preset.c) if getattr(preset, "c", None) else []
while len(colors) < 4:
colors.append((0, 0, 0))
# Apply preset/global brightness once per color
c1 = self.driver.apply_brightness(colors[0], preset.b)
c2 = self.driver.apply_brightness(colors[1], preset.b)
c3 = self.driver.apply_brightness(colors[2], preset.b)
c4 = self.driver.apply_brightness(colors[3], preset.b)
# Helper to normalize and clamp a range
def norm_range(a, b, max_len):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= max_len:
return None
a = max(0, a)
b = min(max_len - 1, b)
if a > b:
return None
return a, b
# For Pose, apply the same ranges on EVERY strip:
# each color band is repeated across all strips.
for strip in self.driver.strips:
strip_len = strip.num_leds
ranges = []
r1 = norm_range(getattr(preset, "n1", 0), getattr(preset, "n2", -1), strip_len)
if r1:
ranges.append((r1[0], r1[1], c1))
r2 = norm_range(getattr(preset, "n3", 0), getattr(preset, "n4", -1), strip_len)
if r2:
ranges.append((r2[0], r2[1], c2))
r3 = norm_range(getattr(preset, "n5", 0), getattr(preset, "n6", -1), strip_len)
if r3:
ranges.append((r3[0], r3[1], c3))
r4 = norm_range(getattr(preset, "n7", 0), getattr(preset, "n8", -1), strip_len)
if r4:
ranges.append((r4[0], r4[1], c4))
# Static draw on this strip: last range wins on overlaps
for i in range(strip_len):
color = (0, 0, 0)
for start, end, c in ranges:
if start <= i <= end:
color = c
strip.set(i, color)
# Flush all strips
for strip in self.driver.strips:
strip.show()
while True:
yield

View File

@@ -1,51 +1,97 @@
import utime
def _hue_to_rgb(hue):
"""Hue 0..360 -> (r, g, b). Simple HSV with S=V=1."""
h = hue % 360
x = 1 - abs((h / 60) % 2 - 1)
if h < 60:
r, g, b = 1, x, 0
elif h < 120:
r, g, b = x, 1, 0
elif h < 180:
r, g, b = 0, 1, x
elif h < 240:
r, g, b = 0, x, 1
elif h < 300:
r, g, b = x, 0, 1
else:
r, g, b = 1, 0, x
return (int(r * 255), int(g * 255), int(b * 255))
def _make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len_bytes).
DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = (i / n) * 360 * 2
r, g, b = _hue_to_rgb(hue)
double_buf[i * 3] = int(g * brightness) & 0xFF
double_buf[i * 3 + 1] = int(r * brightness) & 0xFF
double_buf[i * 3 + 2] = int(b * brightness) & 0xFF
strip_len_bytes = num_leds * 3
return (double_buf, strip_len_bytes)
def _ensure_buffers(driver, preset, buffers_cache):
"""Build or refresh per-strip double buffers with current brightness. Returns (rainbow_data, cumulative_bytes)."""
effective = (preset.b * driver.b) / (255 * 255)
key = (preset.b, driver.b)
if buffers_cache.get("key") == key and buffers_cache.get("data"):
return buffers_cache["data"], buffers_cache["cumulative_bytes"]
strips = driver.strips
rainbow_data = [_make_rainbow_double(s.num_leds, effective) for s in strips]
cumulative_bytes = [0]
for s in strips:
cumulative_bytes.append(cumulative_bytes[-1] + s.num_leds * 3)
buffers_cache["key"] = key
buffers_cache["data"] = rainbow_data
buffers_cache["cumulative_bytes"] = cumulative_bytes
return rainbow_data, cumulative_bytes
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)
self._buffers_cache = {}
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
step_amount = max(1, int(preset.n1)) # n1 = bytes to advance per frame (speed)
total_ring_bytes = self.driver.num_leds * 3
# Phase in bytes; driver.step kept in 0..255 for compatibility
phase = (self.driver.step * total_ring_bytes) // 256
# If auto is False, run a single step and then stop
rainbow_data, cumulative_bytes = _ensure_buffers(
self.driver, preset, self._buffers_cache
)
strips = self.driver.strips
def show_frame(phase):
for i, (strip, (double_buf, strip_len_bytes)) in enumerate(zip(strips, rainbow_data)):
head = (phase + cumulative_bytes[i]) % strip_len_bytes
strip.show(double_buf, head)
self.driver.step = (phase * 256) // total_ring_bytes
# Single step 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
show_frame(phase)
phase = (phase + step_amount) % total_ring_bytes
self.driver.step = (phase * 256) // total_ring_bytes
yield
return
last_update = utime.ticks_ms()
sleep_ms = max(1, int(preset.d))
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
rainbow_data, cumulative_bytes = _ensure_buffers(
self.driver, preset, self._buffers_cache
)
show_frame(phase)
phase = (phase + step_amount) % total_ring_bytes
last_update = current_time
# Yield once per tick so other logic can run
yield

111
pico/src/patterns/roll.py Normal file
View File

@@ -0,0 +1,111 @@
import utime
import math
class Roll:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Roll: all strips show a shared color gradient palette, cycling out of phase.
- All strips participate; each frame shows N discrete colors
(one per strip), from color1 to color2.
- Over time, each strip cycles through all colors, out of phase with the
others, creating a smooth rolling band around the hoop.
- n4: direction (0 = clockwise, 1 = anti-clockwise)
- c[0]: head color (full intensity)
- c[1]: tail color (usually darker or off)
"""
colors = preset.c
base1 = colors[0] if colors else (255, 255, 255)
base2 = colors[1] if len(colors) > 1 else (0, 0, 0)
color1 = self.driver.apply_brightness(base1, preset.b)
color2 = self.driver.apply_brightness(base2, preset.b)
n_segments = self.driver.n.num_strips if hasattr(self.driver.n, "num_strips") else 1
# n3: number of full rotations before stopping (0 = continuous)
max_rotations = int(getattr(preset, "n3", 0) or 0)
# n4: direction (0=cw, 1=ccw); default clockwise if missing
clockwise = int(getattr(preset, "n4", 0)) == 0
step = self.driver.step
# Precompute one shared buffer per brightness level (one per strip),
# using the longest strip length so any strip can DMA from it safely.
strips_list = self.driver.strips
if not strips_list or n_segments <= 0:
while True:
yield
max_leds = max(s.num_leds for s in strips_list)
# Build N discrete color buffers forming a gradient from color1 to color2.
buffers = []
for j in range(n_segments):
if n_segments > 1:
t = j / (n_segments - 1)
else:
t = 0.0
# Linear interpolation between color1 and color2
r = int(color1[0] + (color2[0] - color1[0]) * t)
g = int(color1[1] + (color2[1] - color1[1]) * t)
b = int(color1[2] + (color2[2] - color1[2]) * t)
buf = bytearray(max_leds * 3)
for i in range(max_leds):
o = i * 3
buf[o] = g & 0xFF
buf[o + 1] = r & 0xFF
buf[o + 2] = b & 0xFF
buffers.append(buf)
def draw(step_index):
# Each strip picks a buffer index offset by its strip index so that:
# - all brightness levels are visible simultaneously (one per strip)
# - over time, each strip cycles through all brightness levels
try:
self.driver.last_roll_head = step_index % n_segments
except AttributeError:
pass
for strip_idx, strip in enumerate(strips_list):
if strip_idx < 0 or strip_idx >= n_segments:
continue
if clockwise:
buf_index = (step_index + strip_idx) % n_segments
else:
buf_index = (step_index - strip_idx) % n_segments
buf = buffers[buf_index]
# Show the shared buffer; WS2812B will read num_leds*3 bytes.
strip.show(buf, 0)
if not preset.a:
draw(step)
self.driver.step = step + 1
yield
return
# Auto mode: advance based on preset.d (ms) for smooth, controllable speed
delay_ms = max(10, int(getattr(preset, "d", 60)) or 10)
last_update = utime.ticks_ms() - delay_ms
rotations_done = 0
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
draw(step)
step += 1
self.driver.step = step
last_update = now
# Count full rotations if requested: one rotation per n_segments steps
if max_rotations > 0 and n_segments > 0 and (step % n_segments) == 0:
rotations_done += 1
if rotations_done >= max_rotations:
# Hold the final frame and stop advancing; keep yielding so
# the generator stays alive without changing the LEDs.
while True:
yield
yield

View File

@@ -0,0 +1,56 @@
import utime
RED = (255, 0, 0)
class ScaleTest:
"""
Animated test for the scale() helper.
A single red pixel moves along the reference strip (strip 0). For each other
strip, the position is mapped using:
n2 = scale(l1, l2, n1)
so that all lit pixels stay aligned by proportional position along the strips.
"""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
if not strips:
return
src_strip_idx = 0
l1 = self.driver.strip_length(src_strip_idx)
if l1 <= 0:
return
step = self.driver.step
delay_ms = max(1, int(getattr(preset, "d", 30) or 30))
last_update = utime.ticks_ms()
color = self.driver.apply_brightness(RED, getattr(preset, "b", 255))
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
n1 = step % l1
# Clear all strips
for strip in strips:
strip.fill((0, 0, 0))
# Light mapped position on each strip using Presets.set/show
for dst_strip_idx, _ in enumerate(strips):
self.driver.set(dst_strip_idx, n1, color)
self.driver.show(dst_strip_idx)
step += 1
self.driver.step = step
last_update = now
yield

View File

@@ -0,0 +1,18 @@
class Segments:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
# Apply preset/global brightness once per color
ranges = [
(preset.n1, preset.n2),
(preset.n3, preset.n4),
(preset.n5, preset.n6),
(preset.n7, preset.n8),
]
for n, color in enumerate(preset.c):
self.driver.fill_n(color, ranges[n][0], ranges[n][1])
self.driver.show_all()

View File

@@ -0,0 +1,136 @@
import utime
class SegmentsTransition:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
SegmentsTransition: fade from whatever is currently on the strips
to a new static Segments layout defined by n1n8 and c[0..3].
- Uses the existing strip buffers as the starting state.
- Target state matches the Segments pattern: up to 4 colored bands
along the logical reference strip, mapped to all physical strips.
- Transition duration is taken from preset.d (ms), minimum 50ms.
"""
strips = self.driver.strips
if not strips:
while True:
yield
# Snapshot starting GRB buffers (already scaled by per-strip brightness)
start_bufs = [bytes(strip.ar) for strip in strips]
# Prepare target buffers (same length as each strip's ar)
target_bufs = [bytearray(len(strip.ar)) for strip in strips]
# Base colors (up to 4), missing ones default to black
colors = list(preset.c) if getattr(preset, "c", None) else []
while len(colors) < 4:
colors.append((0, 0, 0))
# Apply preset/global brightness once per color
bright_colors = [
self.driver.apply_brightness(colors[0], preset.b),
self.driver.apply_brightness(colors[1], preset.b),
self.driver.apply_brightness(colors[2], preset.b),
self.driver.apply_brightness(colors[3], preset.b),
]
# Logical reference length for all strips (from scale_map[0])
ref_len = len(self.driver.scale_map[0]) if self.driver.scale_map else 0
if ref_len <= 0:
# Fallback: nothing to do, just hold current state
while True:
yield
# Helper to clamp and normalize a logical range [a, b] (inclusive) over ref_len.
# Returns (start, end_exclusive) suitable for range(start, end_exclusive).
def norm_range(a, b):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= ref_len:
return None
a = max(0, a)
b = min(ref_len - 1, b)
if a > b:
return None
return a, b + 1
raw_ranges = [
(getattr(preset, "n1", 0), getattr(preset, "n2", -1), bright_colors[0]),
(getattr(preset, "n3", 0), getattr(preset, "n4", -1), bright_colors[1]),
(getattr(preset, "n5", 0), getattr(preset, "n6", -1), bright_colors[2]),
(getattr(preset, "n7", 0), getattr(preset, "n8", -1), bright_colors[3]),
]
# Build target buffers using the same logical indexing idea as Segments
for strip_idx, strip in enumerate(strips):
bright = strip.brightness
scale_map = self.driver.scale_map[strip_idx]
buf = target_bufs[strip_idx]
n_leds = strip.num_leds
# Start from black everywhere
for i in range(len(buf)):
buf[i] = 0
# Apply each logical range to this strip
for a, b, color in raw_ranges:
rng = norm_range(a, b)
if not rng:
continue
start, end = rng
r, g, bl = color
for logical_idx in range(start, end):
if logical_idx < 0 or logical_idx >= len(scale_map):
continue
phys_idx = scale_map[logical_idx]
if phys_idx < 0 or phys_idx >= n_leds:
continue
base = phys_idx * 3
if base + 2 >= len(buf):
continue
buf[base] = int(g * bright)
buf[base + 1] = int(r * bright)
buf[base + 2] = int(bl * bright)
# Duration in ms for the whole transition (slower by default)
# If preset.d is provided, use it; otherwise default to a slow 3000ms fade.
raw_d = int(getattr(preset, "d", 3000) or 3000)
duration = max(1000, raw_d) # enforce at least 1s for a clearly visible transition
start_time = utime.ticks_ms()
while True:
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# Final frame: commit target buffers and hold, then update all strips together
for strip, target in zip(strips, target_bufs):
ar = strip.ar
for i in range(len(ar)):
ar[i] = target[i]
self.driver.show_all()
while True:
yield
# Interpolation factor in [0,1]
factor = elapsed / duration
inv = 1.0 - factor
# Blend from start to target in GRB space per byte
for idx, strip in enumerate(strips):
start_buf = start_bufs[idx]
target_buf = target_bufs[idx]
ar = strip.ar
for i in range(len(ar)):
ar[i] = int(start_buf[i] * inv + target_buf[i] * factor)
self.driver.show_all()
yield

101
pico/src/patterns/spin.py Normal file
View File

@@ -0,0 +1,101 @@
"""Spin: continues from Grab — segment (10 each side of center) moves slowly up to the top. Preset color, n1 = rate."""
import utime
SPAN = 0 # LEDs on each side of center (match Grab)
LUT_SIZE = 256 # gradient lookup table entries
class Spin:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
self.driver.fill((0, 0, 0))
self.driver.show_all()
active_indices = (0, 4)
c0 = preset.c[0]
c1 = preset.c[1]
# Precompute gradient LUT: t in [0,1] maps to (r,g,b)
lut = []
for k in range(LUT_SIZE):
t = k / (LUT_SIZE - 1) if LUT_SIZE > 1 else 1
r = int(c0[0] + (c1[0] - c0[0]) * t)
g = int(c0[1] + (c1[1] - c0[1]) * t)
b = int(c0[2] + (c1[2] - c0[2]) * t)
lut.append((r, g, b))
# For each active strip we expand from just outside the grab center
# left: from (mid - SPAN) down to 0
# right: from (mid + SPAN) up to end
midpoints = self.driver.strip_midpoints
rate = max(1, int(preset.n1) or 1)
delay_ms = max(1, int(preset.d) or 1)
margin = max(0, int(preset.n2) or 0)
# Track current extents of each arm
left = {}
right = {}
for idx in active_indices:
if 0 <= idx < len(strips):
mid = midpoints[idx]
left[idx] = mid - SPAN # inner edge of left arm
right[idx] = mid + SPAN + 1 # inner edge of right arm
last_update = utime.ticks_ms()
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = now
for idx in active_indices:
if idx < 0 or idx >= len(strips):
continue
strip = strips[idx]
n = strip.num_leds
mid = midpoints[idx]
# Expand arms at the same rate on both sides
step = max(1, rate)
new_left = max(margin, left[idx] - step)
new_right = min(n - margin, right[idx] + step)
# Left arm: c1 at outer, c0 at inner. Right arm: c0 at inner, c1 at outer.
left_len = max(0, (mid - SPAN) - new_left)
right_len = max(0, new_right - (mid + SPAN + 1))
bright = strip.brightness
ar = strip.ar
for j, i in enumerate(range(new_left, mid - SPAN)):
if 0 <= i < n:
t = 1 - j / (left_len - 1) if left_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
for j, i in enumerate(range(mid + SPAN + 1, new_right)):
if 0 <= i < n:
t = j / (right_len - 1) if right_len > 1 else 0
lut_idx = min(int(t * (LUT_SIZE - 1)), LUT_SIZE - 1)
r, g, b = lut[lut_idx]
base = i * 3
ar[base] = int(g * bright)
ar[base + 1] = int(r * bright)
ar[base + 2] = int(b * bright)
left[idx] = new_left
right[idx] = new_right
# Show only on this strip
strip.show()
yield

28
pico/src/patterns/test.py Normal file
View File

@@ -0,0 +1,28 @@
"""Test pattern: strip i has (i+1) LEDs on at the start (indices 0..i) plus the midpoint LED on. 50% red."""
BRIGHTNESS = 0.50
RED = (255, 0, 0)
def _scale(color, factor):
return tuple(int(c * factor) for c in color)
class Test:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
strips = self.driver.strips
red = _scale(RED, BRIGHTNESS)
for strip_idx, strip in enumerate(strips):
n = strip.num_leds
mid = self.driver.strip_midpoints[strip_idx] # from STRIP_CONFIG
strip.fill((0, 0, 0))
# First (strip_idx + 1) LEDs on: indices 0..strip_idx
for i in range(min(strip_idx + 1, n)):
strip.set(i, red)
# Midpoint LED on
strip.set(mid, red)
strip.show()

View File

@@ -12,6 +12,8 @@ class Preset:
self.n4 = 0
self.n5 = 0
self.n6 = 0
self.n7 = 0
self.n8 = 0
# Override defaults with provided data
self.edit(data)
@@ -76,4 +78,6 @@ class Preset:
"n4": self.n4,
"n5": self.n5,
"n6": self.n6,
"n7": self.n7,
"n8": self.n8,
}

View File

@@ -1,20 +1,94 @@
from machine import Pin
from ws2812 import WS2812B
from preset import Preset
from patterns import Blink, Rainbow, Pulse, Transition, Chase, Circle
from patterns import (
Blink,
Rainbow,
Pulse,
Transition,
Chase,
Circle,
DoubleCircle,
Roll,
Calibration,
Test,
Grab,
Spin,
Lift,
Flare,
Hook,
Pose,
Segments,
SegmentsTransition,
)
import json
class _LogicalRing:
"""
Lightweight logical ring over all strips.
Used by patterns that expect driver.n (e.g. Circle, Roll legacy API).
"""
def __init__(self, driver):
self._driver = driver
self.num_strips = len(driver.strips)
def __len__(self):
return self._driver.num_leds
def fill(self, color):
# Apply color to all logical positions across all strips
for i in range(self._driver.num_leds):
for strip_idx in range(self.num_strips):
self._driver.set(strip_idx, i, color)
def __setitem__(self, index, color):
if index < 0 or index >= self._driver.num_leds:
return
for strip_idx in range(self.num_strips):
self._driver.set(strip_idx, index, color)
def write(self):
self._driver.show_all()
# Order: strips[0]=physical 1 … strips[7]=physical 8. (pin, num_leds, midpoint_index).
STRIP_CONFIG = (
(6, 291, 291 // 2), # 1
(29, 290, 290 // 2-1), # 2
(3, 283, 283 // 2), # 3
(28, 278, 278 // 2-1), # 4
(2, 278, 275 // 2), # 5 (bottom of hoop)
(0, 283, 278 // 2-1), # 6
(4, 290, 283 // 2), # 7
(7, 291, 290 // 2-1), # 8
)
class Presets:
def __init__(self, pin, num_leds, state_machine=0):
def __init__(self):
self.scale_map = []
self.strips = []
self.strip_midpoints = [] # midpoint LED index per strip (from STRIP_CONFIG)
state_machine = 0
for entry in STRIP_CONFIG:
pin, num_leds = entry[0], entry[1]
mid = entry[2] if len(entry) >= 3 else num_leds // 2
self.strip_midpoints.append(mid)
self.strips.append(WS2812B(num_leds, pin, state_machine, brightness=1.0))
state_machine += 1
self.scale_map.append(self.create_scale_map(num_leds))
# Single logical strip using strip 0 as reference for patterns (n[i], .fill(), .write())
# WS2812B with brightness=1.0 so Presets.apply_brightness() does all scaling (NeoPixel drop-in)
num_leds = int(num_leds)
if isinstance(pin, Pin):
self.n = WS2812B(pin, num_leds) # NeoPixel-style (Pin, n)
else:
self.n = WS2812B(num_leds, int(pin), state_machine, brightness=1.0)
self.num_leds = num_leds
# Reference logical length for patterns that use driver.num_leds (Rainbow/Chase/Circle, etc.)
self.num_leds = self.strips[0].num_leds if self.strips else 0
# Legacy logical ring interface for patterns expecting driver.n
self.n = _LogicalRing(self)
self.step = 0
# Remember which strip was last used as the roll head (for flare, etc.)
self.last_roll_head = 0
# Global brightness (0255), controlled via UART/JSON {"b": <value>}
self.b = 255
@@ -32,8 +106,29 @@ class Presets:
"transition": Transition(self).run,
"chase": Chase(self).run,
"circle": Circle(self).run,
"double_circle": DoubleCircle(self).run,
"roll": Roll(self).run,
"calibration": Calibration(self).run,
"test": Test(self).run,
"grab": Grab(self).run,
"spin": Spin(self).run,
"lift": Lift(self).run,
"flare": Flare(self).run,
"hook": Hook(self).run,
"pose": Pose(self).run,
"segments": Segments(self).run,
"segments_transition": SegmentsTransition(self).run,
"point": Segments(self).run, # backwards-compatible alias
}
# --- Strip geometry utilities -------------------------------------------------
def strip_length(self, strip_idx):
"""Return number of LEDs for a physical strip index."""
if 0 <= strip_idx < len(self.strips):
return self.strips[strip_idx].num_leds
return 0
def save(self):
"""Save the presets to a file."""
with open("presets.json", "w") as f:
@@ -87,27 +182,26 @@ class Presets:
self.generator = None
def select(self, preset_name, step=None):
if preset_name is None:
return False
print(f"Selecting preset: {preset_name}")
preset = None
pattern_key = preset_name
if preset_name in self.presets:
preset = self.presets[preset_name]
if preset.p in self.patterns:
# Set step value if explicitly provided
if step is not None:
self.step = step
elif preset.p == "off" or 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
return True
# If preset doesn't exist or pattern not found, default to "off"
return False
def update_num_leds(self, pin, num_leds):
num_leds = int(num_leds)
if isinstance(pin, Pin):
self.n = WS2812B(pin, num_leds)
else:
self.n = WS2812B(num_leds, int(pin), 0, brightness=1.0)
self.num_leds = num_leds
pattern_key = preset.p
if pattern_key not in self.patterns:
return False
# Run by pattern name (works for saved presets and built-ins like calibration, off, test)
if preset is None:
preset = Preset({"p": pattern_key}) if pattern_key != "off" else None
if step is not None:
self.step = step
elif pattern_key == "off" or self.selected != preset_name:
self.step = 0
self.generator = self.patterns[pattern_key](preset)
self.selected = preset_name
return True
def apply_brightness(self, color, brightness_override=None):
# Combine per-preset brightness (override) with global brightness self.b
@@ -116,16 +210,36 @@ class Presets:
effective_brightness = int(local * self.b / 255)
return tuple(int(c * effective_brightness / 255) for c in color)
def fill(self, color=None):
fill_color = color if color is not None else (0, 0, 0)
for i in range(self.num_leds):
self.n[i] = fill_color
self.n.write()
def off(self, preset=None):
self.fill((0, 0, 0))
self.show_all()
def on(self, preset):
colors = preset.c
color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.b))
self.show_all()
def fill(self, color):
for strip in self.strips:
strip.fill(color)
def fill_n(self, color, n1, n2):
for i in range(n1, n2):
for strip_idx in range(8):
self.set(strip_idx, i, color)
def set(self, strip, index, color):
if index >= self.strips[0].num_leds:
return False
self.strips[strip].set(self.scale_map[strip][index], color)
return True
def create_scale_map(self, num_leds):
ref_len = STRIP_CONFIG[0][1]
return [int(i * num_leds / ref_len) for i in range(ref_len)]
def show_all(self):
for strip in self.strips:
strip.show()

View File

@@ -1,94 +0,0 @@
import json
import ubinascii
import machine
class Settings(dict):
SETTINGS_FILE = "/settings.json"
def __init__(self):
super().__init__()
self.load() # Load settings from file during initialization
self.color_order = self.get_color_order(self["color_order"])
def _default_name(self):
"""Device name: use unique_id on Pico (no WiFi); use AP MAC on ESP32."""
try:
import network
mac = network.WLAN(network.AP_IF).config("mac")
return "led-%s" % ubinascii.hexlify(mac).decode()
except Exception:
return "led-%s" % ubinascii.hexlify(machine.unique_id()).decode()
def set_defaults(self):
self["led_pin"] = 10
self["num_leds"] = 50
self["color_order"] = "rgb"
self["name"] = self._default_name()
self["debug"] = False
self["startup_preset"] = None
self["brightness"] = 255
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}")
def load(self):
try:
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
self.set_defaults()
self.save()
def get_color_order(self, color_order):
"""Convert color order string to tuple of hex string indices."""
color_orders = {
"rgb": (1, 3, 5),
"rbg": (1, 5, 3),
"grb": (3, 1, 5),
"gbr": (3, 5, 1),
"brg": (5, 1, 3),
"bgr": (5, 3, 1)
}
return color_orders.get(color_order.lower(), (1, 3, 5)) # Default to RGB
def get_rgb_channel_order(self, color_order=None):
"""Convert color order string to RGB channel indices for reordering tuples.
Returns tuple of channel indices: (r_channel, g_channel, b_channel)
Example: 'grb' -> (1, 0, 2) means (G, R, B)"""
if color_order is None:
color_order = self.get("color_order", "rgb")
color_order = color_order.lower()
# Map hex string positions to RGB channel indices
# Position 1 (R in hex) -> channel 0, Position 3 (G) -> channel 1, Position 5 (B) -> channel 2
hex_to_channel = {1: 0, 3: 1, 5: 2}
hex_indices = self.get_color_order(color_order)
return tuple(hex_to_channel[pos] for pos in hex_indices)
# Example usage
def main():
settings = Settings()
print(f"Number of LEDs: {settings['num_leds']}")
settings['num_leds'] = 100
print(f"Updated number of LEDs: {settings['num_leds']}")
settings.save()
# Create a new Settings object to test loading
new_settings = Settings()
print(f"Loaded number of LEDs: {new_settings['num_leds']}")
print(settings)
# Run the example
if __name__ == "__main__":
main()

79
pico/test/chase.py Normal file
View File

@@ -0,0 +1,79 @@
import sys
if "lib" not in sys.path:
sys.path.insert(0, "lib")
if "../lib" not in sys.path:
sys.path.insert(0, "../lib")
from ws2812 import WS2812B
import time
# --- Chase test: pregenerated double buffer per strip, show via head offset (same as rainbow) ---
# (pin, num_leds) per strip — same config as rainbow
STRIP_CONFIG = (
(2, 291),
(3, 290),
(4, 283),
(7, 278),
(0, 275),
(28, 278),
(29, 283),
(6, 290),
)
strips = []
sm = 0
for pin, num_leds in STRIP_CONFIG:
print(pin, num_leds)
ws = WS2812B(num_leds, pin, sm, brightness=1.0)
strips.append(ws)
sm += 1
cumulative_leds = [0]
for ws in strips[:-1]:
cumulative_leds.append(cumulative_leds[-1] + ws.num_leds)
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
# Chase: color1 n1 long, then color2 n2 long, stepping n3 pixels
COLOR1 = (255, 0, 0) # red
COLOR2 = (0, 0, 255) # blue
N1 = 24 # length of color1 segment
N2 = 24 # length of color2 segment
STEP = 1 # step size in pixels per frame
def make_chase_double(num_leds, cumulative_leds, total_ring_leds, color1, color2, n1, n2):
"""Pregenerate strip double buffer with repeating segments:
color1 for n1 pixels, then color2 for n2 pixels, around the full ring. GRB order."""
n = 2 * num_leds
buf = bytearray(n * 3)
pattern_len = n1 + n2
for b in range(n):
# Position of this pixel along the logical ring
pos = (2 * cumulative_leds - b) % total_ring_leds
seg_pos = pos % pattern_len
if seg_pos < n1:
r, grn, b_ = color1[0], color1[1], color1[2]
else:
r, grn, b_ = color2[0], color2[1], color2[2]
o = b * 3
buf[o] = grn
buf[o + 1] = r
buf[o + 2] = b_
return buf
# Pregenerate one double buffer per strip
chase_buffers = [
make_chase_double(ws.num_leds, cumulative_leds[i], total_ring_leds, COLOR1, COLOR2, N1, N2)
for i, ws in enumerate(strips)
]
chase_pos = 0
while True:
for i, strip in enumerate(strips):
# head in [0, strip_len) so DMA read head..head+num_leds*3 stays in double buffer (same as rainbow)
strip_len = strip.num_leds * 3
head = (chase_pos + cumulative_leds[i]) * 3 % strip_len
strip.show(chase_buffers[i], head)
chase_pos = (chase_pos + STEP) % total_ring_leds
time.sleep_ms(40)

View File

@@ -28,13 +28,13 @@ def hue_to_rgb(hue):
return (int(r * 255), int(g * 255), int(b * 255))
def make_rainbow_ring(total_leds, brightness=1.0):
"""Build one rainbow over the whole ring: 2 full hue cycles over total_leds (GRB).
Returns (double_buf, ring_len_bytes). All strips sample from this so phase is continuous."""
n = 2 * total_leds
def make_rainbow_double(num_leds, brightness=1.0):
"""Build 2 full rainbow cycles (2*num_leds pixels, GRB). Returns (double_buf, strip_len).
head must be in 0..strip_len-1 so DMA reads double_buf[head:head+strip_len] with no copy."""
n = 2 * num_leds
double_buf = bytearray(n * 3)
for i in range(n):
hue = ((i % total_leds) / total_leds) * 360 * 2
hue = (i / n) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
@@ -43,48 +43,27 @@ def make_rainbow_ring(total_leds, brightness=1.0):
double_buf[o] = g
double_buf[o + 1] = r
double_buf[o + 2] = b
ring_len_bytes = total_leds * 3
return (double_buf, ring_len_bytes)
strip_len = num_leds * 3
return (double_buf, strip_len)
def make_strip_rainbow(num_leds, cumulative_leds, total_ring_leds, brightness=1.0):
"""Per-strip double buffer: pixel j has hue at global position (cumulative_leds + j) % total_ring_leds.
Use same head for all strips: head = rainbow_head % (2*num_leds*3)."""
n = 2 * num_leds
buf = bytearray(n * 3)
for j in range(n):
global_pos = (cumulative_leds + j) % total_ring_leds
hue = (global_pos / total_ring_leds) * 360 * 2
r, g, b = hue_to_rgb(hue)
g = int(g * brightness) & 0xFF
r = int(r * brightness) & 0xFF
b = int(b * brightness) & 0xFF
o = j * 3
buf[o] = g
buf[o + 1] = r
buf[o + 2] = b
strip_len_bytes = num_leds * 3
return (buf, strip_len_bytes)
def show_rainbow(strip, double_buf, strip_len, head):
"""DMA reads directly from double_buf at head; no copy. head in 0..strip_len-1."""
strip.show(double_buf, head)
def show_rainbow_segment(strip, buf, strip_len_bytes, head):
"""DMA reads strip's segment from buf at head."""
strip.show(buf, head)
# --- Strips + one global ring rainbow (all strips in phase) ---
# Each strip can have a different length; one rainbow spans total_ring_leds so hue is continuous.
# (pin, num_leds) per strip — lengths differ per segment
# --- Strips + rainbow buffers per strip ---
# Each strip can have a different length; buffers and phase are per-strip.
# Strip config must match pico/src/main.py pins.
STRIP_CONFIG = (
(2, 291),
(7, 291),
(3, 290),
(4, 283),
(7, 278),
(0, 275),
(6, 283),
(28, 278),
(29, 283),
(6, 290),
(29, 275),
(4, 278),
(0, 283),
(2, 290),
)
strips = []
@@ -102,24 +81,19 @@ for ws in strips[:-1]:
total_ring_leds = cumulative_leds[-1] + strips[-1].num_leds
bytes_per_cycle = total_ring_leds * 3
# Per-strip rainbow buffers: each strip's segment of the ring (same phase, no shared-buffer DMA)
# One rainbow double buffer per strip (length = 2 * num_leds for that strip)
now = time.ticks_ms()
rainbow_data = [
make_strip_rainbow(ws.num_leds, cumulative_leds[i], total_ring_leds, ws.brightness)
for i, ws in enumerate(strips)
]
rainbow_data = [make_rainbow_double(ws.num_leds, ws.brightness) for ws in strips]
# Global phase in bytes; each strip: head = (phase + cumulative_leds[i]*3) % strip_len[i]
print(time.ticks_diff(time.ticks_ms(), now), "ms")
rainbow_head = 0
step = 3
while True:
now = time.ticks_ms()
for i, (strip, (buf, strip_len_bytes)) in enumerate(zip(strips, rainbow_data)):
# Same head for all: each strip's buffer is already offset by cumulative_leds[i]
double_len_bytes = 2 * strip.num_leds * 3
head = rainbow_head % double_len_bytes
show_rainbow_segment(strip, buf, strip_len_bytes, head)
for i, (strip, (double_buf, strip_len)) in enumerate(zip(strips, rainbow_data)):
head = (rainbow_head + cumulative_leds[i] * 3) % strip_len
show_rainbow(strip, double_buf, strip_len, head)
rainbow_head = (rainbow_head + step) % bytes_per_cycle
#print(time.ticks_diff(time.ticks_ms(), now), "ms")
time.sleep_ms(10)

114
pico/test/roll.py Normal file
View File

@@ -0,0 +1,114 @@
"""
On-device visual test for the Roll pattern via Presets.
This exercises src/patterns/roll.py (gradient from color1 to color2 across strips),
not the low-level WS2812 driver.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/roll.py :
mpremote connect <device> run roll.py
"""
import utime
from presets import Presets, Preset
def make_roll_preset(name, color1, color2, delay_ms=60, brightness=255, direction=0):
"""
Helper to build a Preset for the 'roll' pattern.
- color1: head color (full intensity)
- color2: tail color (end of gradient)
- direction: 0 = clockwise, 1 = anti-clockwise
"""
data = {
"p": "roll",
"c": [color1, color2],
"b": brightness,
"d": delay_ms,
"n4": direction,
"a": True, # animated
}
return name, Preset(data)
def run_for(presets, duration_ms):
"""Tick the current pattern for duration_ms."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
presets.tick()
utime.sleep_ms(10)
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting roll test.")
return
print("Starting roll pattern gradient tests via Presets...")
# A few different roll presets to compare:
roll_presets = []
# 1. White → off, clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_white_off_cw",
color1=(255, 255, 255),
color2=(0, 0, 0),
delay_ms=120,
brightness=128,
direction=0,
)
)
# 2. Warm white → cool blue, clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_warm_cool_cw",
color1=(255, 200, 100),
color2=(0, 0, 255),
delay_ms=130,
brightness=128,
direction=0,
)
)
# 3. Red → green, counter-clockwise (50% brightness, faster)
roll_presets.append(
make_roll_preset(
"roll_red_green_ccw",
color1=(255, 0, 0),
color2=(0, 255, 0),
delay_ms=110,
brightness=128,
direction=1,
)
)
# Register presets and run them one after another
for name, preset_obj in roll_presets:
presets.presets[name] = preset_obj
for name, _preset in roll_presets:
print("Running roll preset:", name)
presets.select(name)
run_for(presets, duration_ms=8000)
print("Roll pattern Presets test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

6
pico/test/test_all_on.py Normal file
View File

@@ -0,0 +1,6 @@
from neopixel import NeoPixel
from machine import Pin
p = NeoPixel(Pin(6), 291)
p.fill((255, 255, 255))
p.write()

View File

@@ -0,0 +1,71 @@
"""
On-device test that turns all LEDs on via Presets and verifies strip 0 (pin 6).
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_all_on_presets.py :
mpremote connect <device> run test_all_on_presets.py
"""
from presets import Presets, Preset
def verify_strip0_on(presets, expected_color):
"""Check that every LED on strip 0 matches expected_color."""
if not presets.strips:
print("No strips; skipping strip-0 on test.")
return
strip = presets.strips[0]
r_exp, g_exp, b_exp = expected_color
for i in range(strip.num_leds):
o = i * 3
g = strip.ar[o]
r = strip.ar[o + 1]
b = strip.ar[o + 2]
if (r, g, b) != (r_exp, g_exp, b_exp):
raise AssertionError(
"Strip 0 LED %d: got (%d,%d,%d), expected (%d,%d,%d)"
% (i, r, g, b, r_exp, g_exp, b_exp)
)
def main():
presets = Presets()
if not presets.strips:
print("No strips; skipping all-on-presets test.")
return
# Full-brightness white via the built-in 'on' pattern.
base_color = (255, 255, 255)
brightness = 255
data = {
"p": "on",
"c": [base_color],
"b": brightness,
}
name = "test_all_on_presets"
preset = Preset(data)
presets.presets[name] = preset
presets.select(name)
presets.tick()
# Compute the color actually written by the pattern after brightness scaling.
expected_color = presets.apply_brightness(base_color, brightness)
verify_strip0_on(presets, expected_color)
print("test_all_on_presets: OK")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,184 @@
"""
On-device test that exercises the Chase pattern via Presets.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_chase_via_presets.py :
mpremote connect <device> run test_chase_via_presets.py
"""
import utime
from presets import Presets, Preset
def snapshot_strip_colors(presets, strip_idx=0, max_leds=32):
"""Return a list of (r,g,b) tuples for the first max_leds of the given strip."""
strip = presets.strips[strip_idx]
num = min(strip.num_leds, max_leds)
out = []
for i in range(num):
o = i * 3
g = strip.ar[o]
r = strip.ar[o + 1]
b = strip.ar[o + 2]
out.append((r, g, b))
return out
def expected_chase_color(i, num_leds, step_count, color0, color1, n1, n2, n3, n4):
"""Mirror the position logic from patterns/chase.py for a single logical LED."""
segment_length = n1 + n2
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
max_pos = num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
return color0 if relative_pos < n1 else color1
def test_chase_single_step_via_presets():
presets = Presets()
num_leds = presets.num_leds
if num_leds <= 0:
print("No strips; skipping chase test.")
return
# Simple alternating colors with known lengths.
base_color0 = (10, 0, 0)
base_color1 = (0, 0, 20)
# Use full brightness so apply_brightness is identity.
brightness = 255
n1 = 2
n2 = 3
# Same step size on even/odd for easier reasoning.
n3 = 1
n4 = 1
data = {
"p": "chase",
"c": [base_color0, base_color1],
"b": brightness,
"d": 0,
"a": False, # single-step mode
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
}
name = "test_chase_pattern"
preset = Preset(data)
presets.presets[name] = preset
# Select and run one tick; this should render exactly one chase frame for step 0.
presets.select(name, step=0)
presets.tick()
# Colors after brightness scaling (driver.apply_brightness is used in the pattern).
color0 = presets.apply_brightness(base_color0, brightness)
color1 = presets.apply_brightness(base_color1, brightness)
# Snapshot first few LEDs of strip 0 and compare against expected pattern for step 0.
colors = snapshot_strip_colors(presets, strip_idx=0, max_leds=16)
step_count = 0
for i, actual in enumerate(colors):
expected = expected_chase_color(
i, num_leds, step_count, color0, color1, n1, n2, n3, n4
)
assert (
actual == expected
), "LED %d: got %r, expected %r" % (i, actual, expected)
print("test_chase_single_step_via_presets: OK")
def test_chase_multiple_steps_via_presets():
"""Render several steps and verify pattern advances correctly."""
presets = Presets()
num_leds = presets.num_leds
if num_leds <= 0:
print("No strips; skipping chase multi-step test.")
return
base_color0 = (10, 0, 0)
base_color1 = (0, 0, 20)
brightness = 255
n1 = 2
n2 = 3
n3 = 1
n4 = 1
data = {
"p": "chase",
"c": [base_color0, base_color1],
"b": brightness,
"d": 0,
"a": False,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
}
name = "test_chase_pattern_multi"
preset = Preset(data)
presets.presets[name] = preset
color0 = presets.apply_brightness(base_color0, brightness)
color1 = presets.apply_brightness(base_color1, brightness)
# In non-auto mode (a=False), the Chase pattern advances one step per
# invocation of the generator, and Presets is expected to call select()
# again for each beat. Emulate that here by re-selecting with an
# explicit step value for each frame we want to test.
for step_count in range(4):
presets.select(name, step=step_count)
presets.tick()
colors = snapshot_strip_colors(presets, strip_idx=0, max_leds=16)
for i, actual in enumerate(colors):
expected = expected_chase_color(
i, num_leds, step_count, color0, color1, n1, n2, n3, n4
)
assert (
actual == expected
), "step %d, LED %d: got %r, expected %r" % (
step_count,
i,
actual,
expected,
)
print("test_chase_multiple_steps_via_presets: OK")
def main():
test_chase_single_step_via_presets()
test_chase_multiple_steps_via_presets()
# Give a brief pause so message is visible if run interactively.
utime.sleep_ms(100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,157 @@
"""
On-device test for the double_circle pattern using mpremote.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_double_circle.py :
mpremote connect <device> run test_double_circle.py
This script:
- Instantiates Presets
- Creates a few in-memory 'double_circle' presets with different centers, widths, and colors
- Selects each one so you can visually confirm the symmetric bands and color gradients
"""
from presets import Presets, Preset
def make_double_circle_preset(
name, center, half_width, colors, direction=0, step_size=1, brightness=255
):
"""
Helper to build a Preset for the 'double_circle' pattern.
center: logical index (0-based, on reference strip 0)
half_width: number of LEDs each side of center
colors: [color1, color2] where each color is (r,g,b)
"""
cs = list(colors)[:2]
while len(cs) < 2:
cs.append((0, 0, 0))
data = {
"p": "double_circle",
"c": cs,
"b": brightness,
"n1": center,
"n2": half_width,
"n3": direction,
"n4": step_size,
}
return name, Preset(data)
def show_and_wait(presets, name, preset_obj, wait_ms):
"""Select a static double_circle preset and hold it for wait_ms."""
presets.presets[name] = preset_obj
presets.select(name)
# DoubleCircle draws immediately in run(), then just yields; one tick is enough.
presets.tick()
import utime
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < wait_ms:
presets.tick()
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting double_circle test.")
return
print("Starting double_circle pattern test...")
quarter = num_leds // 4
half = num_leds // 2
dc_presets = []
# 1. Center at top (0), moderate width, color1 at center (n3=0)
dc_presets.append(
make_double_circle_preset(
"dc_top_red_to_blue",
center=0,
half_width=quarter,
colors=[(255, 0, 0), (0, 0, 255)],
direction=0,
)
)
# 2. Center at bottom (half), narrow band, color1 at endpoints (n3=1)
dc_presets.append(
make_double_circle_preset(
"dc_bottom_green_to_purple",
center=half,
half_width=quarter // 2,
colors=[(0, 255, 0), (128, 0, 128)],
direction=1,
)
)
# 3. Center at quarter, wide band, both directions for comparison
dc_presets.append(
make_double_circle_preset(
"dc_quarter_white_to_cyan_inward",
center=quarter,
half_width=half,
colors=[(255, 255, 255), (0, 255, 255)],
direction=0,
)
)
dc_presets.append(
make_double_circle_preset(
"dc_quarter_white_to_cyan_outward",
center=quarter,
half_width=half,
colors=[(255, 255, 255), (0, 255, 255)],
direction=1,
)
)
# 4. Explicit test: n1 = 50, n2 = 40 (half of 80) inward
dc_presets.append(
make_double_circle_preset(
"dc_n1_50_n2_40_inward",
center=50,
half_width=40,
colors=[(255, 100, 0), (0, 0, 0)],
direction=0,
)
)
# 5. Explicit test: n1 = num_leds//2, n2 = num_leds//4 outward, stepping as fast as possible
center_half = num_leds // 2
radius_quarter = max(1, num_leds // 4)
dc_presets.append(
make_double_circle_preset(
"dc_n1_half_n2_quarter_outward",
center=center_half,
half_width=radius_quarter,
colors=[(0, 150, 255), (0, 0, 0)],
direction=1,
step_size=radius_quarter, # jump to full radius in one step
)
)
# Show each for ~4 seconds
for name, preset_obj in dc_presets:
print("Showing double_circle preset:", name)
show_and_wait(presets, name, preset_obj, wait_ms=4000)
print("Double_circle pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

52
pico/test/test_fill_n.py Normal file
View File

@@ -0,0 +1,52 @@
"""
On-device test for Presets.fill_n() using mpremote.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_fill_n.py :
mpremote connect <device> run test_fill_n.py
This script:
- Instantiates Presets
- Calls fill_n() with a simple range
- Lets you visually confirm that all strips show the same proportional segment
and that equal-length strip pairs have identical lit indices.
"""
from presets import Presets
def main():
presets = Presets()
presets.load()
# Choose a simple test range on the reference strip (strip 0).
ref_len = presets.strip_length(0)
if ref_len <= 0:
print("No strips or invalid length; aborting fill_n test.")
return
# Use a central segment so it's easy to see.
start = ref_len // 4
end = 3 * ref_len // 4
print("Running fill_n test from", start, "to", end, "on reference strip 0.")
color = (0, 50, 0) # dim green
# First, clear everything
for strip in presets.strips:
strip.fill((0, 0, 0))
strip.show()
# Apply fill_n, which will use scale() internally.
presets.fill_n(color, start, end)
print("fill_n test applied; visually inspect strips.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,264 @@
"""
On-device test that exercises Segments and multiple SegmentsTransition presets via Presets.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_multi_patterns.py :
mpremote connect <device> run test_multi_patterns.py
"""
import utime
from presets import Presets, Preset
def run_for(presets, duration_ms):
"""Tick the current pattern for duration_ms."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
presets.tick()
utime.sleep_ms(10)
def make_segments_preset(name, colors, n_values, brightness=255):
"""
Helper to build a Preset for the 'segments' pattern.
colors: list of up to 4 (r,g,b) tuples
n_values: list/tuple of 8 ints [n1..n8]
"""
cs = list(colors)[:4]
while len(cs) < 4:
cs.append((0, 0, 0))
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "segments",
"c": cs,
"b": brightness,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
}
return name, Preset(data)
def make_segments_transition_preset(name, colors, n_values, duration_ms=1000, brightness=255):
"""
Helper to build a Preset for the 'segments_transition' pattern.
Starts from whatever is currently displayed and fades to the
new segments layout over duration_ms.
"""
cs = list(colors)[:4]
while len(cs) < 4:
cs.append((0, 0, 0))
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "segments_transition",
"c": cs,
"b": brightness,
"d": duration_ms,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
}
return name, Preset(data)
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting multi-pattern test.")
return
print("Starting multi-pattern test with Presets...")
quarter = num_leds // 4
half = num_leds // 2
# 1. Static segments: simple R/G/B bands
name_static, preset_static = make_segments_preset(
"mp_segments_static",
colors=[(255, 0, 0), (0, 255, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red
quarter,
2 * quarter - 1, # green
2 * quarter,
3 * quarter - 1, # blue
0,
-1,
],
)
# 2a. Segments transition: fade from previous buffer to new colors (slow)
name_trans1, preset_trans1 = make_segments_transition_preset(
"mp_segments_transition_1",
colors=[(255, 255, 255), (255, 0, 255), (0, 255, 255)],
n_values=[
0,
half - 1, # white on first half
half,
num_leds - 1, # magenta on second half
0,
-1, # cyan unused in this example
0,
-1,
],
duration_ms=3000,
)
# 2b. Segments transition: fade between two different segment layouts
name_trans2, preset_trans2 = make_segments_transition_preset(
"mp_segments_transition_2",
colors=[(255, 0, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red first quarter
quarter,
2 * quarter - 1, # blue second quarter
0,
-1,
0,
-1,
],
duration_ms=4000,
)
# 2c. Segments transition: thin moving band (center quarter only)
band_start = quarter // 2
band_end = band_start + quarter
name_trans3, preset_trans3 = make_segments_transition_preset(
"mp_segments_transition_3",
colors=[(0, 255, 0)],
n_values=[
band_start,
band_end - 1, # green band in the middle
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=5000,
)
# 2d. Segments transition: full-ring warm white fade
name_trans4, preset_trans4 = make_segments_transition_preset(
"mp_segments_transition_4",
colors=[(255, 200, 100)],
n_values=[
0,
num_leds - 1, # entire strip
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=6000,
)
# 2e. Segments transition: alternating warm/cool halves
name_trans5, preset_trans5 = make_segments_transition_preset(
"mp_segments_transition_5",
colors=[(255, 180, 100), (100, 180, 255)],
n_values=[
0,
half - 1, # warm first half
half,
num_leds - 1, # cool second half
0,
-1,
0,
-1,
],
duration_ms=5000,
)
# 2f. Segments transition: narrow red band near start
narrow_start = num_leds // 16
narrow_end = narrow_start + max(4, num_leds // 32)
name_trans6, preset_trans6 = make_segments_transition_preset(
"mp_segments_transition_6",
colors=[(255, 0, 0)],
n_values=[
narrow_start,
narrow_end - 1,
0,
-1,
0,
-1,
0,
-1,
],
duration_ms=4000,
)
# Register presets in Presets and run them in sequence
presets.presets[name_static] = preset_static
presets.presets[name_trans1] = preset_trans1
presets.presets[name_trans2] = preset_trans2
presets.presets[name_trans3] = preset_trans3
presets.presets[name_trans4] = preset_trans4
presets.presets[name_trans5] = preset_trans5
presets.presets[name_trans6] = preset_trans6
print("Showing static segments...")
presets.select(name_static)
presets.tick() # draw once
run_for(presets, 3000)
print("Running segments transition 1 (fading to new half/half layout)...")
presets.select(name_trans1)
run_for(presets, 3500)
print("Running segments transition 2 (fading to quarter-band layout)...")
presets.select(name_trans2)
run_for(presets, 4500)
print("Running segments transition 3 (fading to center green band)...")
presets.select(name_trans3)
run_for(presets, 5500)
print("Running segments transition 4 (fading to full warm white ring)...")
presets.select(name_trans4)
run_for(presets, 6500)
print("Running segments transition 5 (fading to warm/cool halves)...")
presets.select(name_trans5)
run_for(presets, 5500)
print("Running segments transition 6 (fading to narrow red band)...")
presets.select(name_trans6)
run_for(presets, 4500)
print("Multi-pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

100
pico/test/test_scale.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Test the Presets.scale() helper on-device with mpremote.
Usage (from project root):
mpremote connect <device> cp pico/src/*.py : &&
mpremote connect <device> cp pico/src/patterns/*.py :patterns &&
mpremote connect <device> cp pico/lib/*.py : &&
mpremote connect <device> cp tests/test_scale.py : &&
mpremote connect <device> run test_scale.py
This script:
- Creates a minimal Presets instance
- Runs a few numeric test cases for scale()
- Optionally displays a short visual check on the LEDs
"""
from presets import Presets
def numeric_tests(presets):
"""
Numeric sanity checks for scale() using the actual strip config.
We treat strip 0 as the reference and print the mapped indices for
a few positions on each other strip.
"""
print("Numeric scale() tests (from strip 0):")
ref_len = presets.strip_length(0)
if ref_len <= 0:
print(" strip 0 length <= 0; skipping numeric tests.")
return
test_positions = [0, ref_len // 2, ref_len - 1]
for pos in test_positions:
print(" pos on strip 0:", pos)
for dst_idx in range(len(presets.strips)):
dst_len = presets.strip_length(dst_idx)
if dst_len <= 0:
continue
n2 = presets.scale(dst_idx, pos)
print(" -> strip", dst_idx, "len", dst_len, "pos", n2)
def visual_test(presets):
"""
Simple visual test:
- Use strip 0 as reference
- Move a pixel along strip 0
- Map position to all other strips with scale()
"""
import utime
strips = presets.strips
if not strips:
print("No strips available for visual test.")
return
src_strip_idx = 0
l1 = presets.strip_length(src_strip_idx)
if l1 <= 0:
print("strip_length(0) <= 0; aborting visual test.")
return
color = (50, 0, 0) # dim red so it doesn't blind you
# Run once across the full length of the reference strip,
# jumping 10 LEDs at a time.
step_size = 10
steps = (l1 + step_size - 1) // step_size
print("Starting visual scale() test with 10-LED jumps:", steps, "steps...")
for step in range(steps):
n1 = (step * step_size) % l1
# Clear all strips
for strip in strips:
strip.fill((0, 0, 0))
# Light mapped position on each strip using Presets.set/show
for dst_strip_idx, _ in enumerate(strips):
presets.set(dst_strip_idx, n1, color)
presets.show(dst_strip_idx)
print("Visual test finished.")
def main():
presets = Presets()
presets.load()
numeric_tests(presets)
# Comment this in/out depending on whether you want the LEDs to run:
try:
visual_test(presets)
except Exception as e:
print("Visual test error:", e)
if __name__ == "__main__":
main()

130
pico/test/test_segments.py Normal file
View File

@@ -0,0 +1,130 @@
"""
On-device test for the Segments pattern using mpremote.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp test/test_segments.py :
mpremote connect <device> run test_segments.py
This script:
- Instantiates Presets
- Creates a few in-memory 'point' (Segments) presets with different ranges/colors
- Selects each one so you can visually confirm the segments
"""
from presets import Presets, Preset
def make_segments_preset(name, colors, n_values, brightness=255):
"""
Helper to build a Preset for the 'segments' pattern (key 'point').
colors: list of up to 4 (r,g,b) tuples
n_values: list/tuple of 8 ints [n1..n8]
"""
# Pad or trim colors to 4 entries
cs = list(colors)[:4]
while len(cs) < 4:
cs.append((0, 0, 0))
n1, n2, n3, n4, n5, n6, n7, n8 = n_values
data = {
"p": "segments", # pattern key for Segments
"c": cs,
"b": brightness,
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
"n7": n7,
"n8": n8,
# 'a' is not used by segments; it's static
}
return name, Preset(data)
def show_and_wait(presets, name, preset_obj, wait_ms):
"""Select a static segments preset and hold it for wait_ms."""
presets.presets[name] = preset_obj
presets.select(name)
# Segments draws immediately in run(), then just yields; one tick is enough.
presets.tick()
import utime
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < wait_ms:
# Keep ticking in case other logic ever depends on it
presets.tick()
def main():
presets = Presets()
presets.load()
num_leds = presets.strip_length(0)
if num_leds <= 0:
print("No strips; aborting segments test.")
return
print("Starting segments pattern test...")
quarter = num_leds // 4
half = num_leds // 2
segments_presets = []
# 1. Single band: first quarter, red
segments_presets.append(
make_segments_preset(
"segments_red_q1",
colors=[(255, 0, 0)],
n_values=[0, quarter - 1, 0, -1, 0, -1, 0, -1],
)
)
# 2. Two bands: red first half, green second half
segments_presets.append(
make_segments_preset(
"segments_red_green_halves",
colors=[(255, 0, 0), (0, 255, 0)],
n_values=[0, half - 1, half, num_leds - 1, 0, -1, 0, -1],
)
)
# 3. Three bands: R, G, B quarters
segments_presets.append(
make_segments_preset(
"segments_rgb_quarters",
colors=[(255, 0, 0), (0, 255, 0), (0, 0, 255)],
n_values=[
0,
quarter - 1, # red
quarter,
2 * quarter - 1, # green
2 * quarter,
3 * quarter - 1, # blue
0,
-1,
],
)
)
# Show each for ~4 seconds
for name, preset_obj in segments_presets:
print("Showing segments preset:", name)
show_and_wait(presets, name, preset_obj, wait_ms=4000)
print("Segments pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

45
pico/test/test_serial.py Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""
Serial loopback test single file, runs on Pico and ESP32.
Wire TX to RX (Pico: GP0GP1, ESP32: 1718), then:
mpremote run pico/test/test_serial.py
For ESP32→Pico: run test_serial_send.py on ESP32, test_serial_receive.py on Pico; wire ESP32 TX (17) to Pico RX (1).
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, RX_PIN, BAUD = 1, 17, 18, 115200
else:
UART_ID, TX_PIN, RX_PIN, BAUD = 0, 0, 1, 115200
READ_TIMEOUT_MS = 100
LINE_TERM = b"\n"
print("UART loopback: %s UART%d TX=%s RX=%s %d baud" % (sys.platform, UART_ID, TX_PIN, RX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT), rx=Pin(RX_PIN, Pin.IN))
uart.read()
to_send = [b"hello", b"123", b"{\"v\":\"1\"}"]
errors = []
for msg in to_send:
uart.write(msg + LINE_TERM)
time.sleep_ms(20)
buf = bytearray()
deadline = time.ticks_add(time.ticks_ms(), READ_TIMEOUT_MS)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
n = uart.any()
if n:
buf.extend(uart.read(n))
if LINE_TERM in buf:
break
time.sleep_ms(2)
got = bytes(buf).strip()
if got != msg:
errors.append((msg, got))
uart.deinit()
if errors:
print("FAIL loopback:", errors)
else:
print("PASS loopback: sent and received", to_send)

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
Serial receive test single file. Run on Pico (RX side).
Wire: ESP32 TX (GPIO17) → Pico RX (GPIO1); GND ↔ GND. Run send test on ESP32.
mpremote run pico/test/test_serial_receive.py
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, RX_PIN, BAUD = 1, 17, 18, 115200
else:
UART_ID, TX_PIN, RX_PIN, BAUD = 0, 0, 1, 115200
print("UART receive: %s UART%d TX=%s RX=%s %d baud (10 s)" % (sys.platform, UART_ID, TX_PIN, RX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT), rx=Pin(RX_PIN, Pin.IN))
buf = bytearray()
deadline = time.ticks_add(time.ticks_ms(), 10000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
n = uart.any()
if n:
buf.extend(uart.read(n))
while b"\n" in buf:
idx = buf.index(b"\n")
line = bytes(buf[:idx]).strip()
buf = buf[idx + 1:]
if line:
print("rx:", line.decode("utf-8", "replace"))
time.sleep_ms(10)
uart.deinit()
print("Receive test done.")

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""
Serial send test single file. Run on ESP32 (TX side).
Wire: ESP32 TX (GPIO17) → Pico RX (GPIO1); GND ↔ GND. Run receive test on Pico.
mpremote run pico/test/test_serial_send.py
"""
import time
import sys
from machine import UART, Pin
if "esp32" in sys.platform:
UART_ID, TX_PIN, BAUD = 1, 17, 115200
else:
UART_ID, TX_PIN, BAUD = 0, 0, 115200
print("UART send: %s UART%d TX=%s %d baud" % (sys.platform, UART_ID, TX_PIN, BAUD))
uart = UART(UART_ID, baudrate=BAUD, tx=Pin(TX_PIN, Pin.OUT))
for line in [b"serial send test 1", b"serial send test 2", b"{\"v\":\"1\",\"b\":128}"]:
uart.write(line + b"\n")
print("sent:", line.decode("utf-8"))
time.sleep_ms(50)
uart.deinit()
print("Send test done.")

128
pico/test/test_spin.py Normal file
View File

@@ -0,0 +1,128 @@
"""
On-device test for the Spin pattern using mpremote and Presets.
Usage (from pico/ dir or project root with adjusted paths):
mpremote connect <device> cp src/*.py :
mpremote connect <device> cp src/patterns/*.py :patterns
mpremote connect <device> cp lib/*.py :
mpremote connect <device> cp presets.json :
mpremote connect <device> cp test/test_spin.py :
mpremote connect <device> run test_spin.py
This script:
- Instantiates Presets
- Creates a few in-memory spin presets with different parameters
- Runs each one for a short time so you can visually compare behaviour
"""
import utime
from presets import Presets, Preset
def make_spin_preset(
name,
color_inner,
color_outer,
rate=4,
delay_ms=30,
margin=0,
brightness=255,
):
"""Helper to build a Preset dict for the spin pattern."""
data = {
"p": "spin",
"c": [color_inner, color_outer],
"b": brightness,
"d": delay_ms,
"n1": rate, # expansion step per tick
"n2": margin, # margin from strip ends
"a": True,
}
return name, Preset(data)
def run_preset(presets, name, preset_obj, duration_ms):
"""Run a given spin preset for duration_ms using the existing tick loop."""
# Start each preset from a blank frame so both sides are balanced.
presets.select("off")
presets.tick()
presets.presets[name] = preset_obj
presets.select(name)
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
presets.tick()
utime.sleep_ms(10)
def main():
presets = Presets()
presets.load()
# Ensure we start from a blank frame.
presets.select("off")
presets.tick()
print("Starting spin pattern test...")
# Use strip 0 length to derive a reasonable margin.
ref_len = presets.strip_length(0)
margin_small = ref_len // 16 if ref_len > 0 else 0
margin_large = ref_len // 8 if ref_len > 0 else 0
spin_presets = []
# 1. Slow spin, warm white to orange, small margin
spin_presets.append(
make_spin_preset(
"spin_slow_warm",
color_inner=(255, 200, 120),
color_outer=(255, 100, 0),
rate=2,
delay_ms=40,
margin=margin_small,
brightness=255,
)
)
# 2. Medium spin, cyan to magenta, larger margin
spin_presets.append(
make_spin_preset(
"spin_medium_cyan_magenta",
color_inner=(0, 255, 180),
color_outer=(255, 0, 180),
rate=4,
delay_ms=30,
margin=margin_large,
brightness=255,
)
)
# 3. Fast spin, white to off (fade outwards), no margin
spin_presets.append(
make_spin_preset(
"spin_fast_white",
color_inner=(255, 255, 255),
color_outer=(0, 0, 0),
rate=6,
delay_ms=20,
margin=0,
brightness=255,
)
)
# Run each spin preset for about 6 seconds
for name, preset_obj in spin_presets:
print("Running spin preset:", name)
run_preset(presets, name, preset_obj, duration_ms=6000)
print("Spin pattern test finished. Turning off LEDs.")
presets.select("off")
presets.tick()
if __name__ == "__main__":
main()

618
presets.json Normal file
View File

@@ -0,0 +1,618 @@
{
"start": {
"p": "off",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"grab": {
"p": "grab",
"d": 0,
"b": 0,
"c": [
[64,0,255]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin1": {
"p": "spin",
"d": 0,
"b": 100,
"c": [
[64,0,255],
[255,105,180]
],
"n1": 1,
"n2": 20,
"n3": 0,
"n4": 0
},
"lift": {
"p": "lift",
"d": 0,
"b": 100,
"c": [
[64,0,255],
[255,105,180]
],
"n1": 1,
"n2": 20,
"n3": 0,
"n4": 0
},
"flare": {
"p": "flare",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"hook": {
"p": "hook",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 7,
"n3": 15,
"n4": 15
},
"roll1": {
"p": "roll",
"d": 200,
"b": 100,
"c": [
[64,0,255],
[20,20,40]
],
"n1": 50,
"n2": 160,
"n3": 1,
"n4": 0
},
"invertsplit": {
"p": "invertsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose1": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,0],
[0,255,0],
[0,0,255],
[255,255,255]
],
"n1": 100,
"n2": 150,
"n3": 650,
"n4": 700,
"n5": 1200,
"n6": 1250,
"n7": 1750,
"n8": 1800
},
"pose2": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,105,180],
[64,0,255],
[255,165,0],
[0,255,255]
],
"n1": 150,
"n2": 200,
"n3": 700,
"n4": 750,
"n5": 1250,
"n6": 1300,
"n7": 1800,
"n8": 1850
},
"roll2": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance1": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat1": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose3": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,255,0],
[255,0,255],
[0,255,255],
[255,255,255]
],
"n1": 200,
"n2": 250,
"n3": 750,
"n4": 800,
"n5": 1300,
"n6": 1350,
"n7": 1850,
"n8": 1900
},
"roll3": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"crouch": {
"p": "crouch",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose4": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[64,0,255],
[255,105,180],
[255,255,255],
[255,140,0]
],
"n1": 250,
"n2": 300,
"n3": 800,
"n4": 850,
"n5": 1350,
"n6": 1400,
"n7": 1900,
"n8": 1950
},
"roll4": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbendsplit": {
"p": "backbendsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance2": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbalance3": {
"p": "backbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat2": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"straddle": {
"p": "straddle",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"beat3": {
"p": "beat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"frontbalance1": {
"p": "frontbalance",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose5": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,127],
[0,127,255],
[127,255,0],
[255,255,255]
],
"n1": 300,
"n2": 350,
"n3": 850,
"n4": 900,
"n5": 1400,
"n6": 1450,
"n7": 1950,
"n8": 2000
},
"pose6": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,80,0],
[0,200,120],
[80,0,255],
[255,255,255]
],
"n1": 350,
"n2": 400,
"n3": 900,
"n4": 950,
"n5": 1450,
"n6": 1500,
"n7": 2000,
"n8": 2050
},
"elbowhang": {
"p": "elbowhang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"elbowhangspin": {
"p": "elbowhangspin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin2": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"dismount": {
"p": "dismount",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin3": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"fluff": {
"p": "fluff",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"spin4": {
"p": "spin",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"flare2": {
"p": "flare",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"elbowhangsplit2": {
"p": "elbowhangsplit",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"invert": {
"p": "invert",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"roll5": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"backbend": {
"p": "backbend",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"pose7": {
"p": "point",
"d": 0,
"b": 220,
"c": [
[255,0,0],
[255,165,0],
[255,255,0],
[255,255,255]
],
"n1": 400,
"n2": 450,
"n3": 950,
"n4": 1000,
"n5": 1500,
"n6": 1550,
"n7": 2050,
"n8": 2100
},
"roll6": {
"p": "roll",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"seat": {
"p": "seat",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"kneehang": {
"p": "kneehang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"legswoop": {
"p": "legswoop",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"split": {
"p": "split",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"foothang": {
"p": "foothang",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
},
"end": {
"p": "off",
"d": 0,
"b": 0,
"c": [
[0,0,0]
],
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0
}
}