Compare commits

..

3 Commits

Author SHA1 Message Date
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
63 changed files with 6226 additions and 403 deletions

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

58
esp32/src/buttons.json Normal file
View File

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

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,145 @@
{% 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="point">point</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,70 +1,70 @@
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)
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,32 @@ from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle
from .roll import Roll
from .calibration import Calibration
from .test import Test
from .grab import Grab
from .spin import Spin
from .lift import Lift
from .flare import Flare
from .hook import Hook
from .invertsplit import Invertsplit
from .pose import Pose
from .backbalance import Backbalance
from .beat import Beat
from .crouch import Crouch
from .backbendsplit import Backbendsplit
from .straddle import Straddle
from .frontbalance import Frontbalance
from .elbowhang import Elbowhang
from .elbowhangspin import Elbowhangspin
from .dismount import Dismount
from .fluff import Fluff
from .elbowhangsplit import Elbowhangsplit
from .invert import Invert
from .backbend import Backbend
from .seat import Seat
from .kneehang import Kneehang
from .legswoop import Legswoop
from .split import Split
from .foothang import Foothang
from .point import Point

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Backbalance:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Backbend:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Backbendsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Beat:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,38 @@
"""Calibration: strips 2 and 6 only. First 10 green, then alternating 10 blue / 10 red. 10% brightness."""
BRIGHTNESS = 0.10
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)
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

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Crouch:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Dismount:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Elbowhang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Elbowhangspin:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Elbowhangsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
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

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Fluff:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Foothang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Frontbalance:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
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

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Invert:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Invertsplit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Kneehang:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Legswoop:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
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

View File

@@ -0,0 +1,68 @@
class Point:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""
Point pattern: color bands defined by n ranges.
- n1n2: LEDs with color1 (c[0])
- n3n4: LEDs with color2 (c[1])
- n5n6: LEDs with color3 (c[2])
- n7n8: LEDs with color4 (c[3])
All indices are along the logical ring (driver.n), inclusive ranges.
"""
num_leds = self.driver.num_leds
# 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):
a = int(a)
b = int(b)
if a > b:
a, b = b, a
if b < 0 or a >= num_leds:
return None
a = max(0, a)
b = min(num_leds - 1, b)
if a > b:
return None
return a, b
ranges = []
r1 = norm_range(getattr(preset, "n1", 0), getattr(preset, "n2", -1))
if r1:
ranges.append((r1[0], r1[1], c1))
r2 = norm_range(getattr(preset, "n3", 0), getattr(preset, "n4", -1))
if r2:
ranges.append((r2[0], r2[1], c2))
r3 = norm_range(getattr(preset, "n5", 0), getattr(preset, "n6", -1))
if r3:
ranges.append((r3[0], r3[1], c3))
r4 = norm_range(getattr(preset, "n7", 0), getattr(preset, "n8", -1))
if r4:
ranges.append((r4[0], r4[1], c4))
# Static draw: last range wins on overlaps
for i in range(num_leds):
color = (0, 0, 0)
for start, end, c in ranges:
if start <= i <= end:
color = c
self.driver.n[i] = color
self.driver.n.write()
while True:
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

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

@@ -0,0 +1,154 @@
import utime
class Roll:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Roll: moving band with gradient from color1 to color2 over the strips.
- n1: offset from start of strip (effective start = start + n1)
- n2: offset from end of strip (effective end = end - n2, inclusive)
- n3: number of full rotations before stopping (0 = infinite)
- n4: direction (0 = clockwise, 1 = anti-clockwise)
- c[0]: color1 at the head strip
- c[1]: color2 at the tail strip
"""
colors = preset.c
color1_raw = colors[0] if colors else (255, 255, 255)
color2_raw = colors[1] if len(colors) > 1 else (0, 0, 0)
color1 = self.driver.apply_brightness(color1_raw, preset.b)
color2 = self.driver.apply_brightness(color2_raw, preset.b)
n_segments = self.driver.n.num_strips if hasattr(self.driver.n, "num_strips") else 1
# Margins from the start and end of each strip
start_margin = max(0, int(getattr(preset, "n1", 0)))
end_margin = max(0, int(getattr(preset, "n2", 0)))
# Debug info to see why roll might be black
try:
print(
"ROLL preset",
"p=", getattr(preset, "p", None),
"b=", getattr(preset, "b", None),
"colors_raw=", color1_raw, color2_raw,
"colors_bright=", color1, color2,
)
print(
"ROLL n1..n4",
getattr(preset, "n1", None),
getattr(preset, "n2", None),
getattr(preset, "n3", None),
getattr(preset, "n4", None),
"n_segments=", n_segments,
"start_margin=", start_margin,
"end_margin=", end_margin,
)
except Exception:
pass
# n3: number of rotations (0 = infinite)
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
delay_ms = max(1, int(preset.d) or 1)
last_update = utime.ticks_ms()
rotations_done = 0
def scale_color(c, f):
return tuple(int(x * f) for x in c)
def lerp_color(c1, c2, t):
"""Linear gradient between two colors."""
if t <= 0:
return c1
if t >= 1:
return c2
return (
int(c1[0] + (c2[0] - c1[0]) * t),
int(c1[1] + (c2[1] - c1[1]) * t),
int(c1[2] + (c2[2] - c1[2]) * t),
)
def draw(head):
# Remember head strip for flare
try:
self.driver.last_roll_head = head
except AttributeError:
pass
strips_list = self.driver.strips
for strip_idx, strip in enumerate(strips_list):
if strip_idx < 0 or strip_idx >= n_segments:
continue
# Distance from head along direction, 0..n_segments-1
if clockwise:
dist = (head - strip_idx) % n_segments
else:
dist = (strip_idx - head) % n_segments
# Color gradient from color1 at the head strip to color2 at the tail strip
if n_segments > 1:
t = dist / (n_segments - 1)
else:
t = 0.0
c_strip = lerp_color(color1, color2, t)
n = strip.num_leds
# Effective segment per strip:
# start = 0 + start_margin
# end = (n - 1) - end_margin (inclusive)
width = n - start_margin - end_margin
if width <= 0:
# If margins are too large, fall back to full strip
seg_s = 0
seg_e = n
else:
seg_s = max(0, min(n, start_margin))
seg_e = min(n, n - end_margin)
# Debug for first strip/head to see segment
try:
if strip_idx == 0 and head == 0:
print("ROLL seg strip0 n=", n, "seg_s=", seg_s, "seg_e=", seg_e)
except Exception:
pass
for i in range(n):
if seg_s <= i < seg_e:
strip.set(i, c_strip)
else:
strip.set(i, (0, 0, 0))
strip.show()
if not preset.a:
head = step % n_segments if n_segments > 0 else 0
draw(head)
self.driver.step = step + 1
yield
return
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= delay_ms:
head = step % n_segments if n_segments > 0 else 0
if not clockwise and n_segments > 0:
head = (n_segments - 1 - head)
draw(head)
step += 1
if max_rotations > 0 and n_segments > 0 and (step % n_segments) == 0:
rotations_done += 1
if rotations_done >= max_rotations:
self.driver.step = step
last_update = current_time
return
self.driver.step = step
last_update = current_time
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Seat:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

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

