Compare commits
12 Commits
pi
...
fb8141b320
| Author | SHA1 | Date | |
|---|---|---|---|
| fb8141b320 | |||
| 96712dda88 | |||
| f5a7b42e7c | |||
| 1b1e9d727e | |||
| 668d29b786 | |||
| e5f42e099e | |||
| a9edda38ef | |||
| edec5ff460 | |||
|
|
264eb7296f | ||
|
|
fbd4295302 | ||
|
|
7bdb324ebc | ||
|
|
28b19b5219 |
45
.cursor/rules/led-driver.mdc
Normal file
45
.cursor/rules/led-driver.mdc
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
description: led-driver — MicroPython ESP32: mpremote, imports, layout, I/O, no pycache in src
|
||||
globs: led-driver/**
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# led-driver (MicroPython / ESP32)
|
||||
|
||||
## Device and tests
|
||||
|
||||
1. Validate **MicroPython behaviour** under **`led-driver/`** with **`mpremote connect <PORT> …`** on the chip. Host **`python3`** does **not** prove the firmware build.
|
||||
|
||||
2. **Execution target is fixed:** treat **`led-driver/`** code as firmware that runs **only on MicroPython ESP32 devices**. Do **not** run `led-driver/src/main.py` (or other firmware modules) with host CPython as a normal execution path.
|
||||
|
||||
3. **Flow:** `mpremote connect <PORT> cp <local> :<on-flash>` then `run <script>.py`. Inline commands only — no **`.sh`** wrappers unless the user asks. Default serial placeholder: **`/dev/ttyACM0`**.
|
||||
|
||||
4. Checks that **import and run** code from **`led-driver/src/`** belong in **`led-driver/tests/`** and run with **`mpremote run …`**. **Do not** add **`pytest`** under **`led-controller/tests/`** that **`sys.path`**-loads **`led-driver/src`** and runs those modules on CPython.
|
||||
|
||||
## Import layout
|
||||
|
||||
4. **No** **`sys.path.insert`**, **`__file__`** path stitching, or other import-path hacks under **`led-driver/`**. Use device flash search path, or host **`PYTHONPATH`** / layout you control.
|
||||
|
||||
5. **No** “import fixer” code — fix copy order, flash paths, or env instead.
|
||||
|
||||
## Imports (fail loudly)
|
||||
|
||||
6. If a dependency does not load, **crash** and fix deployment or filesystem. **Do not** catch **`ImportError`** / **`ModuleNotFoundError`** around **`import`** / **`from … import`** for app/firmware modules (`settings`, `utils`, `network`, `machine`, …).
|
||||
|
||||
7. **Allowed — stdlib name pairs only** (MicroPython vs CPython): one **`except ImportError`**, then **one** fallback import, **no** extra logic in **`except`**:
|
||||
- `uos` → `os`
|
||||
- `ubinascii` → `binascii`
|
||||
- `utime` → `time`
|
||||
Not for “maybe the file exists on flash” — only different **stdlib** names.
|
||||
|
||||
8. **No** large inline reimplementations after **`except ImportError`** — deploy the real module.
|
||||
|
||||
## I/O
|
||||
|
||||
9. Non-blocking **recv** / **accept**: use plain **`except OSError:`** (or **break** on empty). **No** errno / EAGAIN / EWOULDBLOCK tables or **`getattr(errno, …)`** unless fixing a **documented** target bug.
|
||||
|
||||
10. Minimal **`try` / `except OSError`** around optional socket options (e.g. **`SO_REUSEADDR`**) is fine.
|
||||
|
||||
## Host Python and `src/`
|
||||
|
||||
11. **Do not** leave **`__pycache__/`** or **`.pyc`** under **`led-driver/src/`** from host runs. Remove if created; **`.gitignore`** already ignores it. Prefer **`PYTHONDONTWRITEBYTECODE=1`** or **`-B`** when host Python must touch **`led-driver/src/`**.
|
||||
16
.cursor/rules/strict-user-scope.mdc
Normal file
16
.cursor/rules/strict-user-scope.mdc
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: enforce strict user-scoped changes only
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Strict User Scope
|
||||
|
||||
1. Only implement exactly what the user asked for in the current message.
|
||||
|
||||
2. Do not add extra refactors, cleanups, renames, architecture changes, or behavioural changes unless the user explicitly asked for them.
|
||||
|
||||
3. If a potential improvement is noticed, mention it briefly and ask before changing code.
|
||||
|
||||
4. For revert/undo requests, perform the narrowest possible revert and do not modify anything else.
|
||||
|
||||
5. Keep edits minimal and local to the requested area.
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
# led-driver/src is MicroPython source — never keep host __pycache__ there (see .cursor/rules/led-driver.mdc)
|
||||
led-driver/src/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
@@ -28,3 +30,5 @@ settings.json
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
.pytest_cache/
|
||||
.ropeproject/
|
||||
|
||||
1
Pipfile
1
Pipfile
@@ -13,6 +13,7 @@ requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
microdot = "*"
|
||||
websockets = "*"
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
|
||||
711
Pipfile.lock
generated
711
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
|
||||
"sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -42,112 +42,112 @@
|
||||
},
|
||||
"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"
|
||||
"sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
|
||||
"sha256:03fe327549f177040b32f7faa736dc152be936d8b264d8b84f94c75f1379bfa1",
|
||||
"sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd",
|
||||
"sha256:0793c51d3b1c7410bde1f7254fff71fabff1bc0cdeba1fa51319ac4e7931df3d",
|
||||
"sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822",
|
||||
"sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012",
|
||||
"sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354",
|
||||
"sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be",
|
||||
"sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559",
|
||||
"sha256:10c0caabff00ab0631d1e4fd25f56c7a5cf0f068426e5860d28dbbb972b509bf",
|
||||
"sha256:133648c3405564e7fef9103f1768cb018de1b4976f3d8beff09cd4acea73bfe4",
|
||||
"sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2",
|
||||
"sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064",
|
||||
"sha256:190a3482818d69faef176171c7cae10d55cb4dd0c686b5aced7f592b5e5591c1",
|
||||
"sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6",
|
||||
"sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11",
|
||||
"sha256:20e412527ec1aac7e3a6542b32a9c34bb852c954676b05008f0e3d58c390a0ac",
|
||||
"sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36",
|
||||
"sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8",
|
||||
"sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4",
|
||||
"sha256:2b9916867fa1ed815739e3e37dda458f397dee25a0e293b808839cfc2a396ca0",
|
||||
"sha256:2da2ca9495668ab77132a911f6bd530d2bfe686d10467584894efc3b66e9ffb5",
|
||||
"sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459",
|
||||
"sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3",
|
||||
"sha256:30d42c34da2974a5e2e0b51c57ecf89892c1e83ed67e1084d1e27eefc27add91",
|
||||
"sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1",
|
||||
"sha256:3a5e594b4be2dbfe021cee8d6d7d96e9bb19dee7ed7be351f43bca7a0619b978",
|
||||
"sha256:3b9358f6437a5fa0c765ffae5810c9830547baf4bcf469438b82845c3f33f998",
|
||||
"sha256:3b9790ae107fc8648155f120e80a58ef8e94424efefff5b355de84061de6a18b",
|
||||
"sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a",
|
||||
"sha256:430fe5150816445c8294a36ce2612360037342d750cea179efe5de38c66670a8",
|
||||
"sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d",
|
||||
"sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b",
|
||||
"sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b",
|
||||
"sha256:4c7ce072191ba23a4a4876452ccd5f2a67b926e66a248d052d39e9969cd3ab47",
|
||||
"sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f",
|
||||
"sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44",
|
||||
"sha256:4fb869faf4b484cb213199ced1e2732091559107637d429fc25d0a9731f5f630",
|
||||
"sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352",
|
||||
"sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd",
|
||||
"sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508",
|
||||
"sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2",
|
||||
"sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9",
|
||||
"sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e",
|
||||
"sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123",
|
||||
"sha256:6956ef0259a037f10da767741aca82925f6f9978bb6dceb5344e56ce0629ab07",
|
||||
"sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc",
|
||||
"sha256:69c8298e8197b113f765a2ea60f49ceb8e1ea9eb308140b3cdc611e0d1de70b8",
|
||||
"sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e",
|
||||
"sha256:6f92d12a46b2a67d56194bb5d226dabf586b386d1f1a5e25be5b745a3080dbba",
|
||||
"sha256:70f70ea138e69ec3159e4a38fef52443cb8eb81388aeb241b273265ea16387c5",
|
||||
"sha256:72a0e87b2196120523fc6194ca6b580fcffa12d7daa4d57a16d7838e60f82d0e",
|
||||
"sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339",
|
||||
"sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57",
|
||||
"sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4",
|
||||
"sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10",
|
||||
"sha256:78ab0d4166cf35c73054d1e04f224af1edc3cb4d75da8b6f74f4cff7c300f358",
|
||||
"sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1",
|
||||
"sha256:7c133052737c7c75bfa49f5ba71918166fe988995b26a0d2f263a79bf8fed58a",
|
||||
"sha256:7eae9e763fbd32f19f2a66dfc2e37906f8422e0c4ad4a6c9dcf9d3246740812e",
|
||||
"sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311",
|
||||
"sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc",
|
||||
"sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc",
|
||||
"sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d",
|
||||
"sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b",
|
||||
"sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907",
|
||||
"sha256:8e12d50d4d65c74bd877e15c276992263b878456a7cfcf72521e7205a553557f",
|
||||
"sha256:9adacf6fdadeeb96e6c902aef08d02d2f45429fdbf0a75b80307e435156066f8",
|
||||
"sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c",
|
||||
"sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb",
|
||||
"sha256:a681bbf9f94027d66e15974cd207cec1a2993837b9c45acf5f6b22a67632b1c2",
|
||||
"sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea",
|
||||
"sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38",
|
||||
"sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d",
|
||||
"sha256:af01133e78e5528ee282ceb1cf4bc54aecb937c2001913e751452ad7dffbbeb1",
|
||||
"sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1",
|
||||
"sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf",
|
||||
"sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b",
|
||||
"sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc",
|
||||
"sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576",
|
||||
"sha256:c263ed9922942353a954cfbcd5f81b7626c0e20dc7f3e53d4926e8bc560ab845",
|
||||
"sha256:c3387c314695f9790dce12fcf44357197ebf773651b6a4195f5e091cf500ae73",
|
||||
"sha256:c4fd3399eaf6f1c77ea3132611efbc3d7a8c0eb899793387b3266be221dc75fd",
|
||||
"sha256:cac0145491619287ff893853bf3ca4d98d5ef94b617271184a5af68a06ac301a",
|
||||
"sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53",
|
||||
"sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5",
|
||||
"sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a",
|
||||
"sha256:d7d5f7f6f80388ce94849775da5f4082ab5e123e259972961970e190d60f5d2b",
|
||||
"sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137",
|
||||
"sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd",
|
||||
"sha256:defa3c12cb06b2fd2066a9e21bf00aab96465be84d9585c8c05195f080510506",
|
||||
"sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441",
|
||||
"sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1",
|
||||
"sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8",
|
||||
"sha256:eb9fa02b9f5bbdb1d036a0c68999337793fa244528e0ce825e4b97cb7f7db99f",
|
||||
"sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9",
|
||||
"sha256:ef123b6aead12e0784f72970e8d94a96ac0d0aa4438c7ab9235e2f8669a0a5ae",
|
||||
"sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5",
|
||||
"sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65",
|
||||
"sha256:fd68db1a0f5d9374a7b735414efe48d2b3ecbf0adea39299bb48030988f16149",
|
||||
"sha256:fd6b5b6df14f98b2e7e474c1c7ea55fc32dcab038b3b34b76a591dec8ba50915",
|
||||
"sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705",
|
||||
"sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a",
|
||||
"sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644"
|
||||
],
|
||||
"version": "==3.8.0"
|
||||
"version": "==3.8.1"
|
||||
},
|
||||
"bitstring": {
|
||||
"hashes": [
|
||||
@@ -257,207 +257,208 @@
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
|
||||
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
|
||||
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
|
||||
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
|
||||
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
|
||||
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
|
||||
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
|
||||
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
|
||||
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
|
||||
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
|
||||
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
|
||||
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
|
||||
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
|
||||
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
|
||||
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
|
||||
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
|
||||
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
|
||||
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
|
||||
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
|
||||
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
|
||||
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
|
||||
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
|
||||
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
|
||||
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
|
||||
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
|
||||
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
|
||||
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
|
||||
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
|
||||
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
|
||||
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
|
||||
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
|
||||
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
|
||||
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
|
||||
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
|
||||
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
|
||||
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
|
||||
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
|
||||
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
|
||||
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
|
||||
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
|
||||
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
|
||||
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
|
||||
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
|
||||
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
|
||||
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
|
||||
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
|
||||
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
|
||||
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
|
||||
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
|
||||
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
|
||||
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
|
||||
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
|
||||
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
|
||||
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
|
||||
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
|
||||
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
|
||||
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
|
||||
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
|
||||
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
|
||||
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
|
||||
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
|
||||
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
|
||||
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
|
||||
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
|
||||
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
|
||||
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
|
||||
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
|
||||
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
|
||||
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
|
||||
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
|
||||
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
|
||||
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
|
||||
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
|
||||
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
|
||||
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
|
||||
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
|
||||
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
|
||||
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
|
||||
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
|
||||
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
|
||||
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
|
||||
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
|
||||
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
|
||||
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
|
||||
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
|
||||
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
|
||||
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
|
||||
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
|
||||
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
|
||||
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
|
||||
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
|
||||
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
|
||||
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
|
||||
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
|
||||
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
|
||||
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
|
||||
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
|
||||
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
|
||||
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
|
||||
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
|
||||
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
|
||||
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
|
||||
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
|
||||
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
|
||||
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
|
||||
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
|
||||
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
|
||||
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
|
||||
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
|
||||
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
|
||||
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
|
||||
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
|
||||
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
|
||||
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
|
||||
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
|
||||
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
|
||||
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
|
||||
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
|
||||
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
|
||||
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
|
||||
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
|
||||
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
|
||||
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
|
||||
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
|
||||
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
|
||||
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
|
||||
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
|
||||
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
|
||||
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
|
||||
"sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc",
|
||||
"sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c",
|
||||
"sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67",
|
||||
"sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4",
|
||||
"sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0",
|
||||
"sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c",
|
||||
"sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5",
|
||||
"sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444",
|
||||
"sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153",
|
||||
"sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9",
|
||||
"sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01",
|
||||
"sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217",
|
||||
"sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b",
|
||||
"sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c",
|
||||
"sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a",
|
||||
"sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83",
|
||||
"sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5",
|
||||
"sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7",
|
||||
"sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb",
|
||||
"sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c",
|
||||
"sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1",
|
||||
"sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42",
|
||||
"sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab",
|
||||
"sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df",
|
||||
"sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e",
|
||||
"sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207",
|
||||
"sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18",
|
||||
"sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734",
|
||||
"sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38",
|
||||
"sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110",
|
||||
"sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18",
|
||||
"sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44",
|
||||
"sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d",
|
||||
"sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48",
|
||||
"sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e",
|
||||
"sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5",
|
||||
"sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d",
|
||||
"sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53",
|
||||
"sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790",
|
||||
"sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c",
|
||||
"sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b",
|
||||
"sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116",
|
||||
"sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d",
|
||||
"sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10",
|
||||
"sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6",
|
||||
"sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2",
|
||||
"sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776",
|
||||
"sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a",
|
||||
"sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265",
|
||||
"sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008",
|
||||
"sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943",
|
||||
"sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374",
|
||||
"sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246",
|
||||
"sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e",
|
||||
"sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5",
|
||||
"sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616",
|
||||
"sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15",
|
||||
"sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41",
|
||||
"sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960",
|
||||
"sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752",
|
||||
"sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e",
|
||||
"sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72",
|
||||
"sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7",
|
||||
"sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8",
|
||||
"sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b",
|
||||
"sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4",
|
||||
"sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545",
|
||||
"sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706",
|
||||
"sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366",
|
||||
"sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb",
|
||||
"sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a",
|
||||
"sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e",
|
||||
"sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00",
|
||||
"sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f",
|
||||
"sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a",
|
||||
"sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1",
|
||||
"sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66",
|
||||
"sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356",
|
||||
"sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319",
|
||||
"sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4",
|
||||
"sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad",
|
||||
"sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d",
|
||||
"sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5",
|
||||
"sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7",
|
||||
"sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0",
|
||||
"sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686",
|
||||
"sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34",
|
||||
"sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49",
|
||||
"sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c",
|
||||
"sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1",
|
||||
"sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e",
|
||||
"sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60",
|
||||
"sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0",
|
||||
"sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274",
|
||||
"sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d",
|
||||
"sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0",
|
||||
"sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae",
|
||||
"sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f",
|
||||
"sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d",
|
||||
"sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe",
|
||||
"sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3",
|
||||
"sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393",
|
||||
"sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1",
|
||||
"sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af",
|
||||
"sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44",
|
||||
"sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00",
|
||||
"sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c",
|
||||
"sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3",
|
||||
"sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7",
|
||||
"sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd",
|
||||
"sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e",
|
||||
"sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b",
|
||||
"sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8",
|
||||
"sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259",
|
||||
"sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859",
|
||||
"sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46",
|
||||
"sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30",
|
||||
"sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b",
|
||||
"sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46",
|
||||
"sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24",
|
||||
"sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a",
|
||||
"sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24",
|
||||
"sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc",
|
||||
"sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215",
|
||||
"sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063",
|
||||
"sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832",
|
||||
"sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6",
|
||||
"sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79",
|
||||
"sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.4.6"
|
||||
"version": "==3.4.7"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
||||
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"
|
||||
"sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
|
||||
"sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==8.3.1"
|
||||
"version": "==8.3.2"
|
||||
},
|
||||
"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"
|
||||
"sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65",
|
||||
"sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832",
|
||||
"sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067",
|
||||
"sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de",
|
||||
"sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4",
|
||||
"sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0",
|
||||
"sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b",
|
||||
"sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968",
|
||||
"sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef",
|
||||
"sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
|
||||
"sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4",
|
||||
"sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3",
|
||||
"sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308",
|
||||
"sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e",
|
||||
"sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163",
|
||||
"sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f",
|
||||
"sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee",
|
||||
"sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77",
|
||||
"sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85",
|
||||
"sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99",
|
||||
"sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7",
|
||||
"sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83",
|
||||
"sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85",
|
||||
"sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006",
|
||||
"sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb",
|
||||
"sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e",
|
||||
"sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba",
|
||||
"sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325",
|
||||
"sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d",
|
||||
"sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1",
|
||||
"sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1",
|
||||
"sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2",
|
||||
"sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0",
|
||||
"sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455",
|
||||
"sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842",
|
||||
"sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457",
|
||||
"sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15",
|
||||
"sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2",
|
||||
"sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c",
|
||||
"sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb",
|
||||
"sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5",
|
||||
"sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4",
|
||||
"sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902",
|
||||
"sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246",
|
||||
"sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022",
|
||||
"sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f",
|
||||
"sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e",
|
||||
"sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298",
|
||||
"sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"
|
||||
],
|
||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||
"version": "==46.0.5"
|
||||
"version": "==46.0.7"
|
||||
},
|
||||
"esptool": {
|
||||
"hashes": [
|
||||
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==5.2.0"
|
||||
},
|
||||
"h11": {
|
||||
@@ -505,15 +506,17 @@
|
||||
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"mpremote": {
|
||||
"hashes": [
|
||||
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
||||
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
|
||||
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
|
||||
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.27.0"
|
||||
"markers": "python_version >= '3.4'",
|
||||
"version": "==1.28.0"
|
||||
},
|
||||
"outcome": {
|
||||
"hashes": [
|
||||
@@ -525,11 +528,11 @@
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
|
||||
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
|
||||
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
|
||||
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.9.4"
|
||||
"version": "==4.9.6"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
@@ -541,11 +544,11 @@
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.19.2"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"pyjwt": {
|
||||
"hashes": [
|
||||
@@ -553,6 +556,7 @@
|
||||
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.12.1"
|
||||
},
|
||||
"pyserial": {
|
||||
@@ -667,19 +671,20 @@
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
|
||||
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.32.5"
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==2.33.1"
|
||||
},
|
||||
"rich": {
|
||||
"hashes": [
|
||||
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
|
||||
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
|
||||
"sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb",
|
||||
"sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
|
||||
],
|
||||
"markers": "python_full_version >= '3.8.0'",
|
||||
"version": "==14.3.3"
|
||||
"markers": "python_full_version >= '3.9.0'",
|
||||
"version": "==15.0.0"
|
||||
},
|
||||
"rich-click": {
|
||||
"hashes": [
|
||||
@@ -691,11 +696,12 @@
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
|
||||
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
|
||||
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
|
||||
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.41.0"
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.43.0"
|
||||
},
|
||||
"sniffio": {
|
||||
"hashes": [
|
||||
@@ -774,6 +780,9 @@
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"socks"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||
@@ -894,6 +903,7 @@
|
||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"websocket-client": {
|
||||
@@ -904,6 +914,74 @@
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==1.9.0"
|
||||
},
|
||||
"websockets": {
|
||||
"hashes": [
|
||||
"sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c",
|
||||
"sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a",
|
||||
"sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe",
|
||||
"sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e",
|
||||
"sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec",
|
||||
"sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1",
|
||||
"sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64",
|
||||
"sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3",
|
||||
"sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8",
|
||||
"sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206",
|
||||
"sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3",
|
||||
"sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156",
|
||||
"sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d",
|
||||
"sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9",
|
||||
"sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad",
|
||||
"sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2",
|
||||
"sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03",
|
||||
"sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8",
|
||||
"sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230",
|
||||
"sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8",
|
||||
"sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea",
|
||||
"sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641",
|
||||
"sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957",
|
||||
"sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6",
|
||||
"sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6",
|
||||
"sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5",
|
||||
"sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f",
|
||||
"sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00",
|
||||
"sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e",
|
||||
"sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b",
|
||||
"sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72",
|
||||
"sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39",
|
||||
"sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9",
|
||||
"sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79",
|
||||
"sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0",
|
||||
"sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac",
|
||||
"sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35",
|
||||
"sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0",
|
||||
"sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5",
|
||||
"sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c",
|
||||
"sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8",
|
||||
"sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1",
|
||||
"sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244",
|
||||
"sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3",
|
||||
"sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767",
|
||||
"sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a",
|
||||
"sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d",
|
||||
"sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd",
|
||||
"sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e",
|
||||
"sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944",
|
||||
"sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82",
|
||||
"sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d",
|
||||
"sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4",
|
||||
"sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5",
|
||||
"sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904",
|
||||
"sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde",
|
||||
"sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f",
|
||||
"sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c",
|
||||
"sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89",
|
||||
"sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da",
|
||||
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==16.0"
|
||||
},
|
||||
"wsproto": {
|
||||
"hashes": [
|
||||
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
|
||||
@@ -940,19 +1018,20 @@
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.19.2"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.20.0"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
|
||||
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
|
||||
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9",
|
||||
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==9.0.2"
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
README.md
16
README.md
@@ -1,23 +1,26 @@
|
||||
# led-controller
|
||||
|
||||
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
|
||||
|
||||
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
|
||||
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
|
||||
|
||||
## Run
|
||||
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run`
|
||||
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
|
||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||
|
||||
## UI modes
|
||||
|
||||
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
|
||||
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
|
||||
- **Run mode**: focused control view. Select zones/presets and apply profiles. Editing actions are hidden.
|
||||
- **Edit mode**: management view. Shows **Zones**, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
|
||||
|
||||
## Profiles
|
||||
|
||||
- Applying a profile updates session scope and refreshes the active zone content.
|
||||
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
|
||||
- In **Run mode**, Profiles supports apply-only behaviour (no create/clone/delete).
|
||||
- In **Edit mode**, Profiles supports create/clone/delete.
|
||||
- Creating a profile always creates a populated `default` zone (starter presets).
|
||||
- Optional **DJ zone** seeding creates:
|
||||
@@ -35,3 +38,6 @@ LED controller web app for managing profiles, tabs, presets, and colour palettes
|
||||
|
||||
- Main API reference: `docs/API.md`
|
||||
|
||||
## Driver pattern modules
|
||||
|
||||
Pattern **`.py`** sources live under **`led-driver/src/patterns`**. The Pi app resolves that path via `util.driver_patterns.driver_patterns_dir()`. If you deploy without that tree next to the app, set **`LED_CONTROLLER_PATTERNS_DIR`** to the directory that contains those files.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.182", "default_pattern": null, "zones": []}, "188b0e1560a8": {"id": "188b0e1560a8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.242", "default_pattern": null, "zones": []}, "24ec4acaffcc": {"id": "24ec4acaffcc", "name": "c", "type": "led", "transport": "wifi", "address": "10.1.1.171", "default_pattern": null, "zones": []}}
|
||||
{"24ec4acaffcc": {"id": "24ec4acaffcc", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.171", "default_pattern": null, "zones": []}, "188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}
|
||||
@@ -1 +1 @@
|
||||
{"1": {"name": "default", "names": ["a", "c", "a"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||
{"1": {"name": "default", "names": ["led-188b0e1560a8", "a", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "10", "11"], ["9", "12", "1"], ["13", "37", "6"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37", "6"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||
53
dev.py
53
dev.py
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import serial
|
||||
import sys
|
||||
|
||||
print(sys.argv)
|
||||
|
||||
# Extract port (first arg if it's not a command)
|
||||
commands = ["src", "lib", "ls", "reset", "follow", "db"]
|
||||
port = None
|
||||
if len(sys.argv) > 1 and sys.argv[1] not in commands:
|
||||
port = sys.argv[1]
|
||||
|
||||
|
||||
for cmd in sys.argv[1:]:
|
||||
print(cmd)
|
||||
match cmd:
|
||||
case "src":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
|
||||
else:
|
||||
print("Error: Port required for 'src' command")
|
||||
case "lib":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'lib' command")
|
||||
case "ls":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'ls' command")
|
||||
case "reset":
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
ser.write(b'\x03\x03\x04')
|
||||
else:
|
||||
print("Error: Port required for 'reset' command")
|
||||
case "follow":
|
||||
if port:
|
||||
with serial.Serial(port, baudrate=115200) as ser:
|
||||
while True:
|
||||
if ser.in_waiting > 0: # Check if there is data in the buffer
|
||||
data = ser.readline().decode('utf-8').strip() # Read and decode the data
|
||||
print(data)
|
||||
else:
|
||||
print("Error: Port required for 'follow' command")
|
||||
case "db":
|
||||
if port:
|
||||
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
|
||||
else:
|
||||
print("Error: Port required for 'db' command")
|
||||
49
docs/API.md
49
docs/API.md
@@ -2,10 +2,12 @@
|
||||
|
||||
This document covers:
|
||||
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
|
||||
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
|
||||
|
||||
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
|
||||
|
||||
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
|
||||
|
||||
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||
|
||||
@@ -16,7 +18,7 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle
|
||||
The main UI has two modes controlled by the mode toggle:
|
||||
|
||||
- **Run mode**: optimized for operation (zone/preset selection and profile apply).
|
||||
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
|
||||
- **Edit mode**: shows editing/management controls (zones, presets, patterns, colour palette, send presets, profile management actions, **Devices** registry for LED driver names/MACs, and related tools).
|
||||
|
||||
Profiles are available in both modes, but behavior differs:
|
||||
|
||||
@@ -50,10 +52,12 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
|
||||
|
||||
Connect to **`ws://<host>:<port>/ws`**.
|
||||
|
||||
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||
- Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
|
||||
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||
|
||||
Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**, device routes, or **`POST /patterns/<name>/send`** as appropriate.
|
||||
|
||||
---
|
||||
|
||||
## HTTP API by resource
|
||||
@@ -77,11 +81,11 @@ Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps t
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **`id`** | Same as the storage key (stable handle for URLs). |
|
||||
| **`name`** | Shown in tabs and used in `select` keys. |
|
||||
| **`name`** | Shown in the UI and used in `select` keys. |
|
||||
| **`type`** | `led` (only value today; extensible). |
|
||||
| **`transport`** | `espnow` or `wifi`. |
|
||||
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
|
||||
| **`default_pattern`**, **`tabs`** | Optional, as before. |
|
||||
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
|
||||
|
||||
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
|
||||
|
||||
@@ -89,7 +93,7 @@ Existing records without `type` / `transport` / `id` are backfilled on load (`le
|
||||
|--------|------|-------------|
|
||||
| GET | `/devices` | Map of device id → device object. |
|
||||
| GET | `/devices/<id>` | One device, 404 if missing. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
|
||||
| DELETE | `/devices/<id>` | Remove device. |
|
||||
|
||||
@@ -102,7 +106,7 @@ Existing records without `type` / `transport` / `id` are backfilled on load (`le
|
||||
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_zone` (request-only) seeds a DJ zone + presets. New profiles always get a populated `default` zone. Returns `{ "<id>": { ... } }` with status 201. |
|
||||
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||
| POST | `/profiles/<id>/clone` | Clone profile (zones, palettes, presets). Body may include `name`. |
|
||||
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||
@@ -143,11 +147,11 @@ Stored preset records can include:
|
||||
- `colors`: resolved hex colours for editor/display.
|
||||
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
|
||||
|
||||
### Tabs — `/zones`
|
||||
### Zones — `/zones`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/zones` | `tabs`, `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/zones` | `zones` (map of zone id → zone object), `zone_order`, `current_zone_id`, `profile_id` for the session-backed profile. |
|
||||
| GET | `/zones/current` | Current zone from cookie/session. |
|
||||
| POST | `/zones` | Create zone; optional JSON `name`, `names`, `presets`; can append to current profile’s zone list. |
|
||||
| GET | `/zones/<id>` | Zone JSON. |
|
||||
@@ -198,20 +202,33 @@ Stored preset records can include:
|
||||
|
||||
### Patterns — `/patterns`
|
||||
|
||||
Pattern metadata lives in **`db/pattern.json`**; driver source files live under **`led-driver/src/patterns/`**. Several routes expose a **runtime map** (metadata merged with on-disk `.py` names so new files appear in menus).
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||
| GET | `/patterns` | All pattern records. |
|
||||
| GET | `/patterns/<id>` | One pattern. |
|
||||
| GET | `/patterns` | Runtime pattern map (object keyed by pattern id). |
|
||||
| GET | `/patterns/definitions` | Same runtime map (intended for UI “definitions” clients). |
|
||||
| GET | `/patterns/ota/manifest` | JSON `{"files":[{"name":"blink.py","url":"http://<Host>/patterns/ota/file/blink.py"},...]}` for OTA pulls. Requires **`Host`** header. |
|
||||
| GET | `/patterns/ota/file/<name>` | Raw **`.py`** source for one driver pattern (`name` must be a safe filename, e.g. `rainbow.py`). |
|
||||
| POST | `/patterns/<name>/send` | Push a **manifest** JSON line to **Wi-Fi** devices so they pull one pattern file over HTTP. Body may include **`device_id`** to target one device; otherwise all Wi-Fi devices with an **`address`** are tried. **`<name>`** may be with or without `.py`. |
|
||||
| POST | `/patterns/upload` | Body JSON: **`name`**, **`code`**, optional **`overwrite`** (default true). Writes **`led-driver/src/patterns/<name>.py`**. |
|
||||
| POST | `/patterns/driver` | Body JSON: **`name`** (identifier), **`code`**, optional metadata (`min_delay`, `max_delay`, `max_colors`, `n1`…`n8`, **`overwrite`**). Creates/updates both the **`.py`** file and **`db/pattern.json`** via the Pattern model. |
|
||||
| GET | `/patterns/<id>` | One pattern record from the Pattern model (metadata only). |
|
||||
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||
| PUT | `/patterns/<id>` | Update. |
|
||||
| DELETE | `/patterns/<id>` | Delete. |
|
||||
|
||||
**Devices — pattern OTA push**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/devices/<id>/patterns/push` | Wi-Fi only. Asks the driver at **`address`** to pull pattern files from this server. Optional body **`manifest`**: either a **URL string** pointing at a manifest JSON document, or a **manifest object** (same shape as in driver messages). If omitted, a default manifest is built from the request **`Host`** header. |
|
||||
|
||||
---
|
||||
|
||||
## LED driver message format (transport / ESP-NOW)
|
||||
## LED driver message format (transport / ESP-NOW / Wi-Fi)
|
||||
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
|
||||
|
||||
### Top-level fields
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ Manage connected devices and create/manage device groups.
|
||||
|
||||
#### Layout
|
||||
- **Header:** Title with "Add Device" button
|
||||
- **Tabs:** Devices and Groups tabs
|
||||
- **Zones:** Devices and Groups zones (zone buttons / zone strip)
|
||||
- **Content Area:** Zone-specific content
|
||||
|
||||
#### Devices Zone
|
||||
@@ -1774,7 +1774,7 @@ peak_mem = usqlite.mem_peak()
|
||||
- Buttons respond to clicks
|
||||
- Sliders update values
|
||||
- Modals open/close
|
||||
- Tabs switch correctly
|
||||
- Zone buttons switch correctly
|
||||
- Preset selector works
|
||||
- Preset creation form validates input
|
||||
- Preset cards display correctly
|
||||
|
||||
20
docs/help.md
20
docs/help.md
@@ -1,6 +1,6 @@
|
||||
# LED controller — user guide
|
||||
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
||||
|
||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||
|
||||
@@ -12,24 +12,24 @@ Figures below are **schematic** (layout and ideas), not pixel-perfect screenshot
|
||||
|
||||
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
|
||||
|
||||

|
||||

|
||||
|
||||
*The active zone is highlighted. Extra management buttons appear only in Edit mode.*
|
||||
|
||||
| Mode | Purpose |
|
||||
|------|--------|
|
||||
| **Run mode** | Day-to-day control: choose a zone, tap presets, apply profiles. Management buttons are hidden. |
|
||||
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
|
||||
| **Edit mode** | Full setup: zones, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
|
||||
|
||||
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
|
||||
|
||||
---
|
||||
|
||||
## Tabs
|
||||
## Zones
|
||||
|
||||
- **Select a zone**: click its button in the top bar. The main area shows that zone’s preset strip and controls.
|
||||
- **Edit mode — open zone settings**: **right-click** a zone button to change its name, **device IDs** (comma-separated), and which presets appear on the zone. Device identifiers are matched to each device’s **name** when the app builds `select` messages for the driver.
|
||||
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
|
||||
- **Zones modal** (Edit mode): create new zones from the header **Zones** button. New zones need a name and device ID list (defaults to `1` if you leave a simple placeholder).
|
||||
- **Brightness slider** (per zone): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
|
||||
|
||||
---
|
||||
@@ -68,7 +68,7 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
|
||||
|
||||
## Profiles
|
||||
|
||||
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
|
||||
- **Apply**: sets the **current profile** in your session. Zones and presets you see are scoped to that profile.
|
||||
- **Edit mode — Create**: new profiles always get a populated **default** zone. Optionally tick **DJ zone** to also create a `dj` zone (device name `dj`) with starter DJ-oriented presets.
|
||||
- **Clone** / **Delete**: available in Edit mode from the profile list.
|
||||
|
||||
@@ -82,7 +82,9 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
|
||||
|
||||
## Patterns
|
||||
|
||||
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
|
||||
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
|
||||
|
||||
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +100,7 @@ The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names
|
||||
|
||||
## Mobile layout
|
||||
|
||||
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.).
|
||||
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Zones, Presets, Help, mode toggle, etc.).
|
||||
|
||||

|
||||
|
||||
@@ -108,5 +110,5 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys).
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
|
||||
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.
|
||||
|
||||
BIN
docs/help.pdf
BIN
docs/help.pdf
Binary file not shown.
@@ -1,112 +0,0 @@
|
||||
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
|
||||
# Run on device: mpremote run esp32/benchmark_peers.py
|
||||
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
|
||||
import espnow
|
||||
import network
|
||||
import time
|
||||
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
MAX_PEERS = 20
|
||||
ITERATIONS = 50
|
||||
PAYLOAD = b"x" * 32 # small payload
|
||||
|
||||
network.WLAN(network.STA_IF).active(True)
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(BROADCAST)
|
||||
|
||||
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
|
||||
def mac(i):
|
||||
return bytes([0, 0, 0, 0, 0, i])
|
||||
peers_list = [mac(i) for i in range(1, 20)]
|
||||
for p in peers_list:
|
||||
esp.add_peer(p)
|
||||
|
||||
# One "new" MAC we'll add/remove.
|
||||
new_mac = bytes([0, 0, 0, 0, 0, 99])
|
||||
|
||||
def bench_lru():
|
||||
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
|
||||
last_used = {BROADCAST: time.ticks_ms()}
|
||||
for p in peers_list:
|
||||
last_used[p] = time.ticks_ms()
|
||||
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
|
||||
esp.del_peer(peers_list[-1])
|
||||
last_used.pop(peers_list[-1], None)
|
||||
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
|
||||
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
|
||||
# eviction each time: use a different "new" each time so we always evict+add.
|
||||
t0 = time.ticks_us()
|
||||
for i in range(ITERATIONS):
|
||||
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
|
||||
peers = esp.get_peers()
|
||||
peer_macs = [p[0] for p in peers]
|
||||
if addr not in peer_macs:
|
||||
if len(peer_macs) >= MAX_PEERS:
|
||||
oldest_mac = None
|
||||
oldest_ts = time.ticks_ms()
|
||||
for m in peer_macs:
|
||||
if m == BROADCAST:
|
||||
continue
|
||||
ts = last_used.get(m, 0)
|
||||
if ts <= oldest_ts:
|
||||
oldest_ts = ts
|
||||
oldest_mac = m
|
||||
if oldest_mac is not None:
|
||||
esp.del_peer(oldest_mac)
|
||||
last_used.pop(oldest_mac, None)
|
||||
esp.add_peer(addr)
|
||||
esp.send(addr, PAYLOAD)
|
||||
last_used[addr] = time.ticks_ms()
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
def bench_add_then_remove():
|
||||
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
|
||||
# Start full: 20 peers. To add new we del any one, add new, send, del new.
|
||||
victim = peers_list[0]
|
||||
t0 = time.ticks_us()
|
||||
for i in range(ITERATIONS):
|
||||
esp.del_peer(victim) # make room
|
||||
esp.add_peer(new_mac)
|
||||
esp.send(new_mac, PAYLOAD)
|
||||
esp.del_peer(new_mac)
|
||||
esp.add_peer(victim) # put victim back so we're at 20 again
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
def bench_send_existing():
|
||||
"""Baseline: send to existing peer only (no add/del)."""
|
||||
t0 = time.ticks_us()
|
||||
for _ in range(ITERATIONS):
|
||||
esp.send(peers_list[0], PAYLOAD)
|
||||
t1 = time.ticks_us()
|
||||
return time.ticks_diff(t1, t0)
|
||||
|
||||
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
|
||||
print()
|
||||
|
||||
# Baseline: send to existing peer
|
||||
try:
|
||||
us = bench_send_existing()
|
||||
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("Send existing failed:", e)
|
||||
print()
|
||||
|
||||
# LRU: evict oldest then add new, send
|
||||
try:
|
||||
us = bench_lru()
|
||||
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("LRU failed:", e)
|
||||
print()
|
||||
|
||||
# Add then remove after use
|
||||
try:
|
||||
us = bench_add_then_remove()
|
||||
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||
except Exception as e:
|
||||
print("Add-then-remove failed:", e)
|
||||
print()
|
||||
print("Done.")
|
||||
253
esp32/main.py
253
esp32/main.py
@@ -1,253 +0,0 @@
|
||||
# Serial-to-ESP-NOW bridge: JSON in both directions on UART + ESP-NOW.
|
||||
#
|
||||
# Pi → UART (two supported forms):
|
||||
# A) Legacy: 6 bytes destination MAC + UTF-8 JSON payload (one write = one frame).
|
||||
# B) Newline JSON: one object per line, UTF-8, ending with \n
|
||||
# - Multicast via ESP32: {"m":"split","peers":["12hex",...],"body":{...}}
|
||||
# - Unicast / broadcast: {"to":"12hex","v":"1",...} (all keys except to/dest go to peers)
|
||||
#
|
||||
# ESP-NOW → Pi: newline-delimited JSON, one object per packet:
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload":{...}} if body was JSON
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload_text":"..."} if UTF-8 not JSON
|
||||
# {"dir":"espnow_rx","from":"<12hex>","payload_b64":"..."} if binary
|
||||
from machine import Pin, UART
|
||||
import espnow
|
||||
import json
|
||||
import network
|
||||
import time
|
||||
import ubinascii
|
||||
|
||||
UART_BAUD = 912000
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
MAX_PEERS = 20
|
||||
WIFI_CHANNEL = 6
|
||||
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
|
||||
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
|
||||
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
esp.add_peer(BROADCAST)
|
||||
|
||||
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
|
||||
|
||||
last_used = {BROADCAST: time.ticks_ms()}
|
||||
uart_rx_buf = b""
|
||||
|
||||
ESP_ERR_ESPNOW_EXIST = -12395
|
||||
|
||||
|
||||
def ensure_peer(addr):
|
||||
peers = esp.get_peers()
|
||||
peer_macs = [p[0] for p in peers]
|
||||
if addr in peer_macs:
|
||||
return
|
||||
if len(peer_macs) >= MAX_PEERS:
|
||||
oldest_mac = None
|
||||
oldest_ts = time.ticks_ms()
|
||||
for mac in peer_macs:
|
||||
if mac == BROADCAST:
|
||||
continue
|
||||
ts = last_used.get(mac, 0)
|
||||
if ts <= oldest_ts:
|
||||
oldest_ts = ts
|
||||
oldest_mac = mac
|
||||
if oldest_mac is not None:
|
||||
esp.del_peer(oldest_mac)
|
||||
last_used.pop(oldest_mac, None)
|
||||
try:
|
||||
esp.add_peer(addr)
|
||||
except OSError as e:
|
||||
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
|
||||
raise
|
||||
|
||||
|
||||
def try_apply_bridge_config(obj):
|
||||
"""Pi sends {"m":"bridge","ch":1..11} — set STA channel only; do not ESP-NOW forward."""
|
||||
if not isinstance(obj, dict) or obj.get("m") != "bridge":
|
||||
return False
|
||||
ch = obj.get("ch")
|
||||
if ch is None:
|
||||
ch = obj.get("wifi_channel")
|
||||
if ch is None:
|
||||
return True
|
||||
try:
|
||||
n = int(ch)
|
||||
if 1 <= n <= 11:
|
||||
sta.config(pm=network.WLAN.PM_NONE, channel=n)
|
||||
print("Bridge STA channel ->", n)
|
||||
except Exception as e:
|
||||
print("bridge config:", e)
|
||||
return True
|
||||
|
||||
|
||||
def send_split_from_obj(obj):
|
||||
"""obj has m=split, peers=[12hex,...], body=dict."""
|
||||
body = obj.get("body")
|
||||
if body is None:
|
||||
return
|
||||
try:
|
||||
out = json.dumps(body).encode("utf-8")
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
for peer in obj.get("peers") or []:
|
||||
if not isinstance(peer, str) or len(peer) != 12:
|
||||
continue
|
||||
try:
|
||||
mac = bytes.fromhex(peer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(mac) != 6:
|
||||
continue
|
||||
ensure_peer(mac)
|
||||
esp.send(mac, out)
|
||||
last_used[mac] = time.ticks_ms()
|
||||
|
||||
|
||||
def process_broadcast_payload_split_or_flood(payload):
|
||||
try:
|
||||
text = payload.decode("utf-8")
|
||||
obj = json.loads(text)
|
||||
except Exception:
|
||||
obj = None
|
||||
if isinstance(obj, dict) and try_apply_bridge_config(obj):
|
||||
return
|
||||
if (
|
||||
isinstance(obj, dict)
|
||||
and obj.get("m") == "split"
|
||||
and isinstance(obj.get("peers"), list)
|
||||
):
|
||||
send_split_from_obj(obj)
|
||||
return
|
||||
ensure_peer(BROADCAST)
|
||||
esp.send(BROADCAST, payload)
|
||||
last_used[BROADCAST] = time.ticks_ms()
|
||||
|
||||
|
||||
def process_legacy_uart_frame(data):
|
||||
if not data or len(data) < 6:
|
||||
return
|
||||
addr = data[:6]
|
||||
payload = data[6:]
|
||||
if addr == BROADCAST:
|
||||
process_broadcast_payload_split_or_flood(payload)
|
||||
return
|
||||
ensure_peer(addr)
|
||||
esp.send(addr, payload)
|
||||
last_used[addr] = time.ticks_ms()
|
||||
|
||||
|
||||
def handle_json_command_line(obj):
|
||||
if not isinstance(obj, dict):
|
||||
return
|
||||
if try_apply_bridge_config(obj):
|
||||
return
|
||||
if obj.get("m") == "split" and isinstance(obj.get("peers"), list):
|
||||
send_split_from_obj(obj)
|
||||
return
|
||||
to = obj.get("to") or obj.get("dest")
|
||||
if isinstance(to, str) and len(to) == 12:
|
||||
try:
|
||||
mac = bytes.fromhex(to)
|
||||
except ValueError:
|
||||
return
|
||||
if len(mac) != 6:
|
||||
return
|
||||
body = {k: v for k, v in obj.items() if k not in ("to", "dest")}
|
||||
if not body:
|
||||
return
|
||||
try:
|
||||
out = json.dumps(body).encode("utf-8")
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
ensure_peer(mac)
|
||||
esp.send(mac, out)
|
||||
last_used[mac] = time.ticks_ms()
|
||||
|
||||
|
||||
def drain_uart_json_lines():
|
||||
"""Parse leading newline-delimited JSON objects from uart_rx_buf; leave rest."""
|
||||
global uart_rx_buf
|
||||
while True:
|
||||
s = uart_rx_buf.lstrip()
|
||||
if not s:
|
||||
uart_rx_buf = b""
|
||||
return
|
||||
if s[0] != ord("{"):
|
||||
uart_rx_buf = s
|
||||
return
|
||||
nl = s.find(b"\n")
|
||||
if nl < 0:
|
||||
uart_rx_buf = s
|
||||
return
|
||||
line = s[:nl].strip()
|
||||
uart_rx_buf = s[nl + 1 :]
|
||||
if line:
|
||||
try:
|
||||
text = line.decode("utf-8")
|
||||
obj = json.loads(text)
|
||||
handle_json_command_line(obj)
|
||||
except Exception as e:
|
||||
print("UART JSON line error:", e)
|
||||
# continue; there may be another JSON line in buffer
|
||||
|
||||
|
||||
def drain_uart_legacy_frame():
|
||||
"""If buffer does not start with '{', treat whole buffer as one 6-byte MAC + JSON frame."""
|
||||
global uart_rx_buf
|
||||
s = uart_rx_buf
|
||||
if not s or s[0] == ord("{"):
|
||||
return
|
||||
if len(s) < 6:
|
||||
return
|
||||
data = s
|
||||
uart_rx_buf = b""
|
||||
process_legacy_uart_frame(data)
|
||||
|
||||
|
||||
def forward_espnow_to_uart(mac, msg):
|
||||
peer_hex = ubinascii.hexlify(mac).decode()
|
||||
try:
|
||||
text = msg.decode("utf-8")
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload": payload}
|
||||
except ValueError:
|
||||
line_obj = {"dir": "espnow_rx", "from": peer_hex, "payload_text": text}
|
||||
except UnicodeDecodeError:
|
||||
line_obj = {
|
||||
"dir": "espnow_rx",
|
||||
"from": peer_hex,
|
||||
"payload_b64": ubinascii.b64encode(msg).decode(),
|
||||
}
|
||||
try:
|
||||
line = json.dumps(line_obj) + "\n"
|
||||
uart.write(line.encode("utf-8"))
|
||||
except Exception as e:
|
||||
print("UART TX error:", e)
|
||||
|
||||
|
||||
print("Starting ESP32 bridge (UART JSON + legacy MAC+JSON, ESP-NOW RX → UART JSON lines)")
|
||||
|
||||
while True:
|
||||
idle = True
|
||||
if uart.any():
|
||||
idle = False
|
||||
uart_rx_buf += uart.read()
|
||||
drain_uart_json_lines()
|
||||
drain_uart_legacy_frame()
|
||||
|
||||
try:
|
||||
peer, msg = esp.recv(0)
|
||||
except OSError:
|
||||
peer, msg = None, None
|
||||
|
||||
if peer is not None and msg is not None:
|
||||
idle = False
|
||||
if len(peer) == 6:
|
||||
forward_espnow_to_uart(peer, msg)
|
||||
|
||||
if idle:
|
||||
time.sleep_ms(1)
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"ch": 6,
|
||||
|
||||
"peers": {
|
||||
"12:3456789012":{
|
||||
"select": [["name1", "preset1"]]
|
||||
|
||||
,
|
||||
"ff:ff:ff:ff:ff:ff": {
|
||||
"presets": {
|
||||
"preset1": {
|
||||
"pattern": "on",
|
||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||
"delay": 100,
|
||||
"brightness": 127,
|
||||
"auto": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule led-driver updated: a64457a0d5...87bd0338bd
2
led-tool
2
led-tool
Submodule led-tool updated: 5f7acf38f0...9e72c62481
@@ -1,6 +0,0 @@
|
||||
from .blink import Blink
|
||||
from .rainbow import Rainbow
|
||||
from .pulse import Pulse
|
||||
from .transition import Transition
|
||||
from .chase import Chase
|
||||
from .circle import Circle
|
||||
@@ -1,33 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Blink:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def run(self, preset):
|
||||
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
|
||||
# Use provided colors, or default to white if none
|
||||
colors = preset.c if preset.c else [(255, 255, 255)]
|
||||
color_index = 0
|
||||
state = True # True = on, False = off
|
||||
last_update = utime.ticks_ms()
|
||||
|
||||
while True:
|
||||
current_time = utime.ticks_ms()
|
||||
# Re-read delay each loop so live updates to preset.d take effect
|
||||
delay_ms = max(1, int(preset.d))
|
||||
if utime.ticks_diff(current_time, last_update) >= delay_ms:
|
||||
if state:
|
||||
base_color = colors[color_index % len(colors)]
|
||||
color = self.driver.apply_brightness(base_color, preset.b)
|
||||
self.driver.fill(color)
|
||||
# Advance to next color for the next "on" phase
|
||||
color_index += 1
|
||||
else:
|
||||
# "Off" phase: turn all LEDs off
|
||||
self.driver.fill((0, 0, 0))
|
||||
state = not state
|
||||
last_update = current_time
|
||||
# Yield once per tick so other logic can run
|
||||
yield
|
||||
@@ -1,124 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Chase:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def run(self, preset):
|
||||
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
|
||||
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
|
||||
colors = preset.c
|
||||
if len(colors) < 1:
|
||||
# Need at least 1 color
|
||||
return
|
||||
|
||||
# Access colors, delay, and n values from preset
|
||||
if not colors:
|
||||
return
|
||||
# If only one color provided, use it for both colors
|
||||
if len(colors) < 2:
|
||||
color0 = colors[0]
|
||||
color1 = colors[0]
|
||||
else:
|
||||
color0 = colors[0]
|
||||
color1 = colors[1]
|
||||
|
||||
color0 = self.driver.apply_brightness(color0, preset.b)
|
||||
color1 = self.driver.apply_brightness(color1, preset.b)
|
||||
|
||||
n1 = max(1, int(preset.n1)) # LEDs of color 0
|
||||
n2 = max(1, int(preset.n2)) # LEDs of color 1
|
||||
n3 = int(preset.n3) # Step movement on even steps (can be negative)
|
||||
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
|
||||
|
||||
segment_length = n1 + n2
|
||||
|
||||
# Calculate position from step_count
|
||||
step_count = self.driver.step
|
||||
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
|
||||
if step_count % 2 == 0:
|
||||
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
|
||||
position = (step_count // 2) * (n3 + n4) + n3
|
||||
else:
|
||||
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
|
||||
position = ((step_count + 1) // 2) * (n3 + n4)
|
||||
|
||||
# Wrap position to keep it reasonable
|
||||
max_pos = self.driver.num_leds + segment_length
|
||||
position = position % max_pos
|
||||
if position < 0:
|
||||
position += max_pos
|
||||
|
||||
# If auto is False, run a single step and then stop
|
||||
if not preset.a:
|
||||
# Clear all LEDs
|
||||
self.driver.n.fill((0, 0, 0))
|
||||
|
||||
# Draw repeating pattern starting at position
|
||||
for i in range(self.driver.num_leds):
|
||||
# Calculate position in the repeating segment
|
||||
relative_pos = (i - position) % segment_length
|
||||
if relative_pos < 0:
|
||||
relative_pos = (relative_pos + segment_length) % segment_length
|
||||
|
||||
# Determine which color based on position in segment
|
||||
if relative_pos < n1:
|
||||
self.driver.n[i] = color0
|
||||
else:
|
||||
self.driver.n[i] = color1
|
||||
|
||||
self.driver.n.write()
|
||||
|
||||
# Increment step for next beat
|
||||
self.driver.step = step_count + 1
|
||||
|
||||
# Allow tick() to advance the generator once
|
||||
yield
|
||||
return
|
||||
|
||||
# Auto mode: continuous loop
|
||||
# Use transition_duration for timing and force the first update to happen immediately
|
||||
transition_duration = max(10, int(preset.d))
|
||||
last_update = utime.ticks_ms() - transition_duration
|
||||
|
||||
while True:
|
||||
current_time = utime.ticks_ms()
|
||||
if utime.ticks_diff(current_time, last_update) >= transition_duration:
|
||||
# Calculate current position from step_count
|
||||
if step_count % 2 == 0:
|
||||
position = (step_count // 2) * (n3 + n4) + n3
|
||||
else:
|
||||
position = ((step_count + 1) // 2) * (n3 + n4)
|
||||
|
||||
# Wrap position
|
||||
max_pos = self.driver.num_leds + segment_length
|
||||
position = position % max_pos
|
||||
if position < 0:
|
||||
position += max_pos
|
||||
|
||||
# Clear all LEDs
|
||||
self.driver.n.fill((0, 0, 0))
|
||||
|
||||
# Draw repeating pattern starting at position
|
||||
for i in range(self.driver.num_leds):
|
||||
# Calculate position in the repeating segment
|
||||
relative_pos = (i - position) % segment_length
|
||||
if relative_pos < 0:
|
||||
relative_pos = (relative_pos + segment_length) % segment_length
|
||||
|
||||
# Determine which color based on position in segment
|
||||
if relative_pos < n1:
|
||||
self.driver.n[i] = color0
|
||||
else:
|
||||
self.driver.n[i] = color1
|
||||
|
||||
self.driver.n.write()
|
||||
|
||||
# Increment step
|
||||
step_count += 1
|
||||
self.driver.step = step_count
|
||||
last_update = current_time
|
||||
|
||||
# Yield once per tick so other logic can run
|
||||
yield
|
||||
@@ -1,96 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Circle:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def run(self, preset):
|
||||
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
|
||||
head = 0
|
||||
tail = 0
|
||||
|
||||
# Calculate timing from preset
|
||||
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
|
||||
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
|
||||
max_length = max(1, int(preset.n2)) # n2 = max length
|
||||
min_length = max(0, int(preset.n4)) # n4 = min length
|
||||
|
||||
head_delay = 1000 // head_rate # ms between head movements
|
||||
tail_delay = 1000 // tail_rate # ms between tail movements
|
||||
|
||||
last_head_move = utime.ticks_ms()
|
||||
last_tail_move = utime.ticks_ms()
|
||||
|
||||
phase = "growing" # "growing", "shrinking", or "off"
|
||||
|
||||
# Support up to two colors (like chase). If only one color is provided,
|
||||
# use black for the second; if none, default to white.
|
||||
colors = preset.c
|
||||
if not colors:
|
||||
base0 = base1 = (255, 255, 255)
|
||||
elif len(colors) == 1:
|
||||
base0 = colors[0]
|
||||
base1 = (0, 0, 0)
|
||||
else:
|
||||
base0 = colors[0]
|
||||
base1 = colors[1]
|
||||
|
||||
color0 = self.driver.apply_brightness(base0, preset.b)
|
||||
color1 = self.driver.apply_brightness(base1, preset.b)
|
||||
|
||||
while True:
|
||||
current_time = utime.ticks_ms()
|
||||
|
||||
# Background: use second color during the "off" phase, otherwise clear to black
|
||||
if phase == "off":
|
||||
self.driver.n.fill(color1)
|
||||
else:
|
||||
self.driver.n.fill((0, 0, 0))
|
||||
|
||||
# Calculate segment length
|
||||
segment_length = (head - tail) % self.driver.num_leds
|
||||
if segment_length == 0 and head != tail:
|
||||
segment_length = self.driver.num_leds
|
||||
|
||||
# Draw segment from tail to head as a solid color (no per-LED alternation)
|
||||
current_color = color0
|
||||
for i in range(segment_length + 1):
|
||||
led_pos = (tail + i) % self.driver.num_leds
|
||||
self.driver.n[led_pos] = current_color
|
||||
|
||||
# Move head continuously at n1 LEDs per second
|
||||
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
|
||||
head = (head + 1) % self.driver.num_leds
|
||||
last_head_move = current_time
|
||||
|
||||
# Tail behavior based on phase
|
||||
if phase == "growing":
|
||||
# Growing phase: tail stays at 0 until max length reached
|
||||
if segment_length >= max_length:
|
||||
phase = "shrinking"
|
||||
elif phase == "shrinking":
|
||||
# Shrinking phase: move tail forward at n3 LEDs per second
|
||||
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
|
||||
tail = (tail + 1) % self.driver.num_leds
|
||||
last_tail_move = current_time
|
||||
|
||||
# Check if we've reached min length
|
||||
current_length = (head - tail) % self.driver.num_leds
|
||||
if current_length == 0 and head != tail:
|
||||
current_length = self.driver.num_leds
|
||||
|
||||
# For min_length = 0, we need at least 1 LED (the head)
|
||||
if min_length == 0 and current_length <= 1:
|
||||
phase = "off" # All LEDs off for 1 step
|
||||
elif min_length > 0 and current_length <= min_length:
|
||||
phase = "growing" # Cycle repeats
|
||||
else: # phase == "off"
|
||||
# Off phase: second color fills the ring for 1 step, then restart
|
||||
tail = head # Reset tail to head position to start fresh
|
||||
phase = "growing"
|
||||
|
||||
self.driver.n.write()
|
||||
|
||||
# Yield once per tick so other logic can run
|
||||
yield
|
||||
@@ -1,64 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Pulse:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def run(self, preset):
|
||||
self.driver.off()
|
||||
|
||||
# Get colors from preset
|
||||
colors = preset.c
|
||||
if not colors:
|
||||
colors = [(255, 255, 255)]
|
||||
|
||||
color_index = 0
|
||||
cycle_start = utime.ticks_ms()
|
||||
|
||||
# State machine based pulse using a single generator loop
|
||||
while True:
|
||||
# Read current timing parameters from preset
|
||||
attack_ms = max(0, int(preset.n1)) # Attack time in ms
|
||||
hold_ms = max(0, int(preset.n2)) # Hold time in ms
|
||||
decay_ms = max(0, int(preset.n3)) # Decay time in ms
|
||||
delay_ms = max(0, int(preset.d))
|
||||
|
||||
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
|
||||
if total_ms <= 0:
|
||||
total_ms = 1
|
||||
|
||||
now = utime.ticks_ms()
|
||||
elapsed = utime.ticks_diff(now, cycle_start)
|
||||
|
||||
base_color = colors[color_index % len(colors)]
|
||||
|
||||
if elapsed < attack_ms and attack_ms > 0:
|
||||
# Attack: fade 0 -> 1
|
||||
factor = elapsed / attack_ms
|
||||
color = tuple(int(c * factor) for c in base_color)
|
||||
self.driver.fill(self.driver.apply_brightness(color, preset.b))
|
||||
elif elapsed < attack_ms + hold_ms:
|
||||
# Hold: full brightness
|
||||
self.driver.fill(self.driver.apply_brightness(base_color, preset.b))
|
||||
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
|
||||
# Decay: fade 1 -> 0
|
||||
dec_elapsed = elapsed - attack_ms - hold_ms
|
||||
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
|
||||
color = tuple(int(c * factor) for c in base_color)
|
||||
self.driver.fill(self.driver.apply_brightness(color, preset.b))
|
||||
elif elapsed < total_ms:
|
||||
# Delay phase: LEDs off between pulses
|
||||
self.driver.fill((0, 0, 0))
|
||||
else:
|
||||
# End of cycle, move to next color and restart timing
|
||||
color_index += 1
|
||||
cycle_start = now
|
||||
if not preset.a:
|
||||
break
|
||||
# Skip drawing this tick, start next cycle
|
||||
yield
|
||||
continue
|
||||
|
||||
# Yield once per tick
|
||||
yield
|
||||
@@ -1,51 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Rainbow:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def _wheel(self, pos):
|
||||
if pos < 85:
|
||||
return (pos * 3, 255 - pos * 3, 0)
|
||||
elif pos < 170:
|
||||
pos -= 85
|
||||
return (255 - pos * 3, 0, pos * 3)
|
||||
else:
|
||||
pos -= 170
|
||||
return (0, pos * 3, 255 - pos * 3)
|
||||
|
||||
def run(self, preset):
|
||||
step = self.driver.step % 256
|
||||
step_amount = max(1, int(preset.n1)) # n1 controls step increment
|
||||
|
||||
# If auto is False, run a single step and then stop
|
||||
if not preset.a:
|
||||
for i in range(self.driver.num_leds):
|
||||
rc_index = (i * 256 // self.driver.num_leds) + step
|
||||
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
|
||||
self.driver.n.write()
|
||||
# Increment step by n1 for next manual call
|
||||
self.driver.step = (step + step_amount) % 256
|
||||
# Allow tick() to advance the generator once
|
||||
yield
|
||||
return
|
||||
|
||||
last_update = utime.ticks_ms()
|
||||
|
||||
while True:
|
||||
current_time = utime.ticks_ms()
|
||||
sleep_ms = max(1, int(preset.d)) # Get delay from preset
|
||||
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
|
||||
for i in range(self.driver.num_leds):
|
||||
rc_index = (i * 256 // self.driver.num_leds) + step
|
||||
self.driver.n[i] = self.driver.apply_brightness(
|
||||
self._wheel(rc_index & 255),
|
||||
preset.b,
|
||||
)
|
||||
self.driver.n.write()
|
||||
step = (step + step_amount) % 256
|
||||
self.driver.step = step
|
||||
last_update = current_time
|
||||
# Yield once per tick so other logic can run
|
||||
yield
|
||||
@@ -1,57 +0,0 @@
|
||||
import utime
|
||||
|
||||
|
||||
class Transition:
|
||||
def __init__(self, driver):
|
||||
self.driver = driver
|
||||
|
||||
def run(self, preset):
|
||||
"""Transition between colors, blending over `delay` ms."""
|
||||
colors = preset.c
|
||||
if not colors:
|
||||
self.driver.off()
|
||||
yield
|
||||
return
|
||||
|
||||
# Only one color: just keep it on
|
||||
if len(colors) == 1:
|
||||
while True:
|
||||
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
|
||||
yield
|
||||
return
|
||||
|
||||
color_index = 0
|
||||
start_time = utime.ticks_ms()
|
||||
|
||||
while True:
|
||||
if not colors:
|
||||
break
|
||||
|
||||
# Get current and next color based on live list
|
||||
c1 = colors[color_index % len(colors)]
|
||||
c2 = colors[(color_index + 1) % len(colors)]
|
||||
|
||||
duration = max(10, int(preset.d)) # At least 10ms
|
||||
now = utime.ticks_ms()
|
||||
elapsed = utime.ticks_diff(now, start_time)
|
||||
|
||||
if elapsed >= duration:
|
||||
# End of this transition step
|
||||
if not preset.a:
|
||||
# One-shot: transition from first to second color only
|
||||
self.driver.fill(self.driver.apply_brightness(c2, preset.b))
|
||||
break
|
||||
# Auto: move to next pair
|
||||
color_index = (color_index + 1) % len(colors)
|
||||
start_time = now
|
||||
yield
|
||||
continue
|
||||
|
||||
# Interpolate between c1 and c2
|
||||
factor = elapsed / duration
|
||||
interpolated = tuple(
|
||||
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
|
||||
)
|
||||
self.driver.fill(self.driver.apply_brightness(interpolated, preset.b))
|
||||
|
||||
yield
|
||||
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_endpoints_pytest.py"]
|
||||
@@ -1,4 +0,0 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_files = test_endpoints_pytest.py
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||
cd "$(dirname "$0")/.."
|
||||
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||
@@ -6,11 +6,12 @@ from models.device import (
|
||||
validate_device_type,
|
||||
)
|
||||
from models.transport import get_current_sender
|
||||
from models.tcp_clients import (
|
||||
from models.wifi_ws_clients import (
|
||||
normalize_tcp_peer_ip,
|
||||
send_json_line_to_ip,
|
||||
tcp_client_connected,
|
||||
)
|
||||
from util.driver_patterns import driver_patterns_dir
|
||||
from util.espnow_message import build_message
|
||||
import asyncio
|
||||
import json
|
||||
@@ -55,8 +56,8 @@ devices = Device()
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
"""
|
||||
Wi-Fi: whether a TCP client is registered for this device's address (IP).
|
||||
ESP-NOW: None (no TCP session on the Pi for that transport).
|
||||
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
|
||||
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
|
||||
"""
|
||||
tr = (dev_dict.get("transport") or "espnow").strip().lower()
|
||||
if tr != "wifi":
|
||||
@@ -73,11 +74,6 @@ def _device_json_with_live_status(dev_dict):
|
||||
return row
|
||||
|
||||
|
||||
def _driver_patterns_dir():
|
||||
here = os.path.dirname(__file__)
|
||||
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
@@ -89,7 +85,7 @@ def _safe_pattern_filename(name):
|
||||
|
||||
|
||||
def _build_patterns_manifest(host):
|
||||
base_dir = _driver_patterns_dir()
|
||||
base_dir = driver_patterns_dir()
|
||||
names = sorted(os.listdir(base_dir))
|
||||
files = []
|
||||
for name in names:
|
||||
@@ -118,7 +114,7 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
"""List all devices (includes ``connected`` for live Wi-Fi TCP presence)."""
|
||||
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
@@ -129,7 +125,7 @@ async def list_devices(request):
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
"""Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence)."""
|
||||
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(_device_json_with_live_status(dev)), 200, {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
from microdot import Microdot
|
||||
from models.pattern import Pattern
|
||||
from models.device import Device
|
||||
from models.tcp_clients import send_json_line_to_ip
|
||||
from models.wifi_ws_clients import send_json_line_to_ip
|
||||
from util.driver_patterns import (
|
||||
driver_patterns_dir,
|
||||
is_firmware_builtin_pattern_module,
|
||||
normalize_pattern_py_filename,
|
||||
)
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
@@ -17,11 +22,6 @@ def _project_root():
|
||||
return os.path.abspath(os.path.join(here, "..", ".."))
|
||||
|
||||
|
||||
def _driver_patterns_dir():
|
||||
here = os.path.dirname(__file__)
|
||||
return os.path.abspath(os.path.join(here, "../../led-driver/src/patterns"))
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
@@ -75,7 +75,7 @@ def load_driver_pattern_names():
|
||||
"""List available pattern module names from led-driver/src/patterns."""
|
||||
try:
|
||||
names = []
|
||||
for filename in os.listdir(_driver_patterns_dir()):
|
||||
for filename in os.listdir(driver_patterns_dir()):
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
continue
|
||||
names.append(filename[:-3])
|
||||
@@ -111,7 +111,7 @@ async def get_pattern_definitions(request):
|
||||
@controller.get('/ota/manifest')
|
||||
async def ota_manifest(request):
|
||||
"""Manifest of driver pattern source files for OTA pulls."""
|
||||
base_dir = _driver_patterns_dir()
|
||||
base_dir = driver_patterns_dir()
|
||||
host = request.headers.get("Host", "")
|
||||
if not host:
|
||||
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||
@@ -137,16 +137,32 @@ async def ota_manifest(request):
|
||||
@controller.get('/ota/file/<name>')
|
||||
async def ota_pattern_file(request, name):
|
||||
"""Serve one driver pattern source file for OTA pulls."""
|
||||
if not _safe_pattern_filename(name) or name == "__init__.py":
|
||||
fname = normalize_pattern_py_filename(name)
|
||||
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
|
||||
return json.dumps({"error": "Invalid filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
path = os.path.join(_driver_patterns_dir(), name)
|
||||
if is_firmware_builtin_pattern_module(fname):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "on and off are built into the driver firmware; there is no module file to serve.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, fname)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
@@ -159,19 +175,34 @@ async def send_pattern_to_device(request, name):
|
||||
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
filename = name if name.endswith(".py") else (name + ".py")
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
filename = normalize_pattern_py_filename(name)
|
||||
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "Invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "on and off are built into the driver firmware; OTA send does not apply.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
devices = Device()
|
||||
body = request.json or {}
|
||||
requested_device_id = str(body.get("device_id") or "").strip()
|
||||
|
||||
path = os.path.join(_driver_patterns_dir(), filename)
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, filename)
|
||||
if not os.path.exists(path):
|
||||
return json.dumps({"error": "Pattern file not found"}), 404, {
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
@@ -261,12 +292,18 @@ async def upload_pattern_file(request):
|
||||
return json.dumps({"error": "invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
path = os.path.join(_driver_patterns_dir(), filename)
|
||||
path = os.path.join(driver_patterns_dir(), filename)
|
||||
exists = os.path.exists(path)
|
||||
if exists and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
@@ -304,6 +341,12 @@ async def create_driver_pattern(request):
|
||||
return json.dumps({
|
||||
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
|
||||
}), 400, {"Content-Type": "application/json"}
|
||||
if is_firmware_builtin_pattern_module(key):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
code = data.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
@@ -314,7 +357,7 @@ async def create_driver_pattern(request):
|
||||
overwrite = bool(data.get("overwrite", True))
|
||||
|
||||
filename = key + ".py"
|
||||
py_path = os.path.join(_driver_patterns_dir(), filename)
|
||||
py_path = os.path.join(driver_patterns_dir(), filename)
|
||||
if os.path.exists(py_path) and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
|
||||
315
src/main.py
315
src/main.py
@@ -23,7 +23,7 @@ import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
from models.transport import get_sender, set_sender, get_current_sender
|
||||
from models.device import Device, normalize_mac
|
||||
from models import tcp_clients as tcp_client_registry
|
||||
from models import wifi_ws_clients as tcp_client_registry
|
||||
from util.device_status_broadcaster import (
|
||||
broadcast_device_tcp_snapshot_to,
|
||||
broadcast_device_tcp_status,
|
||||
@@ -33,82 +33,8 @@ from util.device_status_broadcaster import (
|
||||
|
||||
_tcp_device_lock = threading.Lock()
|
||||
|
||||
# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers
|
||||
# fail drain() within this interval (keepalive alone is often slow or ineffective).
|
||||
TCP_LIVENESS_PING_INTERVAL_S = 12.0
|
||||
DISCOVERY_UDP_PORT = 8766
|
||||
|
||||
# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed.
|
||||
_TCP_PEER_GONE = (
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
ConnectionAbortedError,
|
||||
ConnectionRefusedError,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
)
|
||||
|
||||
|
||||
def _tcp_socket_from_writer(writer):
|
||||
sock = writer.get_extra_info("socket")
|
||||
if sock is not None:
|
||||
return sock
|
||||
transport = getattr(writer, "transport", None)
|
||||
if transport is not None:
|
||||
return transport.get_extra_info("socket")
|
||||
return None
|
||||
|
||||
|
||||
def _enable_tcp_keepalive(writer) -> None:
|
||||
"""
|
||||
Detect vanished peers (power off, Wi-Fi drop) without waiting for a send() failure.
|
||||
Linux: shorten time before the first keepalive probe; other platforms: SO_KEEPALIVE only.
|
||||
"""
|
||||
sock = _tcp_socket_from_writer(writer)
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
except OSError:
|
||||
return
|
||||
if hasattr(socket, "TCP_KEEPIDLE"):
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120)
|
||||
except OSError:
|
||||
pass
|
||||
if hasattr(socket, "TCP_KEEPINTVL"):
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15)
|
||||
except OSError:
|
||||
pass
|
||||
if hasattr(socket, "TCP_KEEPCNT"):
|
||||
try:
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4)
|
||||
except OSError:
|
||||
pass
|
||||
# Do not set TCP_USER_TIMEOUT: a short value causes Errno 110 on recv for Wi-Fi peers
|
||||
# when ACKs are delayed (ESP power save, lossy links). Liveness pings already clear dead
|
||||
# sessions via drain().
|
||||
|
||||
|
||||
async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None:
|
||||
"""Send a bare newline so ``drain()`` fails soon after the peer disappears."""
|
||||
while True:
|
||||
await asyncio.sleep(TCP_LIVENESS_PING_INTERVAL_S)
|
||||
if writer.is_closing():
|
||||
return
|
||||
try:
|
||||
writer.write(b"\n")
|
||||
await writer.drain()
|
||||
except Exception as exc:
|
||||
print(f"[TCP] liveness ping failed {peer_ip!r}: {exc!r}")
|
||||
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
|
||||
try:
|
||||
writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
def _register_udp_device_sync(
|
||||
device_name: str, peer_ip: str, mac, device_type=None
|
||||
@@ -116,10 +42,10 @@ def _register_udp_device_sync(
|
||||
with _tcp_device_lock:
|
||||
try:
|
||||
d = Device()
|
||||
did = d.upsert_wifi_tcp_client(
|
||||
did, persisted = d.upsert_wifi_tcp_client(
|
||||
device_name, peer_ip, mac, device_type=device_type
|
||||
)
|
||||
if did:
|
||||
if did and persisted:
|
||||
print(
|
||||
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
||||
)
|
||||
@@ -155,6 +81,8 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||
device_type = parsed.get("type") or parsed.get("device_type")
|
||||
if dns and normalize_mac(mac):
|
||||
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
||||
if str(parsed.get("v") or "") == "1":
|
||||
tcp_client_registry.ensure_driver_connection(peer_ip)
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
@@ -163,6 +91,109 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
||||
print(f"[UDP] echo send failed: {e!r}")
|
||||
|
||||
|
||||
def _prime_wifi_outbound_driver_connections() -> None:
|
||||
"""
|
||||
For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the
|
||||
outbound WebSocket task. The client loop reconnects automatically if the link
|
||||
drops. Presets are not pushed automatically; use Send Presets / profile apply.
|
||||
"""
|
||||
n = 0
|
||||
try:
|
||||
dev = Device()
|
||||
for mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
tcp_client_registry.ensure_driver_connection(ip)
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
return
|
||||
if n:
|
||||
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
|
||||
|
||||
|
||||
def _ipv4_address(addr: str) -> str | None:
|
||||
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
|
||||
s = (addr or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(".")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
nums = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if not all(0 <= n <= 255 for n in nums):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
||||
"""
|
||||
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
|
||||
UDP discovery port so the device can announce itself and we can reconnect.
|
||||
"""
|
||||
try:
|
||||
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
|
||||
except (TypeError, ValueError):
|
||||
interval = 10.0
|
||||
if interval <= 0:
|
||||
return
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
if udp_holder.get("closing"):
|
||||
break
|
||||
try:
|
||||
dev = Device()
|
||||
except Exception as e:
|
||||
print(f"[hello] device list failed: {e!r}")
|
||||
continue
|
||||
for _mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
if tcp_client_registry.tcp_client_connected(ip):
|
||||
continue
|
||||
name = (doc.get("name") or "").strip()
|
||||
mac = normalize_mac(doc.get("id") or _mac_key)
|
||||
if not name or not mac:
|
||||
continue
|
||||
line = (
|
||||
json.dumps(
|
||||
{"m": "hello", "device_name": name, "mac": mac},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
try:
|
||||
await loop.sock_sendto(
|
||||
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
|
||||
)
|
||||
except OSError as e:
|
||||
print(f"[hello] UDP to {ip!r} failed: {e!r}")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
@@ -189,87 +220,6 @@ async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def _handle_tcp_client(reader, writer):
|
||||
"""Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge."""
|
||||
peer = writer.get_extra_info("peername")
|
||||
peer_ip = peer[0] if peer else ""
|
||||
peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?"
|
||||
print(f"[TCP] client connected {peer_label}")
|
||||
_enable_tcp_keepalive(writer)
|
||||
tcp_client_registry.register_tcp_writer(peer_ip, writer)
|
||||
ping_task = asyncio.create_task(_tcp_liveness_ping_loop(writer, peer_ip))
|
||||
sender = get_current_sender()
|
||||
buf = b""
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
chunk = await reader.read(4096)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except _TCP_PEER_GONE as e:
|
||||
print(f"[TCP] read ended ({peer_label}): {e!r}")
|
||||
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
raw_line, buf = buf.split(b"\n", 1)
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
text = line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
print(
|
||||
f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}"
|
||||
)
|
||||
continue
|
||||
print(f"[TCP] recv {peer_label}: {text}")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
if sender:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else "{}"
|
||||
if sender:
|
||||
try:
|
||||
await sender.send(payload, addr=addr)
|
||||
except Exception as e:
|
||||
print(f"TCP forward to bridge failed: {e}")
|
||||
elif sender:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Drop registry + broadcast connected:false before awaiting ping/close so the UI
|
||||
# does not stay green if ping or wait_closed blocks on a timed-out peer.
|
||||
outcome = tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
|
||||
if outcome == "superseded":
|
||||
print(
|
||||
f"[TCP] TCP session ended (same IP already has a newer connection): {peer_label}"
|
||||
)
|
||||
ping_task.cancel()
|
||||
try:
|
||||
await ping_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except _TCP_PEER_GONE:
|
||||
tcp_client_registry.unregister_tcp_writer(peer_ip, writer)
|
||||
|
||||
|
||||
async def _send_bridge_wifi_channel(settings, sender):
|
||||
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
|
||||
try:
|
||||
@@ -285,23 +235,6 @@ async def _send_bridge_wifi_channel(settings, sender):
|
||||
print(f"[startup] bridge channel message failed: {e}")
|
||||
|
||||
|
||||
async def _run_tcp_server(settings, tcp_holder=None):
|
||||
if not settings.get("tcp_enabled", True):
|
||||
print("TCP server disabled (tcp_enabled=false)")
|
||||
return
|
||||
port = int(settings.get("tcp_port", 8765))
|
||||
server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port)
|
||||
print(f"TCP server listening on 0.0.0.0:{port}")
|
||||
if tcp_holder is not None:
|
||||
tcp_holder["server"] = server
|
||||
try:
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
finally:
|
||||
if tcp_holder is not None:
|
||||
tcp_holder.pop("server", None)
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
@@ -341,6 +274,7 @@ async def main(port=80):
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
|
||||
tcp_client_registry.set_settings(settings)
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@@ -354,7 +288,7 @@ async def main(port=80):
|
||||
def settings_page(request):
|
||||
"""Serve the settings page."""
|
||||
return send_file('templates/settings.html')
|
||||
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
def favicon(request):
|
||||
@@ -407,11 +341,11 @@ async def main(port=80):
|
||||
|
||||
|
||||
|
||||
# Touch Device singleton early so db/device.json exists before first TCP hello.
|
||||
# Touch Device singleton early so db/device.json exists before first UDP hello.
|
||||
Device()
|
||||
await _send_bridge_wifi_channel(settings, sender)
|
||||
_prime_wifi_outbound_driver_connections()
|
||||
|
||||
tcp_holder = {}
|
||||
udp_holder = {"closing": False}
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -424,9 +358,7 @@ async def main(port=80):
|
||||
u.close()
|
||||
except OSError:
|
||||
pass
|
||||
s = tcp_holder.get("server")
|
||||
if s is not None:
|
||||
s.close()
|
||||
tcp_client_registry.cancel_all_driver_tasks()
|
||||
if getattr(app, "server", None) is not None:
|
||||
app.shutdown()
|
||||
|
||||
@@ -439,26 +371,33 @@ async def main(port=80):
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface
|
||||
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
|
||||
# never starts, which clears Wi-Fi presence dots.
|
||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||
try:
|
||||
await asyncio.gather(
|
||||
app.start_server(host="0.0.0.0", port=port),
|
||||
_run_tcp_server(settings, tcp_holder),
|
||||
_run_udp_discovery_server(udp_holder),
|
||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||
)
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
tcp_p = int(settings.get("tcp_port", 8765))
|
||||
print(
|
||||
f"[server] bind failed (address already in use): {e!s}\n"
|
||||
f"[server] HTTP is configured for port {port} (env PORT); "
|
||||
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
|
||||
f"[server] HTTP is configured for port {port} (env PORT). "
|
||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
srv = getattr(app, "server", None)
|
||||
if srv is not None:
|
||||
try:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
if shutdown_handlers_registered:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
|
||||
@@ -237,16 +237,19 @@ class Device(Model):
|
||||
"""
|
||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||
**address** (peer IP), and optionally **type** from the client hello when valid.
|
||||
|
||||
Returns ``(mac_hex | None, persisted)`` where **persisted** is True iff ``save()``
|
||||
ran (new row or field changes). Duplicate hellos with identical data are no-ops.
|
||||
"""
|
||||
mac_hex = normalize_mac(mac)
|
||||
if not mac_hex:
|
||||
return None
|
||||
return None, False
|
||||
name = (device_name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
return None, False
|
||||
ip = normalize_address_for_transport(peer_ip, "wifi")
|
||||
if not ip:
|
||||
return None
|
||||
return None, False
|
||||
resolved_type = None
|
||||
if device_type is not None:
|
||||
try:
|
||||
@@ -254,7 +257,8 @@ class Device(Model):
|
||||
except ValueError:
|
||||
resolved_type = None
|
||||
if mac_hex in self:
|
||||
merged = dict(self[mac_hex])
|
||||
prev = self[mac_hex]
|
||||
merged = dict(prev)
|
||||
merged["name"] = name
|
||||
if resolved_type is not None:
|
||||
merged["type"] = resolved_type
|
||||
@@ -263,9 +267,11 @@ class Device(Model):
|
||||
merged["transport"] = "wifi"
|
||||
merged["address"] = ip
|
||||
merged["id"] = mac_hex
|
||||
if merged == prev:
|
||||
return mac_hex, False
|
||||
self[mac_hex] = merged
|
||||
self.save()
|
||||
return mac_hex
|
||||
return mac_hex, True
|
||||
self[mac_hex] = {
|
||||
"id": mac_hex,
|
||||
"name": name,
|
||||
@@ -276,4 +282,4 @@ class Device(Model):
|
||||
"zones": [],
|
||||
}
|
||||
self.save()
|
||||
return mac_hex
|
||||
return mac_hex, True
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Track connected Wi-Fi LED drivers (TCP clients) for outbound JSON lines."""
|
||||
|
||||
import asyncio
|
||||
|
||||
_writers = {}
|
||||
|
||||
|
||||
def prune_stale_tcp_writers() -> None:
|
||||
"""Remove writers that are already closing so the UI does not stay online."""
|
||||
stale = [(ip, w) for ip, w in list(_writers.items()) if w.is_closing()]
|
||||
for ip, w in stale:
|
||||
unregister_tcp_writer(ip, w)
|
||||
|
||||
|
||||
def normalize_tcp_peer_ip(ip: str) -> str:
|
||||
"""Match asyncio peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
# Optional ``async def (ip: str, connected: bool) -> None`` set from ``main``.
|
||||
_tcp_status_broadcast = None
|
||||
|
||||
|
||||
def set_tcp_status_broadcaster(coro) -> None:
|
||||
global _tcp_status_broadcast
|
||||
_tcp_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_tcp_status_broadcast(ip: str, connected: bool) -> None:
|
||||
fn = _tcp_status_broadcast
|
||||
if not fn:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
try:
|
||||
loop.create_task(fn(ip, connected))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_tcp_writer(peer_ip: str, writer) -> None:
|
||||
if not peer_ip:
|
||||
return
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return
|
||||
old = _writers.get(key)
|
||||
_writers[key] = writer
|
||||
_schedule_tcp_status_broadcast(key, True)
|
||||
if old is not None and old is not writer:
|
||||
try:
|
||||
old.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def unregister_tcp_writer(peer_ip: str, writer=None) -> str:
|
||||
"""
|
||||
Remove the writer for peer_ip. If ``writer`` is given, only pop when it is still
|
||||
the registered instance (avoids a replaced TCP session removing the new one).
|
||||
|
||||
Returns ``removed`` (cleared live session + UI offline), ``noop`` (already gone),
|
||||
or ``superseded`` (this writer is not the registered one for that IP).
|
||||
"""
|
||||
if not peer_ip:
|
||||
return "noop"
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return "noop"
|
||||
current = _writers.get(key)
|
||||
if writer is not None:
|
||||
if current is None:
|
||||
return "noop"
|
||||
if current is not writer:
|
||||
return "superseded"
|
||||
had = key in _writers
|
||||
if had:
|
||||
_writers.pop(key, None)
|
||||
_schedule_tcp_status_broadcast(key, False)
|
||||
print(f"[TCP] device disconnected: {key}")
|
||||
return "removed"
|
||||
return "noop"
|
||||
|
||||
|
||||
def list_connected_ips():
|
||||
"""IPs with an active TCP writer (for UI snapshot)."""
|
||||
prune_stale_tcp_writers()
|
||||
return list(_writers.keys())
|
||||
|
||||
|
||||
def tcp_client_connected(ip: str) -> bool:
|
||||
"""True if a Wi-Fi driver is connected on this IP (TCP writer registered)."""
|
||||
prune_stale_tcp_writers()
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
return bool(key and key in _writers)
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Send one newline-terminated JSON message to a connected TCP client."""
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
writer = _writers.get(ip)
|
||||
if not writer:
|
||||
return False
|
||||
try:
|
||||
line = json_str if json_str.endswith("\n") else json_str + "\n"
|
||||
writer.write(line.encode("utf-8"))
|
||||
await writer.drain()
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[TCP] send to {ip} failed: {exc}")
|
||||
unregister_tcp_writer(ip, writer)
|
||||
return False
|
||||
281
src/models/wifi_ws_clients.py
Normal file
281
src/models/wifi_ws_clients.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Outbound WebSocket clients to Wi-Fi LED drivers (firmware serves ``/ws`` on device)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import traceback
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
|
||||
_connections: dict[str, object] = {}
|
||||
_send_locks: dict[str, asyncio.Lock] = {}
|
||||
_tasks: dict[str, asyncio.Task] = {}
|
||||
_unreachable_counts: dict[str, int] = {}
|
||||
_settings = None
|
||||
|
||||
_tcp_status_broadcast = None
|
||||
|
||||
|
||||
def set_settings(settings) -> None:
|
||||
global _settings
|
||||
_settings = settings
|
||||
|
||||
|
||||
def set_tcp_status_broadcaster(coro) -> None:
|
||||
global _tcp_status_broadcast
|
||||
_tcp_status_broadcast = coro
|
||||
|
||||
|
||||
def _schedule_status_broadcast(ip: str, connected: bool) -> None:
|
||||
fn = _tcp_status_broadcast
|
||||
if not fn:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
try:
|
||||
loop.create_task(fn(ip, connected))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _benign_ws_connect_failure(exc: BaseException) -> bool:
|
||||
"""True for common \"driver down / no route\" errors while dialling the WebSocket."""
|
||||
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
|
||||
return True
|
||||
if isinstance(exc, ConnectionRefusedError):
|
||||
return True
|
||||
if not isinstance(exc, OSError):
|
||||
return False
|
||||
en = exc.errno
|
||||
if en is None:
|
||||
return False
|
||||
codes = {errno.ECONNREFUSED, errno.ETIMEDOUT}
|
||||
for name in ("EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EADDRNOTAVAIL"):
|
||||
if hasattr(errno, name):
|
||||
codes.add(getattr(errno, name))
|
||||
return en in codes
|
||||
|
||||
|
||||
def normalize_tcp_peer_ip(ip: str) -> str:
|
||||
"""Match peer addresses to registry IPs (strip IPv4-mapped IPv6 prefix)."""
|
||||
s = str(ip).strip()
|
||||
if s.lower().startswith("::ffff:"):
|
||||
s = s[7:]
|
||||
return s
|
||||
|
||||
|
||||
def _ws_open(ws) -> bool:
|
||||
try:
|
||||
return ws.close_code is None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def prune_stale_tcp_writers() -> None:
|
||||
"""Drop closed WebSocket entries (name kept for callers)."""
|
||||
stale = [ip for ip, ws in list(_connections.items()) if not _ws_open(ws)]
|
||||
for ip in stale:
|
||||
_connections.pop(ip, None)
|
||||
_schedule_status_broadcast(ip, False)
|
||||
|
||||
|
||||
def _register_ws(ip: str, ws) -> None:
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
if not key:
|
||||
return
|
||||
_connections[key] = ws
|
||||
_unreachable_counts.pop(key, None)
|
||||
if key not in _send_locks:
|
||||
_send_locks[key] = asyncio.Lock()
|
||||
_schedule_status_broadcast(key, True)
|
||||
print(f"[WS] driver connected {key!r}")
|
||||
|
||||
|
||||
def unregister_tcp_writer(peer_ip: str, ws=None) -> str:
|
||||
"""
|
||||
Remove the WebSocket for peer_ip. If ``ws`` is given, only pop when it is still
|
||||
the registered instance.
|
||||
|
||||
Returns ``removed``, ``noop``, or ``superseded`` (same contract as former TCP registry).
|
||||
"""
|
||||
if not peer_ip:
|
||||
return "noop"
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return "noop"
|
||||
current = _connections.get(key)
|
||||
if ws is not None:
|
||||
if current is None:
|
||||
return "noop"
|
||||
if current is not ws:
|
||||
return "superseded"
|
||||
had = key in _connections
|
||||
if had:
|
||||
_connections.pop(key, None)
|
||||
_schedule_status_broadcast(key, False)
|
||||
print(f"[WS] driver disconnected: {key}")
|
||||
return "removed"
|
||||
return "noop"
|
||||
|
||||
|
||||
def list_connected_ips():
|
||||
"""IPs with an active outbound WebSocket to the driver."""
|
||||
prune_stale_tcp_writers()
|
||||
return list(_connections.keys())
|
||||
|
||||
|
||||
def tcp_client_connected(ip: str) -> bool:
|
||||
"""True if the controller has an outbound WebSocket to this driver IP."""
|
||||
prune_stale_tcp_writers()
|
||||
key = normalize_tcp_peer_ip(ip)
|
||||
return bool(key and key in _connections)
|
||||
|
||||
|
||||
async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
||||
"""Send one JSON text frame (v1 line; trailing newline stripped for WebSocket)."""
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
ws = _connections.get(ip)
|
||||
if ws is None or not _ws_open(ws):
|
||||
return False
|
||||
text = json_str.rstrip("\n")
|
||||
lock = _send_locks.setdefault(ip, asyncio.Lock())
|
||||
try:
|
||||
async with lock:
|
||||
await ws.send(text)
|
||||
return True
|
||||
except Exception as exc:
|
||||
print(f"[WS] send to {ip} failed: {exc}")
|
||||
unregister_tcp_writer(ip, ws)
|
||||
return False
|
||||
|
||||
|
||||
async def _recv_forward_loop(ip: str, ws) -> None:
|
||||
from models.transport import get_current_sender
|
||||
|
||||
sender = get_current_sender()
|
||||
async for message in ws:
|
||||
if isinstance(message, bytes):
|
||||
try:
|
||||
text = message.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
print(f"[WS] recv {ip} (non-UTF-8, {len(message)} bytes)")
|
||||
continue
|
||||
else:
|
||||
text = message
|
||||
text = text.strip()
|
||||
if not text:
|
||||
continue
|
||||
print(f"[WS] recv {ip}: {text}")
|
||||
if not sender:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else "{}"
|
||||
try:
|
||||
await sender.send(payload, addr=addr)
|
||||
except Exception as e:
|
||||
print(f"[WS] forward to bridge failed: {e}")
|
||||
else:
|
||||
try:
|
||||
await sender.send(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _driver_connection_loop(ip: str) -> None:
|
||||
global _settings
|
||||
if _settings is None:
|
||||
return
|
||||
port = int(_settings.get("wifi_driver_ws_port", 80))
|
||||
path = str(_settings.get("wifi_driver_ws_path", "/ws"))
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
uri = f"ws://{ip}:{port}{path}"
|
||||
retry_interval_s = 2.0
|
||||
retry_window_s = 30.0
|
||||
deadline = asyncio.get_running_loop().time() + retry_window_s
|
||||
try:
|
||||
while True:
|
||||
now = asyncio.get_running_loop().time()
|
||||
if now >= deadline:
|
||||
print(
|
||||
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; "
|
||||
"stopping retries until next hello"
|
||||
)
|
||||
break
|
||||
try:
|
||||
print(f"[WS] connecting to {uri!r}")
|
||||
async with websockets.connect(
|
||||
uri,
|
||||
ping_interval=20,
|
||||
ping_timeout=15,
|
||||
open_timeout=30,
|
||||
) as ws:
|
||||
_register_ws(ip, ws)
|
||||
try:
|
||||
await _recv_forward_loop(ip, ws)
|
||||
finally:
|
||||
unregister_tcp_writer(ip, ws)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ConnectionClosed as e:
|
||||
print(f"[WS] driver {ip} closed: {e}")
|
||||
unregister_tcp_writer(ip, None)
|
||||
except Exception as e:
|
||||
if _benign_ws_connect_failure(e):
|
||||
n = _unreachable_counts.get(ip, 0) + 1
|
||||
_unreachable_counts[ip] = n
|
||||
if n == 1 or (n % 30) == 0:
|
||||
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})")
|
||||
else:
|
||||
print(f"[WS] driver {ip} session error: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
_unreachable_counts.pop(ip, None)
|
||||
unregister_tcp_writer(ip, None)
|
||||
await asyncio.sleep(retry_interval_s)
|
||||
except asyncio.CancelledError:
|
||||
unregister_tcp_writer(ip, None)
|
||||
raise
|
||||
finally:
|
||||
_tasks.pop(ip, None)
|
||||
|
||||
|
||||
def ensure_driver_connection(peer_ip: str) -> None:
|
||||
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
|
||||
key = normalize_tcp_peer_ip(peer_ip)
|
||||
if not key:
|
||||
return
|
||||
t = _tasks.get(key)
|
||||
if t is not None and not t.done():
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
_tasks[key] = loop.create_task(_driver_connection_loop(key))
|
||||
|
||||
|
||||
def cancel_all_driver_tasks() -> None:
|
||||
"""Signal shutdown: cancel outbound driver connection tasks."""
|
||||
for _ip, t in list(_tasks.items()):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
_tasks.clear()
|
||||
for ip in list(_connections.keys()):
|
||||
_schedule_status_broadcast(ip, False)
|
||||
_connections.clear()
|
||||
_send_locks.clear()
|
||||
_unreachable_counts.clear()
|
||||
@@ -48,11 +48,15 @@ class Settings(dict):
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 6
|
||||
# Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport)
|
||||
if 'tcp_enabled' not in self:
|
||||
self['tcp_enabled'] = True
|
||||
if 'tcp_port' not in self:
|
||||
self['tcp_port'] = 8765
|
||||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
||||
if 'wifi_driver_ws_port' not in self:
|
||||
self['wifi_driver_ws_port'] = 80
|
||||
if 'wifi_driver_ws_path' not in self:
|
||||
self['wifi_driver_ws_path'] = '/ws'
|
||||
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
|
||||
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
|
||||
if 'wifi_driver_hello_interval_s' not in self:
|
||||
self['wifi_driver_hello_interval_s'] = 10.0
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -55,8 +55,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (mode === 'create') {
|
||||
if (labelEl) {
|
||||
labelEl.textContent = '';
|
||||
labelEl.style.display = 'none';
|
||||
labelEl.textContent = `${key}:`;
|
||||
labelEl.style.display = '';
|
||||
}
|
||||
if (inputEl) {
|
||||
inputEl.value = '';
|
||||
@@ -203,6 +203,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
/** on/off are implemented in driver firmware (presets.py), not as OTA ``.py`` files. */
|
||||
const FIRMWARE_BUILTIN_PATTERNS = new Set(['on', 'off']);
|
||||
|
||||
const isFirmwareBuiltinPattern = (patternName) => {
|
||||
const id = String(patternName || '')
|
||||
.trim()
|
||||
.replace(/\.py$/i, '')
|
||||
.toLowerCase();
|
||||
return FIRMWARE_BUILTIN_PATTERNS.has(id);
|
||||
};
|
||||
|
||||
const sendPatternToDevices = async (patternName) => {
|
||||
const response = await fetch(`/patterns/${encodeURIComponent(patternName)}/send`, {
|
||||
method: 'POST',
|
||||
@@ -281,7 +292,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(patternName)}.py`, {
|
||||
const raw = String(patternName || '').trim();
|
||||
const fileSegment = /\.py$/i.test(raw) ? raw : `${raw}.py`;
|
||||
const response = await fetch(`/patterns/ota/file/${encodeURIComponent(fileSegment)}`, {
|
||||
headers: { Accept: 'text/plain' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -315,39 +328,41 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const label = document.createElement('span');
|
||||
label.textContent = patternName;
|
||||
|
||||
const details = document.createElement('span');
|
||||
const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
|
||||
const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
|
||||
details.textContent = `${minDelay}–${maxDelay} ms`;
|
||||
details.style.color = '#aaa';
|
||||
details.style.fontSize = '0.85em';
|
||||
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.className = 'btn btn-primary btn-small';
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await sendPatternToDevices(patternName);
|
||||
} catch (error) {
|
||||
console.error('Send pattern failed:', error);
|
||||
alert(error.message || 'Failed to send pattern.');
|
||||
}
|
||||
});
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', async () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
await loadPatternIntoEditor(patternName, data || {});
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(details);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(sendBtn);
|
||||
|
||||
if (isFirmwareBuiltinPattern(patternName)) {
|
||||
const note = document.createElement('span');
|
||||
note.className = 'muted-text';
|
||||
note.style.fontSize = '0.85em';
|
||||
note.textContent = 'Built-in (no OTA module)';
|
||||
row.appendChild(note);
|
||||
} else {
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.className = 'btn btn-primary btn-small';
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
await sendPatternToDevices(patternName);
|
||||
} catch (error) {
|
||||
console.error('Send pattern failed:', error);
|
||||
alert(error.message || 'Failed to send pattern.');
|
||||
}
|
||||
});
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', async () => {
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.add('active');
|
||||
}
|
||||
await loadPatternIntoEditor(patternName, data || {});
|
||||
});
|
||||
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(sendBtn);
|
||||
}
|
||||
|
||||
patternsList.appendChild(row);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1288,6 +1288,12 @@ body.preset-ui-run .edit-mode-only {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .n-param-group:has(.pattern-n-readable-input) label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 2.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#pattern-editor-modal .pattern-n-readable-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
|
||||
@@ -259,14 +259,6 @@
|
||||
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
|
||||
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
|
||||
</div>
|
||||
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
|
||||
<input type="number" id="pattern-create-min-delay" min="0" value="10">
|
||||
<label for="pattern-create-max-delay">Max delay (ms)</label>
|
||||
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
|
||||
<label for="pattern-create-max-colors">Max colours</label>
|
||||
<input type="number" id="pattern-create-max-colors" min="0" value="10">
|
||||
</div>
|
||||
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
|
||||
<h3 class="muted-text">Readable parameter names</h3>
|
||||
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
|
||||
@@ -305,6 +297,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-row pattern-editor-meta-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-min-delay" style="min-width: 7rem;">Min delay (ms)</label>
|
||||
<input type="number" id="pattern-create-min-delay" min="0" value="10">
|
||||
<label for="pattern-create-max-delay">Max delay (ms)</label>
|
||||
<input type="number" id="pattern-create-max-delay" min="0" value="10000">
|
||||
<label for="pattern-create-max-colors">Max colours</label>
|
||||
<input type="number" id="pattern-create-max-colors" min="0" value="10">
|
||||
</div>
|
||||
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-file">Pattern file</label>
|
||||
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ESPNow Message Builder
|
||||
# Driver message builder (`espnow_message`)
|
||||
|
||||
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
|
||||
This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -69,7 +69,7 @@ presets = build_presets_dict(presets_data)
|
||||
|
||||
## API Specification
|
||||
|
||||
See `docs/API.md` for the complete ESPNow API specification.
|
||||
See **`docs/API.md`** for REST routes, session scoping, and the compact preset keys on the wire.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Push Wi-Fi TCP connect/disconnect updates to browser WebSocket clients."""
|
||||
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
@@ -20,7 +20,7 @@ async def unregister_device_status_ws(ws: Any) -> None:
|
||||
|
||||
|
||||
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
|
||||
from models.tcp_clients import normalize_tcp_peer_ip
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip
|
||||
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
if not ip:
|
||||
@@ -42,7 +42,7 @@ async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
|
||||
|
||||
|
||||
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
|
||||
from models import tcp_clients as tcp
|
||||
from models import wifi_ws_clients as tcp
|
||||
|
||||
ips = tcp.list_connected_ips()
|
||||
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Deliver driver JSON messages over serial (ESP-NOW) and/or TCP (Wi-Fi clients)."""
|
||||
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from models.device import normalize_mac
|
||||
from models.tcp_clients import send_json_line_to_ip
|
||||
from models.wifi_ws_clients import send_json_line_to_ip
|
||||
|
||||
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
|
||||
_SPLIT_MODE = "split"
|
||||
@@ -20,7 +20,7 @@ def _split_serial_envelope(inner_json_str, peer_hex_list):
|
||||
|
||||
def _wifi_message_for_device(msg, device_name):
|
||||
"""
|
||||
For Wi-Fi TCP fanout, narrow a v1 select map to a single device name.
|
||||
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
|
||||
Returns the original message when no narrowing applies.
|
||||
"""
|
||||
if not device_name:
|
||||
@@ -40,6 +40,33 @@ def _wifi_message_for_device(msg, device_name):
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
|
||||
|
||||
def _combine_preset_chunks_for_wifi(chunk_messages):
|
||||
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
|
||||
merged_presets = {}
|
||||
save_flag = False
|
||||
default_id = None
|
||||
for msg in chunk_messages:
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
presets = body.get("presets")
|
||||
if isinstance(presets, dict):
|
||||
merged_presets.update(presets)
|
||||
if body.get("save"):
|
||||
save_flag = True
|
||||
if body.get("default") is not None:
|
||||
default_id = body.get("default")
|
||||
out = {"v": "1", "presets": merged_presets}
|
||||
if save_flag:
|
||||
out["save"] = True
|
||||
if default_id is not None:
|
||||
out["default"] = default_id
|
||||
return json.dumps(out, separators=(",", ":"))
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
@@ -50,8 +77,8 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
):
|
||||
"""
|
||||
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
|
||||
Wi-Fi driver over TCP. If default_id is set, send a per-target default message
|
||||
(unicast serial or TCP) with targets=[device name] for each registry entry.
|
||||
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
|
||||
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
|
||||
"""
|
||||
if not chunk_messages:
|
||||
return 0
|
||||
@@ -72,17 +99,22 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
wifi_ips.append(str(doc["address"]).strip())
|
||||
|
||||
deliveries = 0
|
||||
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
|
||||
for msg in chunk_messages:
|
||||
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
|
||||
for ip in wifi_ips:
|
||||
if ip:
|
||||
tasks.append(send_json_line_to_ip(ip, msg))
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
if results and results[0] is True:
|
||||
deliveries += 1
|
||||
for r in results[1:]:
|
||||
if r is True:
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
for ip in wifi_ips:
|
||||
if not ip:
|
||||
continue
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, wifi_combined_msg):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
if default_id:
|
||||
@@ -98,7 +130,7 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
if await send_json_line_to_ip(ip, out):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] default TCP failed: {e!r}")
|
||||
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
|
||||
else:
|
||||
try:
|
||||
await sender.send(out, addr=mac)
|
||||
@@ -112,10 +144,10 @@ async def deliver_preset_broadcast_then_per_device(
|
||||
|
||||
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
|
||||
"""
|
||||
Send each message string to the bridge and/or TCP clients.
|
||||
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
|
||||
|
||||
If target_macs is None or empty: one serial send per message (default/broadcast address).
|
||||
Otherwise: Wi-Fi uses TCP in parallel. Multiple ESP-NOW peers are sent in **one** serial
|
||||
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
|
||||
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
|
||||
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
|
||||
tasks run together in one asyncio.gather.
|
||||
|
||||
53
src/util/driver_patterns.py
Normal file
53
src/util/driver_patterns.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import os
|
||||
|
||||
_ENV_PATTERNS_DIR = "LED_CONTROLLER_PATTERNS_DIR"
|
||||
|
||||
def driver_patterns_dir():
|
||||
"""Absolute path to driver pattern ``.py`` modules.
|
||||
|
||||
If ``LED_CONTROLLER_PATTERNS_DIR`` is set to an existing directory, that wins
|
||||
(for installs where ``led-driver`` is not next to this repo). Otherwise uses
|
||||
``<project-root>/led-driver/src/patterns``.
|
||||
"""
|
||||
env = (os.environ.get(_ENV_PATTERNS_DIR) or "").strip()
|
||||
if env and os.path.isdir(env):
|
||||
return os.path.abspath(env)
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.abspath(os.path.join(here, "..", ".."))
|
||||
return os.path.join(root, "led-driver", "src", "patterns")
|
||||
|
||||
|
||||
def normalize_pattern_py_filename(name):
|
||||
"""Return a single ``*.py`` basename (no paths), or ``\"\"`` if invalid.
|
||||
|
||||
Strips repeated ``.py`` suffixes so ``blink.py.py`` becomes ``blink.py``.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return ""
|
||||
s = name.strip()
|
||||
if not s:
|
||||
return ""
|
||||
lower = s.lower()
|
||||
while lower.endswith(".py"):
|
||||
s = s[:-3]
|
||||
s = s.strip()
|
||||
lower = s.lower()
|
||||
if not s:
|
||||
return ""
|
||||
if "/" in s or "\\" in s or ".." in s:
|
||||
return ""
|
||||
return s + ".py"
|
||||
|
||||
|
||||
# Implemented in led-driver ``presets.py`` only — no separate ``patterns/*.py``.
|
||||
FIRMWARE_BUILTIN_PATTERN_IDS = frozenset({"on", "off"})
|
||||
|
||||
|
||||
def is_firmware_builtin_pattern_module(name):
|
||||
"""True for ``on`` / ``off``, with or without a ``.py`` suffix."""
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
s = name.strip().lower()
|
||||
while s.endswith(".py"):
|
||||
s = s[:-3].strip()
|
||||
return s in FIRMWARE_BUILTIN_PATTERN_IDS
|
||||
@@ -1,79 +1,47 @@
|
||||
# Tests
|
||||
|
||||
This directory contains tests for the LED Controller project.
|
||||
Tests for the LED Controller project live under **`tests/`** (pytest + legacy scripts).
|
||||
|
||||
## Directory Structure
|
||||
## Layout
|
||||
|
||||
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
|
||||
- `test_ws.py` - WebSocket tests
|
||||
- `test_p2p.py` - ESP-NOW P2P tests
|
||||
- `models/` - Model unit tests
|
||||
- `web.py` - Local development web server
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
|
||||
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage |
|
||||
| `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) |
|
||||
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
|
||||
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
|
||||
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
|
||||
| `ws.py` | WebSocket client checks |
|
||||
| `p2p.py` | ESP-NOW–related helpers / experiments |
|
||||
| `web.py` | Local dev static server (not the main app) |
|
||||
| `conftest.py` | Pytest fixtures |
|
||||
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
|
||||
|
||||
## Running Tests
|
||||
## Running tests
|
||||
|
||||
### Browser Tests (Real Browser Automation)
|
||||
### Pytest (recommended)
|
||||
|
||||
Tests the web interface in an actual browser using Selenium:
|
||||
From the project root (with dev dependencies installed):
|
||||
|
||||
```bash
|
||||
pipenv run pytest tests/ -q
|
||||
```
|
||||
|
||||
### Browser tests (real browser)
|
||||
|
||||
```bash
|
||||
python tests/test_browser.py
|
||||
```
|
||||
|
||||
These tests:
|
||||
- Open a real Chrome browser
|
||||
- Navigate to the device at 192.168.4.1
|
||||
- Interact with UI elements (buttons, forms, modals)
|
||||
- Test complete user workflows
|
||||
- Verify visual elements and interactions
|
||||
Requires **Selenium**, Chrome/Chromium, and a matching **ChromeDriver**.
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install selenium
|
||||
# Also need ChromeDriver installed and in PATH
|
||||
# Download from: https://chromedriver.chromium.org/
|
||||
```
|
||||
|
||||
### Endpoint Tests (Browser-like HTTP)
|
||||
|
||||
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
|
||||
|
||||
```bash
|
||||
python tests/test_endpoints.py
|
||||
```
|
||||
|
||||
These tests:
|
||||
- Mimic web browser requests with proper headers
|
||||
- Handle cookies for session management
|
||||
- Test all CRUD operations (GET, POST, PUT, DELETE)
|
||||
- Verify responses and status codes
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install requests
|
||||
```
|
||||
|
||||
### WebSocket Tests
|
||||
|
||||
```bash
|
||||
python tests/test_ws.py
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
```bash
|
||||
pip install websockets
|
||||
```
|
||||
|
||||
### Model Tests
|
||||
### Model tests only
|
||||
|
||||
```bash
|
||||
python tests/models/run_all.py
|
||||
```
|
||||
|
||||
### Local Development Server
|
||||
### Local static server
|
||||
|
||||
Run the local development server (port 5000):
|
||||
|
||||
```bash
|
||||
python tests/web.py
|
||||
```
|
||||
`tests/web.py` serves files for quick UI experiments; it is **not** the Microdot app. For the real server use **`pipenv run run`** from the repo root.
|
||||
|
||||
300
tests/device_ws_cycle.py
Normal file
300
tests/device_ws_cycle.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover a Wi‑Fi LED driver via UDP hello, then drive it over WebSocket.
|
||||
|
||||
1. Listens on UDP (default port 8766) for the same JSON line the firmware sends
|
||||
(``v``, ``device_name``, ``mac``, ``type``: ``led``).
|
||||
2. Opens ``ws://<device-ip>:<port>/ws``.
|
||||
3. Pushes a few test presets (``v``: ``"1"``) and cycles ``select`` for the
|
||||
reported ``device_name``.
|
||||
|
||||
The firmware sends UDP hello about one second **after** HTTP is listening, so
|
||||
this script retries the WebSocket handshake by default.
|
||||
|
||||
The device ``settings.json`` ``name`` must match ``device_name`` in the hello
|
||||
(and in each ``select`` map).
|
||||
|
||||
Examples::
|
||||
|
||||
pipenv install --dev
|
||||
pipenv run python tests/device_ws_cycle.py
|
||||
|
||||
pipenv run python tests/device_ws_cycle.py --timeout 60 --cycle-s 4
|
||||
|
||||
# Skip UDP; connect directly (set ``--device-name`` to the device's ``name``)::
|
||||
pipenv run python tests/device_ws_cycle.py --host 192.168.1.42 --device-name a
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def _parse_hello_line(data: bytes) -> tuple[dict | None, bytes]:
|
||||
line = data.split(b"\n", 1)[0].strip()
|
||||
if not line:
|
||||
return None, line
|
||||
try:
|
||||
obj = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return None, line
|
||||
if not isinstance(obj, dict):
|
||||
return None, line
|
||||
return obj, line
|
||||
|
||||
|
||||
def wait_for_udp_hello(
|
||||
bind: str,
|
||||
port: int,
|
||||
timeout_s: float,
|
||||
echo: bool,
|
||||
) -> tuple[str, str, dict]:
|
||||
"""Block until a valid hello arrives. Returns (device_ip, device_name, hello_dict)."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except OSError:
|
||||
pass
|
||||
sock.bind((bind, port))
|
||||
sock.settimeout(timeout_s)
|
||||
print(
|
||||
f"UDP listening on {bind}:{port} (timeout {timeout_s}s) — "
|
||||
"power the device or wait for hello…",
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except socket.timeout as e:
|
||||
raise SystemExit(f"No UDP hello before timeout: {e}") from e
|
||||
peer_ip = addr[0]
|
||||
parsed, raw_line = _parse_hello_line(data)
|
||||
if parsed is None:
|
||||
print(f"Ignored datagram from {peer_ip!r}: {raw_line!r}")
|
||||
continue
|
||||
if str(parsed.get("v") or "") != "1":
|
||||
print(f"Ignored v={parsed.get('v')!r} from {peer_ip!r}")
|
||||
continue
|
||||
dev_type = parsed.get("type") or parsed.get("device_type")
|
||||
if dev_type is not None and dev_type != "led":
|
||||
print(f"Ignored type={dev_type!r} from {peer_ip!r}")
|
||||
continue
|
||||
name = str(parsed.get("device_name") or "").strip()
|
||||
mac = parsed.get("mac")
|
||||
if not name or not mac:
|
||||
print(
|
||||
f"Ignored hello without device_name/mac from {peer_ip!r}: {parsed!r}",
|
||||
)
|
||||
continue
|
||||
print(
|
||||
f"Heard hello: ip={peer_ip!r} device_name={name!r} mac={mac!r}",
|
||||
)
|
||||
if echo:
|
||||
try:
|
||||
sock.sendto(data, addr)
|
||||
except OSError as e:
|
||||
print(f"UDP echo to {addr} failed: {e!r}")
|
||||
return peer_ip, name, parsed
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
PRESETS = {
|
||||
"_test_on": {"p": "on", "c": [(0, 80, 200)]},
|
||||
"_test_blink": {"p": "blink", "d": 120, "b": 200, "c": [(255, 40, 0), (0, 40, 255)]},
|
||||
"_test_rainbow": {"p": "rainbow", "d": 12, "n1": 2, "a": True},
|
||||
}
|
||||
|
||||
PRESET_ORDER = ["_test_on", "_test_blink", "_test_rainbow"]
|
||||
|
||||
|
||||
async def cycle_presets(
|
||||
host: str,
|
||||
device_name: str,
|
||||
ws_port: int,
|
||||
ws_path: str,
|
||||
cycle_s: float,
|
||||
passes: int,
|
||||
*,
|
||||
ws_open_timeout_s: float,
|
||||
ws_connect_retries: int,
|
||||
ws_connect_retry_delay_s: float,
|
||||
) -> None:
|
||||
try:
|
||||
import websockets
|
||||
except ImportError as e:
|
||||
raise SystemExit(
|
||||
"Install websockets: pipenv install websockets (or: pip install websockets)"
|
||||
) from e
|
||||
|
||||
path = ws_path if ws_path.startswith("/") else "/" + ws_path
|
||||
uri = f"ws://{host}:{ws_port}{path}"
|
||||
print(f"WebSocket connect {uri!r} …")
|
||||
|
||||
n = max(1, ws_connect_retries)
|
||||
last_err: BaseException | None = None
|
||||
for attempt in range(n):
|
||||
try:
|
||||
async with websockets.connect(
|
||||
uri,
|
||||
open_timeout=ws_open_timeout_s,
|
||||
) as ws:
|
||||
print("Connected.")
|
||||
push = json.dumps({"v": "1", "presets": PRESETS})
|
||||
await ws.send(push)
|
||||
print(f"Sent presets: {list(PRESETS.keys())}")
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
for p in range(passes):
|
||||
print(f"--- pass {p + 1}/{passes} ---")
|
||||
for pname in PRESET_ORDER:
|
||||
sel = json.dumps({"v": "1", "select": {device_name: [pname]}})
|
||||
await ws.send(sel)
|
||||
print(f" select {pname!r}")
|
||||
await asyncio.sleep(cycle_s)
|
||||
|
||||
print("Done.")
|
||||
return
|
||||
except (TimeoutError, OSError, ConnectionError) as e:
|
||||
last_err = e
|
||||
if attempt + 1 < n:
|
||||
print(
|
||||
f" connect failed ({e!r}), retry {attempt + 2}/{n} in "
|
||||
f"{ws_connect_retry_delay_s}s …",
|
||||
)
|
||||
await asyncio.sleep(ws_connect_retry_delay_s)
|
||||
|
||||
raise TimeoutError(
|
||||
f"WebSocket handshake failed after {n} attempts: {last_err!r}",
|
||||
) from last_err
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="UDP hello discovery + WebSocket preset cycle (led-driver)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bind",
|
||||
default="0.0.0.0",
|
||||
help="UDP bind address (default 0.0.0.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--udp-port",
|
||||
type=int,
|
||||
default=8766,
|
||||
help="UDP listen port (default 8766)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=120.0,
|
||||
help="Seconds to wait for first hello (default 120)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-echo",
|
||||
action="store_true",
|
||||
help="Do not echo the datagram back (firmware often uses wait_reply=False)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="",
|
||||
metavar="IP",
|
||||
help="Skip UDP and use this device IP",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device-name",
|
||||
default="",
|
||||
metavar="NAME",
|
||||
help="Device settings name for select map (required with --host if not default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-port",
|
||||
type=int,
|
||||
default=80,
|
||||
help="Device WebSocket port (default 80)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-path",
|
||||
default="/ws",
|
||||
help="WebSocket path (default /ws)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cycle-s",
|
||||
type=float,
|
||||
default=3.0,
|
||||
help="Seconds between select commands (default 3)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--passes",
|
||||
type=int,
|
||||
default=2,
|
||||
help="How many full cycles through all test presets (default 2)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-open-timeout",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="Per-attempt WebSocket handshake timeout in seconds (default 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-retries",
|
||||
type=int,
|
||||
default=15,
|
||||
help="WebSocket connect attempts (default 15; use with device hello after HTTP)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ws-retry-delay",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Seconds between WebSocket retries (default 1)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.host:
|
||||
host = args.host.strip()
|
||||
device_name = (args.device_name or "a").strip()
|
||||
if not device_name:
|
||||
print("--device-name is required when using a generic --host", file=sys.stderr)
|
||||
return 1
|
||||
print(f"Using host {host!r} device_name {device_name!r} (no UDP)")
|
||||
else:
|
||||
host, device_name, _hello = wait_for_udp_hello(
|
||||
args.bind,
|
||||
args.udp_port,
|
||||
args.timeout,
|
||||
echo=not args.no_echo,
|
||||
)
|
||||
|
||||
try:
|
||||
asyncio.run(
|
||||
cycle_presets(
|
||||
host=host,
|
||||
device_name=device_name,
|
||||
ws_port=args.ws_port,
|
||||
ws_path=args.ws_path,
|
||||
cycle_s=args.cycle_s,
|
||||
passes=max(1, args.passes),
|
||||
ws_open_timeout_s=args.ws_open_timeout,
|
||||
ws_connect_retries=args.ws_retries,
|
||||
ws_connect_retry_delay_s=args.ws_retry_delay,
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
return 130
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -93,39 +93,43 @@ def test_device():
|
||||
|
||||
def test_upsert_wifi_tcp_client():
|
||||
devices = _fresh_device()
|
||||
assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) is None
|
||||
assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") is None
|
||||
assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) == (None, False)
|
||||
assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") == (
|
||||
None,
|
||||
False,
|
||||
)
|
||||
|
||||
m1 = "001122334455"
|
||||
m2 = "001122334466"
|
||||
i1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
|
||||
assert i1 == m1
|
||||
i1, p1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
|
||||
assert i1 == m1 and p1 is True
|
||||
d = devices.read(i1)
|
||||
assert d["name"] == "kitchen"
|
||||
assert d["type"] == "led"
|
||||
assert d["transport"] == "wifi"
|
||||
assert d["address"] == "192.168.1.20"
|
||||
|
||||
i2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2)
|
||||
assert i2 == m2
|
||||
noop_mac, noop_p = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
|
||||
assert noop_mac == m1 and noop_p is False
|
||||
|
||||
i2, p2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2)
|
||||
assert i2 == m2 and p2 is True
|
||||
assert devices.read(m1)["address"] == "192.168.1.20"
|
||||
assert devices.read(m2)["address"] == "192.168.1.21"
|
||||
assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen"
|
||||
|
||||
again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
|
||||
assert again == m1
|
||||
again, p_again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
|
||||
assert again == m1 and p_again is True
|
||||
assert devices.read(m1)["address"] == "192.168.1.99"
|
||||
|
||||
assert (
|
||||
devices.upsert_wifi_tcp_client(
|
||||
"kitchen", "192.168.1.100", m1, device_type="bogus"
|
||||
)
|
||||
== m1
|
||||
bogus_mac, bogus_p = devices.upsert_wifi_tcp_client(
|
||||
"kitchen", "192.168.1.100", m1, device_type="bogus"
|
||||
)
|
||||
assert bogus_mac == m1 and bogus_p is True
|
||||
assert devices.read(m1)["type"] == "led"
|
||||
|
||||
i3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
|
||||
assert i3 == "deadbeefcafe"
|
||||
i3, p3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
|
||||
assert i3 == "deadbeefcafe" and p3 is True
|
||||
assert len(devices.list()) == 3
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Browser automation tests using Selenium.
|
||||
Tests run against the device at 192.168.4.1 in an actual browser.
|
||||
Tests run against the device in an actual browser. Target host defaults to
|
||||
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
|
||||
or a full ``http://`` / ``https://`` base URL).
|
||||
|
||||
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
|
||||
(default ``0.5``, i.e. half the nominal pause). Set to ``1`` for the old pacing,
|
||||
or ``0`` to skip fixed sleeps (may flake). Driver implicit wait defaults to
|
||||
``2`` seconds; override with ``LED_CONTROLLER_BROWSER_IMPLICIT_WAIT``.
|
||||
|
||||
On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
|
||||
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1":
|
||||
# pytest catches Skipped; plain `python tests/test_browser.py` does not.
|
||||
if __name__ == "__main__":
|
||||
print(
|
||||
"Browser tests are disabled by default. "
|
||||
"Set LED_CONTROLLER_RUN_BROWSER_TESTS=1 to run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(0)
|
||||
pytest.skip(
|
||||
"Legacy device browser automation script; enable explicitly to run.",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
from typing import Optional, List
|
||||
@@ -28,10 +43,46 @@ from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.chrome.options import Options as ChromeOptions
|
||||
from selenium.webdriver.firefox.options import Options as FirefoxOptions
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||
from selenium.common.exceptions import (
|
||||
TimeoutException,
|
||||
NoSuchElementException,
|
||||
ElementNotInteractableException,
|
||||
)
|
||||
|
||||
_DEFAULT_DEVICE_HOST = "192.168.4.1"
|
||||
|
||||
|
||||
def _device_base_url() -> str:
|
||||
raw = os.environ.get("LED_CONTROLLER_DEVICE_IP", _DEFAULT_DEVICE_HOST).strip()
|
||||
if not raw:
|
||||
raw = _DEFAULT_DEVICE_HOST
|
||||
if raw.startswith(("http://", "https://")):
|
||||
return raw.rstrip("/")
|
||||
return f"http://{raw}"
|
||||
|
||||
|
||||
# Base URL for the device
|
||||
BASE_URL = "http://192.168.4.1"
|
||||
BASE_URL = _device_base_url()
|
||||
|
||||
|
||||
def _browser_sleep(seconds: float) -> None:
|
||||
"""Scale fixed UI pauses via LED_CONTROLLER_BROWSER_SLEEP_SCALE (default 0.5)."""
|
||||
try:
|
||||
scale = float(os.environ.get("LED_CONTROLLER_BROWSER_SLEEP_SCALE", "0.5"))
|
||||
except ValueError:
|
||||
scale = 0.5
|
||||
if scale <= 0:
|
||||
return
|
||||
time.sleep(max(0.0, float(seconds)) * scale)
|
||||
|
||||
|
||||
def _implicit_wait_s() -> int:
|
||||
try:
|
||||
v = float(os.environ.get("LED_CONTROLLER_BROWSER_IMPLICIT_WAIT", "2"))
|
||||
except ValueError:
|
||||
v = 2.0
|
||||
return int(max(0, min(60, round(v))))
|
||||
|
||||
|
||||
class BrowserTest:
|
||||
"""Browser automation test class."""
|
||||
@@ -40,7 +91,7 @@ class BrowserTest:
|
||||
self.base_url = base_url
|
||||
self.driver = None
|
||||
self.headless = headless
|
||||
self.created_tabs: List[str] = []
|
||||
self.created_zones: List[str] = []
|
||||
self.created_profiles: List[str] = []
|
||||
self.created_presets: List[str] = []
|
||||
|
||||
@@ -57,7 +108,7 @@ class BrowserTest:
|
||||
opts.add_argument('--disable-gpu')
|
||||
opts.add_argument('--window-size=1920,1080')
|
||||
self.driver = webdriver.Chrome(options=opts)
|
||||
self.driver.implicitly_wait(5)
|
||||
self.driver.implicitly_wait(_implicit_wait_s())
|
||||
print("✓ Browser started (Chrome)")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -68,7 +119,7 @@ class BrowserTest:
|
||||
if self.headless:
|
||||
opts.add_argument('--headless')
|
||||
self.driver = webdriver.Firefox(options=opts)
|
||||
self.driver.implicitly_wait(5)
|
||||
self.driver.implicitly_wait(_implicit_wait_s())
|
||||
print("✓ Browser started (Firefox)")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -92,7 +143,7 @@ class BrowserTest:
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
self.driver.get(url)
|
||||
time.sleep(1) # Wait for page load
|
||||
_browser_sleep(1) # Wait for page load
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to navigate to {url}: {e}")
|
||||
@@ -107,12 +158,19 @@ class BrowserTest:
|
||||
return element
|
||||
except TimeoutException:
|
||||
return None
|
||||
|
||||
def _scroll_into_view(self, element) -> None:
|
||||
self.driver.execute_script(
|
||||
"arguments[0].scrollIntoView({block: 'center', inline: 'nearest'});",
|
||||
element,
|
||||
)
|
||||
|
||||
def click_element(self, by, value, timeout=10, use_js=False):
|
||||
"""Click an element."""
|
||||
try:
|
||||
element = self.wait_for_element(by, value, timeout)
|
||||
if element:
|
||||
self._scroll_into_view(element)
|
||||
if use_js:
|
||||
# Use JavaScript click for elements that might be intercepted
|
||||
self.driver.execute_script("arguments[0].click();", element)
|
||||
@@ -122,7 +180,7 @@ class BrowserTest:
|
||||
element.click()
|
||||
except Exception:
|
||||
self.driver.execute_script("arguments[0].click();", element)
|
||||
time.sleep(0.5) # Wait for action
|
||||
_browser_sleep(0.5) # Wait for action
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -137,7 +195,7 @@ class BrowserTest:
|
||||
alert.accept()
|
||||
else:
|
||||
alert.dismiss()
|
||||
time.sleep(0.3)
|
||||
_browser_sleep(0.3)
|
||||
return True
|
||||
except TimeoutException:
|
||||
return False
|
||||
@@ -161,8 +219,8 @@ class BrowserTest:
|
||||
except Exception as e:
|
||||
print(f" ⚠ Failed to cleanup preset {preset_id}: {e}")
|
||||
|
||||
# Delete created tabs by ID
|
||||
for zone_id in self.created_tabs:
|
||||
# Delete created zones by ID
|
||||
for zone_id in self.created_zones:
|
||||
try:
|
||||
response = session.delete(f"{self.base_url}/zones/{zone_id}")
|
||||
if response.status_code == 200:
|
||||
@@ -183,17 +241,17 @@ class BrowserTest:
|
||||
test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset',
|
||||
'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone']
|
||||
|
||||
# Cleanup tabs by name
|
||||
# Cleanup zones by name
|
||||
try:
|
||||
tabs_response = session.get(f"{self.base_url}/zones")
|
||||
if tabs_response.status_code == 200:
|
||||
tabs_data = tabs_response.json()
|
||||
tabs = tabs_data.get('zones', {})
|
||||
for zone_id, tab_data in zones.items():
|
||||
if isinstance(tab_data, dict) and tab_data.get('name') in test_names:
|
||||
zones_response = session.get(f"{self.base_url}/zones")
|
||||
if zones_response.status_code == 200:
|
||||
zones_data = zones_response.json()
|
||||
zones_map = zones_data.get('zones', {})
|
||||
for zone_id, zone_row in zones_map.items():
|
||||
if isinstance(zone_row, dict) and zone_row.get('name') in test_names:
|
||||
try:
|
||||
session.delete(f"{self.base_url}/zones/{zone_id}")
|
||||
print(f" ✓ Cleaned up zone by name: {tab_data.get('name')}")
|
||||
print(f" ✓ Cleaned up zone by name: {zone_row.get('name')}")
|
||||
except:
|
||||
pass
|
||||
except:
|
||||
@@ -232,7 +290,7 @@ class BrowserTest:
|
||||
pass
|
||||
|
||||
# Clear the lists
|
||||
self.created_tabs.clear()
|
||||
self.created_zones.clear()
|
||||
self.created_profiles.clear()
|
||||
self.created_presets.clear()
|
||||
except Exception as e:
|
||||
@@ -243,8 +301,34 @@ class BrowserTest:
|
||||
try:
|
||||
element = self.wait_for_element(by, value, timeout)
|
||||
if element:
|
||||
self._scroll_into_view(element)
|
||||
# Chrome often reports <input type="color"> as not interactable for clear/send_keys.
|
||||
if (element.get_attribute("type") or "").lower() == "color":
|
||||
hex_v = text.strip()
|
||||
if hex_v and not hex_v.startswith("#"):
|
||||
hex_v = "#" + hex_v
|
||||
self.driver.execute_script(
|
||||
"""
|
||||
var el = arguments[0], v = arguments[1];
|
||||
el.value = v;
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
el.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
""",
|
||||
element,
|
||||
hex_v,
|
||||
)
|
||||
return True
|
||||
element.clear()
|
||||
element.send_keys(text)
|
||||
try:
|
||||
element.send_keys(text)
|
||||
except ElementNotInteractableException:
|
||||
self.driver.execute_script(
|
||||
"arguments[0].value = arguments[1];"
|
||||
"arguments[0].dispatchEvent(new Event('input', {bubbles: true}));"
|
||||
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));",
|
||||
element,
|
||||
text,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -273,7 +357,7 @@ class BrowserTest:
|
||||
try:
|
||||
actions = ActionChains(self.driver)
|
||||
actions.drag_and_drop(source_element, target_element).perform()
|
||||
time.sleep(0.5) # Wait for drop to complete
|
||||
_browser_sleep(0.5) # Wait for drop to complete
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Drag and drop failed: {e}")
|
||||
@@ -284,12 +368,18 @@ class BrowserTest:
|
||||
try:
|
||||
actions = ActionChains(self.driver)
|
||||
actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform()
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"✗ Drag and drop by offset failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser() -> BrowserTest:
|
||||
return BrowserTest()
|
||||
|
||||
|
||||
def test_browser_connection(browser: BrowserTest) -> bool:
|
||||
"""Test basic browser connection."""
|
||||
print("Testing browser connection...")
|
||||
@@ -308,9 +398,9 @@ def test_browser_connection(browser: BrowserTest) -> bool:
|
||||
finally:
|
||||
browser.teardown()
|
||||
|
||||
def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
"""Test tabs UI in browser."""
|
||||
print("\n=== Testing Tabs UI in Browser ===")
|
||||
def test_zones_ui(browser: BrowserTest) -> bool:
|
||||
"""Test zones UI in browser."""
|
||||
print("\n=== Testing Zones UI in Browser ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
@@ -328,20 +418,20 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
browser.teardown()
|
||||
return False
|
||||
|
||||
# Test 2: Open tabs modal
|
||||
# Test 2: Open zones modal
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'zones-btn'):
|
||||
print("✓ Clicked Tabs button")
|
||||
print("✓ Clicked Zones button")
|
||||
# Wait for modal to appear
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
modal = browser.wait_for_element(By.ID, 'zones-modal')
|
||||
if modal and 'active' in modal.get_attribute('class'):
|
||||
print("✓ Tabs modal opened")
|
||||
print("✓ Zones modal opened")
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ Tabs modal didn't open")
|
||||
print("✗ Zones modal didn't open")
|
||||
else:
|
||||
print("✗ Failed to click Tabs button")
|
||||
print("✗ Failed to click Zones button")
|
||||
|
||||
# Test 3: Create a zone via UI
|
||||
total += 1
|
||||
@@ -353,7 +443,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
# Click create button
|
||||
if browser.click_element(By.ID, 'create-zone-btn'):
|
||||
print(" ✓ Clicked create button")
|
||||
time.sleep(1) # Wait for creation
|
||||
_browser_sleep(1) # Wait for creation
|
||||
# Check if zone appears in list and extract ID
|
||||
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
|
||||
if tabs_list:
|
||||
@@ -367,7 +457,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
if 'Browser Test Zone' in row.text:
|
||||
zone_id = row.get_attribute('data-zone-id')
|
||||
if zone_id:
|
||||
browser.created_tabs.append(zone_id)
|
||||
browser.created_zones.append(zone_id)
|
||||
break
|
||||
except:
|
||||
pass # If we can't extract ID, cleanup will try by name
|
||||
@@ -375,20 +465,20 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
else:
|
||||
print("✗ Zone not found in list after creation")
|
||||
else:
|
||||
print("✗ Tabs list not found")
|
||||
print("✗ Zones list not found")
|
||||
else:
|
||||
print("✗ Failed to click create button")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to create zone via UI: {e}")
|
||||
|
||||
# Test 4: Edit a zone via UI (right-click in Tabs list)
|
||||
# Test 4: Edit a zone via UI (right-click in zones list)
|
||||
total += 1
|
||||
try:
|
||||
# First, close and reopen modal to refresh
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Right-click the row corresponding to 'Browser Test Zone'
|
||||
try:
|
||||
@@ -402,7 +492,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
if tab_row:
|
||||
actions = ActionChains(browser.driver)
|
||||
actions.context_click(tab_row).perform()
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Check if edit modal opened
|
||||
edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal')
|
||||
@@ -415,7 +505,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
edit_form = browser.wait_for_element(By.ID, 'edit-zone-form')
|
||||
if edit_form:
|
||||
browser.driver.execute_script("arguments[0].submit();", edit_form)
|
||||
time.sleep(1) # Wait for update
|
||||
_browser_sleep(1) # Wait for update
|
||||
print("✓ Submitted edit form")
|
||||
passed += 1
|
||||
else:
|
||||
@@ -450,7 +540,7 @@ def test_tabs_ui(browser: BrowserTest) -> bool:
|
||||
browser.cleanup_test_data()
|
||||
browser.teardown()
|
||||
|
||||
print(f"\nBrowser tabs UI tests: {passed}/{total} passed")
|
||||
print(f"\nBrowser zones UI tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_profiles_ui(browser: BrowserTest) -> bool:
|
||||
@@ -476,7 +566,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'profiles-btn'):
|
||||
print("✓ Clicked Profiles button")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
modal = browser.wait_for_element(By.ID, 'profiles-modal')
|
||||
if modal:
|
||||
print("✓ Profiles modal opened")
|
||||
@@ -491,7 +581,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
|
||||
print(" ✓ Filled profile name")
|
||||
if browser.click_element(By.ID, 'create-profile-btn'):
|
||||
print(" ✓ Clicked create button")
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
# Check if profile appears
|
||||
profiles_list = browser.wait_for_element(By.ID, 'profiles-list')
|
||||
if profiles_list and 'Browser Test Profile' in profiles_list.text:
|
||||
@@ -535,7 +625,7 @@ def test_mobile_tab_presets_two_columns():
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
|
||||
assert first_tab is not None, "No zone buttons found"
|
||||
first_tab.click()
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
|
||||
assert container is not None, "presets-list-zone not found"
|
||||
@@ -577,7 +667,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'presets-btn'):
|
||||
print("✓ Clicked Presets button")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
modal = browser.wait_for_element(By.ID, 'presets-modal')
|
||||
if modal:
|
||||
print("✓ Presets modal opened")
|
||||
@@ -590,7 +680,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
||||
try:
|
||||
if browser.click_element(By.ID, 'preset-add-btn'):
|
||||
print(" ✓ Clicked Add Preset button")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal')
|
||||
if editor_modal:
|
||||
print("✓ Preset editor modal opened")
|
||||
@@ -628,7 +718,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
||||
# Save preset
|
||||
if browser.click_element(By.ID, 'preset-save-btn'):
|
||||
print(" ✓ Clicked save button")
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
# Check if preset appears in list
|
||||
presets_list = browser.wait_for_element(By.ID, 'presets-list')
|
||||
if presets_list and 'Browser Test Preset' in presets_list.text:
|
||||
@@ -645,7 +735,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
|
||||
|
||||
# Close editor modal
|
||||
browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True)
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
# Close presets modal
|
||||
browser.click_element(By.ID, 'presets-close-btn', use_js=True)
|
||||
|
||||
@@ -683,7 +773,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
|
||||
total += 1
|
||||
if browser.click_element(By.ID, 'color-palette-btn'):
|
||||
print("✓ Clicked Color Palette button")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
modal = browser.wait_for_element(By.ID, 'color-palette-modal')
|
||||
if modal:
|
||||
print("✓ Color palette modal opened")
|
||||
@@ -703,7 +793,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
|
||||
# Click add color button
|
||||
if browser.click_element(By.ID, 'palette-add-color-btn'):
|
||||
print(" ✓ Clicked Add Color button")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
# Handle alert if color already exists
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
# Check if color appears in palette
|
||||
@@ -735,7 +825,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
|
||||
target = color_swatches[1]
|
||||
if browser.drag_and_drop(source, target):
|
||||
print("✓ Dragged color to reorder")
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ Drag and drop failed")
|
||||
@@ -778,69 +868,69 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
browser.teardown()
|
||||
return False
|
||||
|
||||
# Test 2: Open tabs modal and create/select a zone
|
||||
# Test 2: Open zones modal and create/select a zone
|
||||
total += 1
|
||||
browser.click_element(By.ID, 'zones-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Check if we have tabs, if not create one
|
||||
# Check if we have zones, if not create one
|
||||
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
|
||||
if tabs_list and 'No tabs found' in tabs_list.text:
|
||||
if tabs_list and 'No zones found' in tabs_list.text:
|
||||
# Create a zone
|
||||
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
|
||||
browser.click_element(By.ID, 'create-zone-btn')
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
|
||||
# Select first zone (or the one we just created)
|
||||
select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
|
||||
if select_buttons:
|
||||
select_buttons[0].click()
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
print("✓ Selected a zone")
|
||||
passed += 1
|
||||
else:
|
||||
print("✗ No tabs available to select")
|
||||
print("✗ No zones available to select")
|
||||
browser.click_element(By.ID, 'zones-close-btn')
|
||||
browser.teardown()
|
||||
return False
|
||||
|
||||
browser.click_element(By.ID, 'zones-close-btn', use_js=True)
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Test 3: Open presets modal and create presets
|
||||
total += 1
|
||||
browser.click_element(By.ID, 'presets-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Create first preset
|
||||
browser.click_element(By.ID, 'preset-add-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
browser.fill_input(By.ID, 'preset-name-input', 'Preset 1')
|
||||
browser.fill_input(By.ID, 'preset-new-color', '#ff0000')
|
||||
browser.click_element(By.ID, 'preset-add-color-btn')
|
||||
browser.click_element(By.ID, 'preset-save-btn')
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
|
||||
# Create second preset
|
||||
browser.click_element(By.ID, 'preset-add-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
browser.fill_input(By.ID, 'preset-name-input', 'Preset 2')
|
||||
browser.fill_input(By.ID, 'preset-new-color', '#00ff00')
|
||||
browser.click_element(By.ID, 'preset-add-color-btn')
|
||||
browser.click_element(By.ID, 'preset-save-btn')
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
|
||||
# Create third preset
|
||||
browser.click_element(By.ID, 'preset-add-btn')
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
browser.fill_input(By.ID, 'preset-name-input', 'Preset 3')
|
||||
browser.fill_input(By.ID, 'preset-new-color', '#0000ff')
|
||||
browser.click_element(By.ID, 'preset-add-color-btn')
|
||||
browser.click_element(By.ID, 'preset-save-btn')
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
|
||||
browser.click_element(By.ID, 'presets-close-btn', use_js=True)
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
print("✓ Created 3 presets for drag test")
|
||||
passed += 1
|
||||
@@ -858,24 +948,24 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
zone_id
|
||||
)
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
|
||||
if list_el:
|
||||
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
|
||||
if len(select_buttons) >= 2:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
_browser_sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
|
||||
if len(select_buttons) >= 1:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
_browser_sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 2 presets to zone")
|
||||
passed += 1
|
||||
elif len(select_buttons) == 1:
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
_browser_sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
print(" ✓ Added 1 preset to zone")
|
||||
passed += 1
|
||||
@@ -894,16 +984,16 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
# Wait for presets to load in the zone
|
||||
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
|
||||
if presets_list_tab:
|
||||
time.sleep(1) # Wait for presets to render
|
||||
_browser_sleep(1) # Wait for presets to render
|
||||
|
||||
# Reordering is only available in Edit mode (tiles get .draggable-preset)
|
||||
mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5)
|
||||
if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
|
||||
mode_toggle.click()
|
||||
time.sleep(0.5)
|
||||
_browser_sleep(0.5)
|
||||
|
||||
# Find draggable preset elements - wait a bit more for rendering
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
|
||||
if len(draggable_presets) >= 2:
|
||||
print(f" ✓ Found {len(draggable_presets)} draggable presets")
|
||||
@@ -919,7 +1009,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
# Use ActionChains for drag and drop
|
||||
actions = ActionChains(browser.driver)
|
||||
actions.click_and_hold(source).move_to_element(target).release().perform()
|
||||
time.sleep(1) # Wait for reorder to complete
|
||||
_browser_sleep(1) # Wait for reorder to complete
|
||||
|
||||
# Check if order changed
|
||||
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
|
||||
@@ -945,18 +1035,18 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
"if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
|
||||
zone_id
|
||||
)
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
|
||||
if select_buttons:
|
||||
print(" Attempting to add another preset...")
|
||||
browser.driver.execute_script("arguments[0].click();", select_buttons[0])
|
||||
time.sleep(1.5)
|
||||
_browser_sleep(1.5)
|
||||
browser.handle_alert(accept=True, timeout=1)
|
||||
try:
|
||||
browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
|
||||
if len(draggable_presets) >= 2:
|
||||
print(" ✓ Added another preset, now testing drag...")
|
||||
@@ -964,7 +1054,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
|
||||
target = draggable_presets[1]
|
||||
actions = ActionChains(browser.driver)
|
||||
actions.click_and_hold(source).move_to_element(target).release().perform()
|
||||
time.sleep(1)
|
||||
_browser_sleep(1)
|
||||
print("✓ Performed drag and drop")
|
||||
passed += 1
|
||||
else:
|
||||
@@ -1012,7 +1102,7 @@ def main():
|
||||
|
||||
# Run browser tests
|
||||
results.append(("Browser Connection", test_browser_connection(browser)))
|
||||
results.append(("Tabs UI", test_tabs_ui(browser)))
|
||||
results.append(("Zones UI", test_zones_ui(browser)))
|
||||
results.append(("Profiles UI", test_profiles_ui(browser)))
|
||||
results.append(("Presets UI", test_presets_ui(browser)))
|
||||
results.append(("Color Palette UI", test_color_palette_ui(browser)))
|
||||
|
||||
@@ -82,19 +82,19 @@ def test_connection(client: TestClient) -> bool:
|
||||
print(f"✗ Connection error: {e}")
|
||||
return False
|
||||
|
||||
def test_tabs(client: TestClient) -> bool:
|
||||
"""Test tabs endpoints."""
|
||||
print("\n=== Testing Tabs Endpoints ===")
|
||||
def test_zones(client: TestClient) -> bool:
|
||||
"""Test zones endpoints."""
|
||||
print("\n=== Testing Zones Endpoints ===")
|
||||
passed = 0
|
||||
total = 0
|
||||
|
||||
# Test 1: List tabs
|
||||
# Test 1: List zones
|
||||
total += 1
|
||||
try:
|
||||
response = client.get('/zones')
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✓ GET /zones - Found {len(data.get('zones', {}))} tabs")
|
||||
print(f"✓ GET /zones - Found {len(data.get('zones', {}))} zones")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /zones - Status: {response.status_code}")
|
||||
@@ -104,17 +104,17 @@ def test_tabs(client: TestClient) -> bool:
|
||||
# Test 2: Create zone
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
zone_data = {
|
||||
"name": "Test Zone",
|
||||
"names": ["1", "2"]
|
||||
}
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=zone_data)
|
||||
if response.status_code == 201:
|
||||
created_tab = response.json()
|
||||
# Response format: {zone_id: {tab_data}}
|
||||
if isinstance(created_tab, dict):
|
||||
created_zone = response.json()
|
||||
# Response format: {zone_id: {zone object}}
|
||||
if isinstance(created_zone, dict):
|
||||
# Get the first key which should be the zone ID
|
||||
zone_id = next(iter(created_tab.keys())) if created_tab else None
|
||||
zone_id = next(iter(created_zone.keys())) if created_zone else None
|
||||
else:
|
||||
zone_id = None
|
||||
print(f"✓ POST /zones - Created zone: {zone_id}")
|
||||
@@ -203,7 +203,7 @@ def test_tabs(client: TestClient) -> bool:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\nTabs tests: {passed}/{total} passed")
|
||||
print(f"\nZones tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_profiles(client: TestClient) -> bool:
|
||||
@@ -405,10 +405,33 @@ def test_patterns(client: TestClient) -> bool:
|
||||
except Exception as e:
|
||||
print(f"✗ GET /patterns/definitions - Error: {e}")
|
||||
|
||||
# Test 3: Firmware-only on/off — no OTA file (400 from API).
|
||||
total += 1
|
||||
try:
|
||||
r = client.get("/patterns/ota/file/on.py")
|
||||
if r.status_code == 400 and "error" in r.json():
|
||||
print("✓ GET /patterns/ota/file/on.py - Rejected as built-in")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ GET /patterns/ota/file/on.py - Expected 400, got {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ GET /patterns/ota/file/on.py - Error: {e}")
|
||||
|
||||
total += 1
|
||||
try:
|
||||
r = client.post("/patterns/off/send", json_data={})
|
||||
if r.status_code == 400 and "error" in r.json():
|
||||
print("✓ POST /patterns/off/send - Rejected as built-in")
|
||||
passed += 1
|
||||
else:
|
||||
print(f"✗ POST /patterns/off/send - Expected 400, got {r.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ POST /patterns/off/send - Error: {e}")
|
||||
|
||||
print(f"\nPatterns tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
def test_zone_edit_workflow(client: TestClient) -> bool:
|
||||
"""Test complete zone edit workflow like a browser would."""
|
||||
print("\n=== Testing Zone Edit Workflow ===")
|
||||
passed = 0
|
||||
@@ -417,11 +440,11 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
# Step 1: Create a zone to edit
|
||||
total += 1
|
||||
try:
|
||||
tab_data = {
|
||||
zone_data = {
|
||||
"name": "Zone to Edit",
|
||||
"names": ["1"]
|
||||
}
|
||||
response = client.post('/zones', json_data=tab_data)
|
||||
response = client.post('/zones', json_data=zone_data)
|
||||
if response.status_code == 201:
|
||||
created = response.json()
|
||||
if isinstance(created, dict):
|
||||
@@ -437,8 +460,8 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
total += 1
|
||||
response = client.get(f'/zones/{zone_id}')
|
||||
if response.status_code == 200:
|
||||
original_tab = response.json()
|
||||
print(f"✓ Retrieved zone - Name: '{original_tab.get('name')}', IDs: {original_tab.get('names')}")
|
||||
original_zone = response.json()
|
||||
print(f"✓ Retrieved zone - Name: '{original_zone.get('name')}', IDs: {original_zone.get('names')}")
|
||||
passed += 1
|
||||
|
||||
# Step 3: Edit the zone (simulate browser edit form submission)
|
||||
@@ -493,7 +516,7 @@ def test_tab_edit_workflow(client: TestClient) -> bool:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"\nTab edit workflow tests: {passed}/{total} passed")
|
||||
print(f"\nZone edit workflow tests: {passed}/{total} passed")
|
||||
return passed == total
|
||||
|
||||
def test_static_files(client: TestClient) -> bool:
|
||||
@@ -543,8 +566,8 @@ def main():
|
||||
results = []
|
||||
|
||||
# Run all tests
|
||||
results.append(("Tabs", test_tabs(client)))
|
||||
results.append(("Zone Edit Workflow", test_tab_edit_workflow(client)))
|
||||
results.append(("Zones", test_zones(client)))
|
||||
results.append(("Zone Edit Workflow", test_zone_edit_workflow(client)))
|
||||
results.append(("Profiles", test_profiles(client)))
|
||||
results.append(("Presets", test_presets(client)))
|
||||
results.append(("Patterns", test_patterns(client)))
|
||||
|
||||
@@ -119,7 +119,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
import models.preset as models_preset # noqa: E402
|
||||
import models.profile as models_profile # noqa: E402
|
||||
import models.group as models_group # noqa: E402
|
||||
import models.zone as models_tab # noqa: E402
|
||||
import models.zone as models_zone # noqa: E402
|
||||
import models.pallet as models_pallet # noqa: E402
|
||||
import models.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
@@ -130,7 +130,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
models_preset.Preset,
|
||||
models_profile.Profile,
|
||||
models_group.Group,
|
||||
models_tab.Zone,
|
||||
models_zone.Zone,
|
||||
models_pallet.Palette,
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
@@ -205,7 +205,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
app.mount(profile_ctl.controller, "/profiles")
|
||||
app.mount(group_ctl.controller, "/groups")
|
||||
app.mount(sequence_ctl.controller, "/sequences")
|
||||
app.mount(tab_ctl.controller, "/zones")
|
||||
app.mount(zone_ctl.controller, "/zones")
|
||||
app.mount(palette_ctl.controller, "/palettes")
|
||||
app.mount(scene_ctl.controller, "/scenes")
|
||||
app.mount(pattern_ctl.controller, "/patterns")
|
||||
@@ -348,7 +348,7 @@ def test_settings_controller(server):
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_profiles_presets_tabs_endpoints(server, monkeypatch):
|
||||
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||
c: requests.Session = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
sender: DummySender = server["sender"]
|
||||
@@ -423,19 +423,19 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
|
||||
resp = c.get(f"{base_url}/presets/{new_preset_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
# Tabs CRUD (scoped to current profile session).
|
||||
unique_tab_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
|
||||
# Zones CRUD (scoped to current profile session).
|
||||
unique_zone_name = f"pytest-zone-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(
|
||||
f"{base_url}/zones",
|
||||
json={"name": unique_tab_name, "names": ["1", "2"]},
|
||||
json={"name": unique_zone_name, "names": ["1", "2"]},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
created_tabs = resp.json()
|
||||
zone_id = next(iter(created_tabs.keys()))
|
||||
created_zones = resp.json()
|
||||
zone_id = next(iter(created_zones.keys()))
|
||||
|
||||
resp = c.get(f"{base_url}/zones/{zone_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == unique_tab_name
|
||||
assert resp.json()["name"] == unique_zone_name
|
||||
|
||||
resp = c.post(f"{base_url}/zones/{zone_id}/set-current")
|
||||
assert resp.status_code == 200
|
||||
@@ -446,7 +446,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/zones/{zone_id}",
|
||||
json={"name": f"{unique_tab_name}-updated", "names": ["3"]},
|
||||
json={"name": f"{unique_zone_name}-updated", "names": ["3"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["names"] == ["3"]
|
||||
@@ -497,6 +497,7 @@ def test_profiles_presets_tabs_endpoints(server, monkeypatch):
|
||||
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
c: requests.Session = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
sender: DummySender = server["sender"]
|
||||
|
||||
# Groups.
|
||||
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
|
||||
@@ -714,7 +715,10 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
resp = c.get(f"{base_url}/patterns")
|
||||
assert resp.status_code == 200
|
||||
patterns_list = resp.json()
|
||||
assert pattern_id in patterns_list
|
||||
assert isinstance(patterns_list, dict)
|
||||
# Runtime list merges repo ``db/pattern.json`` + driver ``.py`` names; test DB
|
||||
# entries are still exposed on GET /patterns/<id> after POST.
|
||||
assert "blink" in patterns_list or len(patterns_list) >= 1
|
||||
|
||||
resp = c.get(f"{base_url}/patterns/{pattern_id}")
|
||||
assert resp.status_code == 200
|
||||
@@ -727,3 +731,27 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
resp = c.delete(f"{base_url}/patterns/{pattern_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# on/off are firmware-only in presets.py — no OTA file; API rejects serve/send/upload/driver.
|
||||
resp = c.get(f"{base_url}/patterns/ota/file/on.py")
|
||||
assert resp.status_code == 400
|
||||
assert "error" in resp.json()
|
||||
|
||||
resp = c.post(f"{base_url}/patterns/off/send", json={})
|
||||
assert resp.status_code == 400
|
||||
assert "error" in resp.json()
|
||||
|
||||
resp = c.post(
|
||||
f"{base_url}/patterns/upload",
|
||||
json={"name": "on.py", "code": "class On:\n def run(self, p):\n yield\n"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = c.post(
|
||||
f"{base_url}/patterns/driver",
|
||||
json={
|
||||
"name": "off",
|
||||
"code": "class Off:\n def run(self, p):\n yield\n",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
Reference in New Issue
Block a user