@@ -0,0 +1,98 @@
"""Spin: continues from Grab — segment (10 each side of center) moves slowly up to the top. Preset color, n1 = rate."""
import utime
SPAN = 10 # 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
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: inside (strip 1, idx 0) moves slower, outside (strip 5, idx 4) faster
step = max(1, rate // 2) if idx == 0 else 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

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Split:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
yield

View File

@@ -0,0 +1,9 @@
"""Placeholder until implemented."""
class Straddle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
while True:
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,
}

618
pico/src/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
}
}

View File

@@ -1,20 +1,92 @@
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, Roll, Calibration, Test,
Grab, Spin, Lift, Flare, Hook, Invertsplit, Pose,
Backbalance, Beat, Crouch, Backbendsplit, Straddle,
Frontbalance, Elbowhang, Elbowhangspin, Dismount, Fluff,
Elbowhangsplit, Invert, Backbend, Seat, Kneehang,
Legswoop, Split, Foothang, Point,
)
import json
# 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 StripRing:
"""Treat multiple WS2812B strips as one logical ring. Equal weight per strip (scale by strip length)."""
def __init__(self, strips):
self.strips = strips
self._cumul = [0]
for s in strips:
self._cumul.append(self._cumul[-1] + s.num_leds)
self.num_leds = self._cumul[-1]
self.num_strips = len(strips)
def _strip_and_local(self, i):
if i < 0 or i >= self.num_leds:
raise IndexError(i)
for s in range(len(self.strips)):
if i < self._cumul[s + 1]:
return s, i - self._cumul[s]
return len(self.strips) - 1, i - self._cumul[-2]
def __setitem__(self, i, color):
s, local = self._strip_and_local(i)
self.strips[s].set(local, color)
def fill(self, color):
for s in self.strips:
s.fill(color)
def write(self):
for s in self.strips:
s.show()
def position(self, i):
"""Normalized position 0..1 with equal span per strip (longer strips get same angular span)."""
s, local = self._strip_and_local(i)
strip_len = self.strips[s].num_leds
frac = (local / strip_len) if strip_len else 0
return (s + frac) / self.num_strips
def segment(self, i):
"""Segment index 0..num_strips-1 (strip index) so segments align with physical strips."""
s, _ = self._strip_and_local(i)
return s
class Presets:
def __init__(self, pin, num_leds, state_machine=0):
def __init__(self):
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
# Single logical strip over all 8 strips for patterns (n[i], .fill(), .write())
self.n = StripRing(self.strips)
self.num_leds = self.n.num_leds
# 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
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 +104,79 @@ class Presets:
"transition": Transition(self).run,
"chase": Chase(self).run,
"circle": Circle(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,
"invertsplit": Invertsplit(self).run,
"pose": Pose(self).run,
"backbalance": Backbalance(self).run,
"beat": Beat(self).run,
"crouch": Crouch(self).run,
"backbendsplit": Backbendsplit(self).run,
"straddle": Straddle(self).run,
"frontbalance": Frontbalance(self).run,
"elbowhang": Elbowhang(self).run,
"elbowhangspin": Elbowhangspin(self).run,
"dismount": Dismount(self).run,
"fluff": Fluff(self).run,
"elbowhangsplit": Elbowhangsplit(self).run,
"invert": Invert(self).run,
"backbend": Backbend(self).run,
"seat": Seat(self).run,
"kneehang": Kneehang(self).run,
"legswoop": Legswoop(self).run,
"split": Split(self).run,
"foothang": Foothang(self).run,
"point": Point(self).run,
}
# --- 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 strip_index_to_angle(self, strip_idx, index):
"""Map an LED index on a given strip to a normalized angle 0..1.
This accounts for different strip lengths so that the same angle value
corresponds to the same physical angle on all concentric strips.
"""
n = self.strip_length(strip_idx)
if n <= 0:
return 0.0
index = int(index) % n
return index / float(n)
def strip_angle_to_index(self, strip_idx, angle):
"""Map a normalized angle 0..1 to an LED index on a given strip.
Use this when you want patterns to align by angle instead of raw index,
despite strips having different circumferences.
"""
n = self.strip_length(strip_idx)
if n <= 0:
return 0
# Normalize angle into [0,1)
angle = float(angle)
angle = angle - int(angle)
if angle < 0.0:
angle += 1.0
return int(angle * n) % n
def remap_strip_index(self, src_strip_idx, dst_strip_idx, src_index):
"""Remap an index from one strip to another so they align by angle."""
angle = self.strip_index_to_angle(src_strip_idx, src_index)
return self.strip_angle_to_index(dst_strip_idx, angle)
def save(self):
"""Save the presets to a file."""
with open("presets.json", "w") as f:
@@ -87,19 +230,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
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 update_num_leds(self, pin, num_leds):
num_leds = int(num_leds)

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

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

@@ -0,0 +1,78 @@
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: trail length (0 = single LED), color (R,G,B)
TRAIL_LEN = 8
CHASE_COLOR = (0, 255, 100) # cyan-green
def make_chase_double(num_leds, cumulative_leds, total_ring_leds, color, trail_len=0):
"""Pregenerate strip double buffer: when head shows index b first, that pixel is at
distance (2*cumulative_leds - b) % total_ring_leds from chase head. GRB order."""
n = 2 * num_leds
buf = bytearray(n * 3)
for b in range(n):
dist = (2 * cumulative_leds - b) % total_ring_leds
if dist == 0:
r, grn, b_ = color[0], color[1], color[2]
elif trail_len and 0 < dist <= trail_len:
fade = 1.0 - (dist / (trail_len + 1))
r = int(color[0] * fade)
grn = int(color[1] * fade)
b_ = int(color[2] * fade)
else:
r = grn = b_ = 0
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, CHASE_COLOR, TRAIL_LEN)
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 + 1) % total_ring_leds
time.sleep_ms(20)

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)

81
pico/test/roll_strips.py Normal file
View File

@@ -0,0 +1,81 @@
import math
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
# --- Roll: N buffers (length = max strip), gradient full -> off; sequence through strips ---
N_BUFFERS = 32 # more buffers = smoother transition
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
num_strips = len(strips)
max_leds = max(ws.num_leds for ws in strips)
# Color when "on" (R, G, B); GRB order in buffer
ROLL_COLOR = (0, 255, 120) # cyan-green
def make_gradient_buffers(n_buffers, max_leds, color):
"""Create n_buffers buffers, each max_leds long. Buffer 0 = full brightness, last = off.
Gradient is logarithmic (perceptually smoother: more steps near full, fewer near off). GRB order."""
out = []
for j in range(n_buffers):
# log gradient: scale = 255 * log(1 + (n - 1 - j)) / log(n) so 255 at j=0, 0 at j=n-1
if n_buffers <= 1:
scale = 255
elif j >= n_buffers - 1:
scale = 0
else:
# 1 + (n_buffers - 1 - j) runs from n_buffers down to 1
scale = int(255 * math.log(1 + (n_buffers - 1 - j)) / math.log(n_buffers))
scale = min(255, scale)
buf = bytearray(max_leds * 3)
r = (color[0] * scale) // 255
g = (color[1] * scale) // 255
b = (color[2] * scale) // 255
for i in range(max_leds):
o = i * 3
buf[o] = g & 0xFF
buf[o + 1] = r & 0xFF
buf[o + 2] = b & 0xFF
out.append(buf)
return out
# N buffers: first full, last off, gradient between
buffers = make_gradient_buffers(N_BUFFERS, max_leds, ROLL_COLOR)
step = 0
delay_ms = 50
# Deadline-based loop: no extra pause at rotation wrap, smooth continuous roll
next_ms = time.ticks_ms()
while True:
for i, strip in enumerate(strips):
buf_index = (step + i) % N_BUFFERS
strip.show(buffers[buf_index], 0)
step += 1 # unbounded; wrap only in index so no hitch at cycle end
next_ms += delay_ms
# Sleep until next frame time (handles drift, no pause at wrap)
while time.ticks_diff(next_ms, time.ticks_ms()) > 0:
time.sleep_ms(1)

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