8 Commits

29 changed files with 1416 additions and 1174 deletions

View 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/`**.

View 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
View File

@@ -1,5 +1,7 @@
# Python # Python
__pycache__/ __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[cod]
*$py.class *$py.class
*.so *.so
@@ -28,3 +30,5 @@ settings.json
*.log *.log
*.db *.db
*.sqlite *.sqlite
.pytest_cache/
.ropeproject/

View File

@@ -13,6 +13,7 @@ requests = "*"
selenium = "*" selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*" microdot = "*"
websockets = "*"
[dev-packages] [dev-packages]
pytest = "*" pytest = "*"

711
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8" "sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -42,112 +42,112 @@
}, },
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
"sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199", "sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
"sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a", "sha256:03fe327549f177040b32f7faa736dc152be936d8b264d8b84f94c75f1379bfa1",
"sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", "sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd",
"sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", "sha256:0793c51d3b1c7410bde1f7254fff71fabff1bc0cdeba1fa51319ac4e7931df3d",
"sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", "sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822",
"sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4", "sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012",
"sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861", "sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354",
"sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", "sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be",
"sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", "sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559",
"sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d", "sha256:10c0caabff00ab0631d1e4fd25f56c7a5cf0f068426e5860d28dbbb972b509bf",
"sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55", "sha256:133648c3405564e7fef9103f1768cb018de1b4976f3d8beff09cd4acea73bfe4",
"sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", "sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2",
"sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce", "sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064",
"sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a", "sha256:190a3482818d69faef176171c7cae10d55cb4dd0c686b5aced7f592b5e5591c1",
"sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", "sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6",
"sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e", "sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11",
"sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b", "sha256:20e412527ec1aac7e3a6542b32a9c34bb852c954676b05008f0e3d58c390a0ac",
"sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30", "sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36",
"sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", "sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8",
"sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80", "sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4",
"sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", "sha256:2b9916867fa1ed815739e3e37dda458f397dee25a0e293b808839cfc2a396ca0",
"sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", "sha256:2da2ca9495668ab77132a911f6bd530d2bfe686d10467584894efc3b66e9ffb5",
"sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", "sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459",
"sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77", "sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3",
"sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0", "sha256:30d42c34da2974a5e2e0b51c57ecf89892c1e83ed67e1084d1e27eefc27add91",
"sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125", "sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1",
"sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671", "sha256:3a5e594b4be2dbfe021cee8d6d7d96e9bb19dee7ed7be351f43bca7a0619b978",
"sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de", "sha256:3b9358f6437a5fa0c765ffae5810c9830547baf4bcf469438b82845c3f33f998",
"sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", "sha256:3b9790ae107fc8648155f120e80a58ef8e94424efefff5b355de84061de6a18b",
"sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe", "sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a",
"sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", "sha256:430fe5150816445c8294a36ce2612360037342d750cea179efe5de38c66670a8",
"sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc", "sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d",
"sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", "sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b",
"sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a", "sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b",
"sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", "sha256:4c7ce072191ba23a4a4876452ccd5f2a67b926e66a248d052d39e9969cd3ab47",
"sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9", "sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f",
"sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", "sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44",
"sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", "sha256:4fb869faf4b484cb213199ced1e2732091559107637d429fc25d0a9731f5f630",
"sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf", "sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352",
"sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", "sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd",
"sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa", "sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508",
"sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954", "sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2",
"sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", "sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9",
"sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d", "sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e",
"sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", "sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123",
"sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e", "sha256:6956ef0259a037f10da767741aca82925f6f9978bb6dceb5344e56ce0629ab07",
"sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54", "sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc",
"sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", "sha256:69c8298e8197b113f765a2ea60f49ceb8e1ea9eb308140b3cdc611e0d1de70b8",
"sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24", "sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e",
"sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", "sha256:6f92d12a46b2a67d56194bb5d226dabf586b386d1f1a5e25be5b745a3080dbba",
"sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a", "sha256:70f70ea138e69ec3159e4a38fef52443cb8eb81388aeb241b273265ea16387c5",
"sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b", "sha256:72a0e87b2196120523fc6194ca6b580fcffa12d7daa4d57a16d7838e60f82d0e",
"sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70", "sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339",
"sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", "sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57",
"sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a", "sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4",
"sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada", "sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10",
"sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541", "sha256:78ab0d4166cf35c73054d1e04f224af1edc3cb4d75da8b6f74f4cff7c300f358",
"sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b", "sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1",
"sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", "sha256:7c133052737c7c75bfa49f5ba71918166fe988995b26a0d2f263a79bf8fed58a",
"sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2", "sha256:7eae9e763fbd32f19f2a66dfc2e37906f8422e0c4ad4a6c9dcf9d3246740812e",
"sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", "sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311",
"sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646", "sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc",
"sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", "sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc",
"sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", "sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d",
"sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd", "sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b",
"sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1", "sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907",
"sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", "sha256:8e12d50d4d65c74bd877e15c276992263b878456a7cfcf72521e7205a553557f",
"sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220", "sha256:9adacf6fdadeeb96e6c902aef08d02d2f45429fdbf0a75b80307e435156066f8",
"sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", "sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c",
"sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", "sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb",
"sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", "sha256:a681bbf9f94027d66e15974cd207cec1a2993837b9c45acf5f6b22a67632b1c2",
"sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", "sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea",
"sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c", "sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38",
"sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a", "sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d",
"sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", "sha256:af01133e78e5528ee282ceb1cf4bc54aecb937c2001913e751452ad7dffbbeb1",
"sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", "sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1",
"sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8", "sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf",
"sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", "sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b",
"sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20", "sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc",
"sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", "sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576",
"sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6", "sha256:c263ed9922942353a954cfbcd5f81b7626c0e20dc7f3e53d4926e8bc560ab845",
"sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", "sha256:c3387c314695f9790dce12fcf44357197ebf773651b6a4195f5e091cf500ae73",
"sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02", "sha256:c4fd3399eaf6f1c77ea3132611efbc3d7a8c0eb899793387b3266be221dc75fd",
"sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425", "sha256:cac0145491619287ff893853bf3ca4d98d5ef94b617271184a5af68a06ac301a",
"sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d", "sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53",
"sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", "sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5",
"sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7", "sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a",
"sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4", "sha256:d7d5f7f6f80388ce94849775da5f4082ab5e123e259972961970e190d60f5d2b",
"sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096", "sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137",
"sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", "sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd",
"sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", "sha256:defa3c12cb06b2fd2066a9e21bf00aab96465be84d9585c8c05195f080510506",
"sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b", "sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441",
"sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35", "sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1",
"sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", "sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8",
"sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d", "sha256:eb9fa02b9f5bbdb1d036a0c68999337793fa244528e0ce825e4b97cb7f7db99f",
"sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", "sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9",
"sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741", "sha256:ef123b6aead12e0784f72970e8d94a96ac0d0aa4438c7ab9235e2f8669a0a5ae",
"sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f", "sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5",
"sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8", "sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65",
"sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f", "sha256:fd68db1a0f5d9374a7b735414efe48d2b3ecbf0adea39299bb48030988f16149",
"sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148", "sha256:fd6b5b6df14f98b2e7e474c1c7ea55fc32dcab038b3b34b76a591dec8ba50915",
"sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", "sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705",
"sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97", "sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a",
"sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe" "sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644"
], ],
"version": "==3.8.0" "version": "==3.8.1"
}, },
"bitstring": { "bitstring": {
"hashes": [ "hashes": [
@@ -257,207 +257,208 @@
}, },
"charset-normalizer": { "charset-normalizer": {
"hashes": [ "hashes": [
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc",
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c",
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67",
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4",
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0",
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c",
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5",
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444",
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153",
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9",
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01",
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217",
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b",
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c",
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a",
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83",
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5",
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7",
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb",
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c",
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1",
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42",
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab",
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df",
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e",
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207",
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18",
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734",
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38",
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110",
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18",
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44",
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d",
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48",
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e",
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5",
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d",
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53",
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790",
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c",
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b",
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116",
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d",
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10",
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6",
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2",
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776",
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a",
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265",
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008",
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943",
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374",
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246",
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e",
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5",
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616",
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15",
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41",
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960",
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752",
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e",
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72",
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7",
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8",
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b",
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4",
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545",
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706",
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366",
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb",
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a",
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e",
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00",
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f",
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a",
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1",
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66",
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356",
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319",
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4",
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad",
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d",
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5",
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7",
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0",
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686",
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34",
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49",
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c",
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1",
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e",
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60",
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0",
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274",
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d",
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0",
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae",
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f",
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d",
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe",
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3",
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393",
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1",
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af",
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44",
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00",
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c",
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3",
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7",
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd",
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e",
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b",
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8",
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259",
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859",
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46",
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30",
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b",
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46",
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24",
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a",
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24",
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc",
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215",
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063",
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832",
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6",
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79",
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.4.6" "version": "==3.4.7"
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5",
"sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==8.3.1" "version": "==8.3.2"
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65",
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832",
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067",
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de",
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4",
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0",
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b",
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968",
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef",
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b",
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4",
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3",
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308",
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e",
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163",
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f",
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee",
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77",
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85",
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99",
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7",
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83",
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85",
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006",
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb",
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e",
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba",
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325",
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d",
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1",
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1",
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2",
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0",
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455",
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842",
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457",
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15",
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2",
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c",
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb",
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5",
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4",
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902",
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246",
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022",
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f",
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e",
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298",
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87" "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "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": { "esptool": {
"hashes": [ "hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0" "version": "==5.2.0"
}, },
"h11": { "h11": {
@@ -505,15 +506,17 @@
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464" "sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.0" "version": "==2.6.0"
}, },
"mpremote": { "mpremote": {
"hashes": [ "hashes": [
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4", "sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" "sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.27.0" "markers": "python_version >= '3.4'",
"version": "==1.28.0"
}, },
"outcome": { "outcome": {
"hashes": [ "hashes": [
@@ -525,11 +528,11 @@
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==4.9.4" "version": "==4.9.6"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
@@ -541,11 +544,11 @@
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.9'",
"version": "==2.19.2" "version": "==2.20.0"
}, },
"pyjwt": { "pyjwt": {
"hashes": [ "hashes": [
@@ -553,6 +556,7 @@
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1" "version": "==2.12.1"
}, },
"pyserial": { "pyserial": {
@@ -667,19 +671,20 @@
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.32.5" "markers": "python_version >= '3.10'",
"version": "==2.33.1"
}, },
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb",
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.9.0'",
"version": "==14.3.3" "version": "==15.0.0"
}, },
"rich-click": { "rich-click": {
"hashes": [ "hashes": [
@@ -691,11 +696,12 @@
}, },
"selenium": { "selenium": {
"hashes": [ "hashes": [
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa", "sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1" "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.41.0" "markers": "python_version >= '3.10'",
"version": "==4.43.0"
}, },
"sniffio": { "sniffio": {
"hashes": [ "hashes": [
@@ -774,6 +780,9 @@
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": { "urllib3": {
"extras": [
"socks"
],
"hashes": [ "hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -894,6 +903,7 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"websocket-client": { "websocket-client": {
@@ -904,6 +914,74 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.9.0" "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": { "wsproto": {
"hashes": [ "hashes": [
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
@@ -940,19 +1018,20 @@
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.9'",
"version": "==2.19.2" "version": "==2.20.0"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9",
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
], ],
"index": "pypi", "index": "pypi",
"version": "==9.0.2" "markers": "python_version >= '3.10'",
"version": "==9.0.3"
} }
} }
} }

View File

@@ -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": []}}

View File

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

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

View File

@@ -3,11 +3,11 @@
This document covers: This document covers:
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources. 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 **newline-delimited JSON over TCP** to **Wi-Fi** drivers (same logical fields). 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 HTTP 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:** the Pi accepts TCP connections on **`tcp_port`** in settings (default **8765**). **UDP discovery** listens on **8766** so drivers can find the controller IP on the LAN. **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 drivers 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 WiFi 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. All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
@@ -228,7 +228,7 @@ Pattern metadata lives in **`db/pattern.json`**; driver source files live under
## LED driver message format (transport / ESP-NOW / Wi-Fi) ## 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, and the same logical object can be sent as a **single JSON line** to a Wi-Fi driver over TCP. 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 ### Top-level fields

View File

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

View File

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

View File

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

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"]

View File

@@ -1,4 +0,0 @@
[pytest]
testpaths = tests
python_files = test_endpoints_pytest.py

View File

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

View File

@@ -6,7 +6,7 @@ from models.device import (
validate_device_type, validate_device_type,
) )
from models.transport import get_current_sender from models.transport import get_current_sender
from models.tcp_clients import ( from models.wifi_ws_clients import (
normalize_tcp_peer_ip, normalize_tcp_peer_ip,
send_json_line_to_ip, send_json_line_to_ip,
tcp_client_connected, tcp_client_connected,
@@ -56,8 +56,8 @@ devices = Device()
def _device_live_connected(dev_dict): def _device_live_connected(dev_dict):
""" """
Wi-Fi: whether a TCP client is registered for this device's address (IP). Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
ESP-NOW: None (no TCP session on the Pi for that transport). ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
""" """
tr = (dev_dict.get("transport") or "espnow").strip().lower() tr = (dev_dict.get("transport") or "espnow").strip().lower()
if tr != "wifi": if tr != "wifi":
@@ -114,7 +114,7 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
@controller.get("") @controller.get("")
async def list_devices(request): 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 = {} devices_data = {}
for dev_id in devices.list(): for dev_id in devices.list():
d = devices.read(dev_id) d = devices.read(dev_id)
@@ -125,7 +125,7 @@ async def list_devices(request):
@controller.get("/<id>") @controller.get("/<id>")
async def get_device(request, 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) dev = devices.read(id)
if dev: if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, { return json.dumps(_device_json_with_live_status(dev)), 200, {

View File

@@ -1,7 +1,7 @@
from microdot import Microdot from microdot import Microdot
from models.pattern import Pattern from models.pattern import Pattern
from models.device import Device 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 ( from util.driver_patterns import (
driver_patterns_dir, driver_patterns_dir,
is_firmware_builtin_pattern_module, is_firmware_builtin_pattern_module,

View File

@@ -23,7 +23,7 @@ import controllers.settings as settings_controller
import controllers.device as device_controller import controllers.device as device_controller
from models.transport import get_sender, set_sender, get_current_sender from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac 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 ( from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to, broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status, broadcast_device_tcp_status,
@@ -33,82 +33,8 @@ from util.device_status_broadcaster import (
_tcp_device_lock = threading.Lock() _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 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( def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None device_name: str, peer_ip: str, mac, device_type=None
@@ -116,10 +42,10 @@ def _register_udp_device_sync(
with _tcp_device_lock: with _tcp_device_lock:
try: try:
d = Device() d = Device()
did = d.upsert_wifi_tcp_client( did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type device_name, peer_ip, mac, device_type=device_type
) )
if did: if did and persisted:
print( print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}" 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") device_type = parsed.get("type") or parsed.get("device_type")
if dns and normalize_mac(mac): if dns and normalize_mac(mac):
_register_udp_device_sync(dns, peer_ip, mac, device_type) _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): except (UnicodeError, ValueError, TypeError):
pass pass
try: try:
@@ -163,6 +91,109 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
print(f"[UDP] echo send failed: {e!r}") print(f"[UDP] echo send failed: {e!r}")
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi 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: async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False) sock.setblocking(False)
@@ -189,87 +220,6 @@ async def _run_udp_discovery_server(udp_holder=None) -> None:
pass 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): 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.""" """Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
try: try:
@@ -285,23 +235,6 @@ async def _send_bridge_wifi_channel(settings, sender):
print(f"[startup] bridge channel message failed: {e}") 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): async def main(port=80):
settings = Settings() settings = Settings()
print(settings) print(settings)
@@ -341,6 +274,7 @@ async def main(port=80):
app.mount(settings_controller.controller, '/settings') app.mount(settings_controller.controller, '/settings')
app.mount(device_controller.controller, '/devices') app.mount(device_controller.controller, '/devices')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status) 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) # 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): def settings_page(request):
"""Serve the settings page.""" """Serve the settings page."""
return send_file('templates/settings.html') return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed) # Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(request): 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() Device()
await _send_bridge_wifi_channel(settings, sender) await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
tcp_holder = {}
udp_holder = {"closing": False} udp_holder = {"closing": False}
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -424,9 +358,7 @@ async def main(port=80):
u.close() u.close()
except OSError: except OSError:
pass pass
s = tcp_holder.get("server") tcp_client_registry.cancel_all_driver_tasks()
if s is not None:
s.close()
if getattr(app, "server", None) is not None: if getattr(app, "server", None) is not None:
app.shutdown() app.shutdown()
@@ -439,26 +371,33 @@ async def main(port=80):
except (NotImplementedError, RuntimeError): except (NotImplementedError, RuntimeError):
pass pass
# Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface # Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
# here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP
# never starts, which clears Wi-Fi presence dots.
try: try:
await asyncio.gather( await asyncio.gather(
app.start_server(host="0.0.0.0", port=port), app.start_server(host="0.0.0.0", port=port),
_run_tcp_server(settings, tcp_holder),
_run_udp_discovery_server(udp_holder), _run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
) )
except OSError as e: except OSError as e:
if e.errno == errno.EADDRINUSE: if e.errno == errno.EADDRINUSE:
tcp_p = int(settings.get("tcp_port", 8765))
print( print(
f"[server] bind failed (address already in use): {e!s}\n" f"[server] bind failed (address already in use): {e!s}\n"
f"[server] HTTP is configured for port {port} (env PORT); " f"[server] HTTP is configured for port {port} (env PORT). "
f"Wi-Fi LED drivers use tcp_port {tcp_p}. "
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run" f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
) )
raise raise
finally: 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: if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
try: try:

View File

@@ -237,16 +237,19 @@ class Device(Model):
""" """
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**, 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. **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) mac_hex = normalize_mac(mac)
if not mac_hex: if not mac_hex:
return None return None, False
name = (device_name or "").strip() name = (device_name or "").strip()
if not name: if not name:
return None return None, False
ip = normalize_address_for_transport(peer_ip, "wifi") ip = normalize_address_for_transport(peer_ip, "wifi")
if not ip: if not ip:
return None return None, False
resolved_type = None resolved_type = None
if device_type is not None: if device_type is not None:
try: try:
@@ -254,7 +257,8 @@ class Device(Model):
except ValueError: except ValueError:
resolved_type = None resolved_type = None
if mac_hex in self: if mac_hex in self:
merged = dict(self[mac_hex]) prev = self[mac_hex]
merged = dict(prev)
merged["name"] = name merged["name"] = name
if resolved_type is not None: if resolved_type is not None:
merged["type"] = resolved_type merged["type"] = resolved_type
@@ -263,9 +267,11 @@ class Device(Model):
merged["transport"] = "wifi" merged["transport"] = "wifi"
merged["address"] = ip merged["address"] = ip
merged["id"] = mac_hex merged["id"] = mac_hex
if merged == prev:
return mac_hex, False
self[mac_hex] = merged self[mac_hex] = merged
self.save() self.save()
return mac_hex return mac_hex, True
self[mac_hex] = { self[mac_hex] = {
"id": mac_hex, "id": mac_hex,
"name": name, "name": name,
@@ -276,4 +282,4 @@ class Device(Model):
"zones": [], "zones": [],
} }
self.save() self.save()
return mac_hex return mac_hex, True

View File

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

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

View File

@@ -48,11 +48,15 @@ class Settings(dict):
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111 # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self: if 'wifi_channel' not in self:
self['wifi_channel'] = 6 self['wifi_channel'] = 6
# Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport) # Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'tcp_enabled' not in self: if 'wifi_driver_ws_port' not in self:
self['tcp_enabled'] = True self['wifi_driver_ws_port'] = 80
if 'tcp_port' not in self: if 'wifi_driver_ws_path' not in self:
self['tcp_port'] = 8765 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): def save(self):
try: try:

View File

@@ -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 json
import threading 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: 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) ip = normalize_tcp_peer_ip(ip)
if not 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: 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() ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips}) msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})

View File

@@ -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 asyncio
import json import json
from models.device import normalize_mac 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. # Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
_SPLIT_MODE = "split" _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): 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. Returns the original message when no narrowing applies.
""" """
if not device_name: if not device_name:
@@ -40,6 +40,33 @@ def _wifi_message_for_device(msg, device_name):
return json.dumps(body, separators=(",", ":")) 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( async def deliver_preset_broadcast_then_per_device(
sender, sender,
chunk_messages, 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 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 Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
(unicast serial or TCP) with targets=[device name] for each registry entry. (unicast serial or WebSocket) with targets=[device name] for each registry entry.
""" """
if not chunk_messages: if not chunk_messages:
return 0 return 0
@@ -72,17 +99,22 @@ async def deliver_preset_broadcast_then_per_device(
wifi_ips.append(str(doc["address"]).strip()) wifi_ips.append(str(doc["address"]).strip())
deliveries = 0 deliveries = 0
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
for msg in chunk_messages: for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)] 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) results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True: if results and results[0] is True:
deliveries += 1 deliveries += 1
for r in results[1:]: await asyncio.sleep(delay_s)
if r is True:
for ip in wifi_ips:
if not ip:
continue
try:
if await send_json_line_to_ip(ip, wifi_combined_msg):
deliveries += 1 deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
await asyncio.sleep(delay_s) await asyncio.sleep(delay_s)
if default_id: if default_id:
@@ -98,7 +130,7 @@ async def deliver_preset_broadcast_then_per_device(
if await send_json_line_to_ip(ip, out): if await send_json_line_to_ip(ip, out):
deliveries += 1 deliveries += 1
except Exception as e: 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: else:
try: try:
await sender.send(out, addr=mac) 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): 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). 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 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 peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
tasks run together in one asyncio.gather. tasks run together in one asyncio.gather.

300
tests/device_ws_cycle.py Normal file
View File

@@ -0,0 +1,300 @@
#!/usr/bin/env python3
"""Discover a WiFi 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())

View File

@@ -93,39 +93,43 @@ def test_device():
def test_upsert_wifi_tcp_client(): def test_upsert_wifi_tcp_client():
devices = _fresh_device() devices = _fresh_device()
assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) 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") is None assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") == (
None,
False,
)
m1 = "001122334455" m1 = "001122334455"
m2 = "001122334466" m2 = "001122334466"
i1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1) i1, p1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert i1 == m1 assert i1 == m1 and p1 is True
d = devices.read(i1) d = devices.read(i1)
assert d["name"] == "kitchen" assert d["name"] == "kitchen"
assert d["type"] == "led" assert d["type"] == "led"
assert d["transport"] == "wifi" assert d["transport"] == "wifi"
assert d["address"] == "192.168.1.20" assert d["address"] == "192.168.1.20"
i2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2) noop_mac, noop_p = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert i2 == m2 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(m1)["address"] == "192.168.1.20"
assert devices.read(m2)["address"] == "192.168.1.21" assert devices.read(m2)["address"] == "192.168.1.21"
assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen" assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen"
again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1) again, p_again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
assert again == m1 assert again == m1 and p_again is True
assert devices.read(m1)["address"] == "192.168.1.99" assert devices.read(m1)["address"] == "192.168.1.99"
assert ( bogus_mac, bogus_p = devices.upsert_wifi_tcp_client(
devices.upsert_wifi_tcp_client( "kitchen", "192.168.1.100", m1, device_type="bogus"
"kitchen", "192.168.1.100", m1, device_type="bogus"
)
== m1
) )
assert bogus_mac == m1 and bogus_p is True
assert devices.read(m1)["type"] == "led" assert devices.read(m1)["type"] == "led"
i3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe") i3, p3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
assert i3 == "deadbeefcafe" assert i3 == "deadbeefcafe" and p3 is True
assert len(devices.list()) == 3 assert len(devices.list()) == 3

View File

@@ -1,22 +1,37 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Browser automation tests using Selenium. 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 On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium
and chromedriver are installed (e.g. chromium-browser chromium-chromedriver). and chromedriver are installed (e.g. chromium-browser chromium-chromedriver).
""" """
import os import os
import sys
import pytest import pytest
if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1": 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( pytest.skip(
"Legacy device browser automation script; enable explicitly to run.", "Legacy device browser automation script; enable explicitly to run.",
allow_module_level=True, allow_module_level=True,
) )
import sys
import time import time
import requests import requests
from typing import Optional, List 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.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions from selenium.webdriver.firefox.options import Options as FirefoxOptions
from selenium.webdriver.common.action_chains import ActionChains 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 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: class BrowserTest:
"""Browser automation test class.""" """Browser automation test class."""
@@ -57,7 +108,7 @@ class BrowserTest:
opts.add_argument('--disable-gpu') opts.add_argument('--disable-gpu')
opts.add_argument('--window-size=1920,1080') opts.add_argument('--window-size=1920,1080')
self.driver = webdriver.Chrome(options=opts) self.driver = webdriver.Chrome(options=opts)
self.driver.implicitly_wait(5) self.driver.implicitly_wait(_implicit_wait_s())
print("✓ Browser started (Chrome)") print("✓ Browser started (Chrome)")
return True return True
except Exception as e: except Exception as e:
@@ -68,7 +119,7 @@ class BrowserTest:
if self.headless: if self.headless:
opts.add_argument('--headless') opts.add_argument('--headless')
self.driver = webdriver.Firefox(options=opts) self.driver = webdriver.Firefox(options=opts)
self.driver.implicitly_wait(5) self.driver.implicitly_wait(_implicit_wait_s())
print("✓ Browser started (Firefox)") print("✓ Browser started (Firefox)")
return True return True
except Exception as e: except Exception as e:
@@ -92,7 +143,7 @@ class BrowserTest:
url = f"{self.base_url}{path}" url = f"{self.base_url}{path}"
try: try:
self.driver.get(url) self.driver.get(url)
time.sleep(1) # Wait for page load _browser_sleep(1) # Wait for page load
return True return True
except Exception as e: except Exception as e:
print(f"✗ Failed to navigate to {url}: {e}") print(f"✗ Failed to navigate to {url}: {e}")
@@ -107,12 +158,19 @@ class BrowserTest:
return element return element
except TimeoutException: except TimeoutException:
return None 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): def click_element(self, by, value, timeout=10, use_js=False):
"""Click an element.""" """Click an element."""
try: try:
element = self.wait_for_element(by, value, timeout) element = self.wait_for_element(by, value, timeout)
if element: if element:
self._scroll_into_view(element)
if use_js: if use_js:
# Use JavaScript click for elements that might be intercepted # Use JavaScript click for elements that might be intercepted
self.driver.execute_script("arguments[0].click();", element) self.driver.execute_script("arguments[0].click();", element)
@@ -122,7 +180,7 @@ class BrowserTest:
element.click() element.click()
except Exception: except Exception:
self.driver.execute_script("arguments[0].click();", element) 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 True
return False return False
except Exception as e: except Exception as e:
@@ -137,7 +195,7 @@ class BrowserTest:
alert.accept() alert.accept()
else: else:
alert.dismiss() alert.dismiss()
time.sleep(0.3) _browser_sleep(0.3)
return True return True
except TimeoutException: except TimeoutException:
return False return False
@@ -243,8 +301,34 @@ class BrowserTest:
try: try:
element = self.wait_for_element(by, value, timeout) element = self.wait_for_element(by, value, timeout)
if element: 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.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 True
return False return False
except Exception as e: except Exception as e:
@@ -273,7 +357,7 @@ class BrowserTest:
try: try:
actions = ActionChains(self.driver) actions = ActionChains(self.driver)
actions.drag_and_drop(source_element, target_element).perform() 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 return True
except Exception as e: except Exception as e:
print(f"✗ Drag and drop failed: {e}") print(f"✗ Drag and drop failed: {e}")
@@ -284,12 +368,18 @@ class BrowserTest:
try: try:
actions = ActionChains(self.driver) actions = ActionChains(self.driver)
actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform() actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform()
time.sleep(0.5) _browser_sleep(0.5)
return True return True
except Exception as e: except Exception as e:
print(f"✗ Drag and drop by offset failed: {e}") print(f"✗ Drag and drop by offset failed: {e}")
return False return False
@pytest.fixture
def browser() -> BrowserTest:
return BrowserTest()
def test_browser_connection(browser: BrowserTest) -> bool: def test_browser_connection(browser: BrowserTest) -> bool:
"""Test basic browser connection.""" """Test basic browser connection."""
print("Testing browser connection...") print("Testing browser connection...")
@@ -333,7 +423,7 @@ def test_zones_ui(browser: BrowserTest) -> bool:
if browser.click_element(By.ID, 'zones-btn'): if browser.click_element(By.ID, 'zones-btn'):
print("✓ Clicked Zones button") print("✓ Clicked Zones button")
# Wait for modal to appear # Wait for modal to appear
time.sleep(0.5) _browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'zones-modal') modal = browser.wait_for_element(By.ID, 'zones-modal')
if modal and 'active' in modal.get_attribute('class'): if modal and 'active' in modal.get_attribute('class'):
print("✓ Zones modal opened") print("✓ Zones modal opened")
@@ -353,7 +443,7 @@ def test_zones_ui(browser: BrowserTest) -> bool:
# Click create button # Click create button
if browser.click_element(By.ID, 'create-zone-btn'): if browser.click_element(By.ID, 'create-zone-btn'):
print(" ✓ Clicked create button") 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 # Check if zone appears in list and extract ID
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
if tabs_list: if tabs_list:
@@ -386,9 +476,9 @@ def test_zones_ui(browser: BrowserTest) -> bool:
try: try:
# First, close and reopen modal to refresh # First, close and reopen modal to refresh
browser.click_element(By.ID, 'zones-close-btn') browser.click_element(By.ID, 'zones-close-btn')
time.sleep(0.5) _browser_sleep(0.5)
browser.click_element(By.ID, 'zones-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) _browser_sleep(0.5)
# Right-click the row corresponding to 'Browser Test Zone' # Right-click the row corresponding to 'Browser Test Zone'
try: try:
@@ -402,7 +492,7 @@ def test_zones_ui(browser: BrowserTest) -> bool:
if tab_row: if tab_row:
actions = ActionChains(browser.driver) actions = ActionChains(browser.driver)
actions.context_click(tab_row).perform() actions.context_click(tab_row).perform()
time.sleep(0.5) _browser_sleep(0.5)
# Check if edit modal opened # Check if edit modal opened
edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal') edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal')
@@ -415,7 +505,7 @@ def test_zones_ui(browser: BrowserTest) -> bool:
edit_form = browser.wait_for_element(By.ID, 'edit-zone-form') edit_form = browser.wait_for_element(By.ID, 'edit-zone-form')
if edit_form: if edit_form:
browser.driver.execute_script("arguments[0].submit();", 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") print("✓ Submitted edit form")
passed += 1 passed += 1
else: else:
@@ -476,7 +566,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
total += 1 total += 1
if browser.click_element(By.ID, 'profiles-btn'): if browser.click_element(By.ID, 'profiles-btn'):
print("✓ Clicked Profiles button") print("✓ Clicked Profiles button")
time.sleep(0.5) _browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'profiles-modal') modal = browser.wait_for_element(By.ID, 'profiles-modal')
if modal: if modal:
print("✓ Profiles modal opened") print("✓ Profiles modal opened")
@@ -491,7 +581,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
print(" ✓ Filled profile name") print(" ✓ Filled profile name")
if browser.click_element(By.ID, 'create-profile-btn'): if browser.click_element(By.ID, 'create-profile-btn'):
print(" ✓ Clicked create button") print(" ✓ Clicked create button")
time.sleep(1) _browser_sleep(1)
# Check if profile appears # Check if profile appears
profiles_list = browser.wait_for_element(By.ID, 'profiles-list') profiles_list = browser.wait_for_element(By.ID, 'profiles-list')
if profiles_list and 'Browser Test Profile' in profiles_list.text: 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) first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
assert first_tab is not None, "No zone buttons found" assert first_tab is not None, "No zone buttons found"
first_tab.click() first_tab.click()
time.sleep(1) _browser_sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-zone not found" assert container is not None, "presets-list-zone not found"
@@ -577,7 +667,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
total += 1 total += 1
if browser.click_element(By.ID, 'presets-btn'): if browser.click_element(By.ID, 'presets-btn'):
print("✓ Clicked Presets button") print("✓ Clicked Presets button")
time.sleep(0.5) _browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'presets-modal') modal = browser.wait_for_element(By.ID, 'presets-modal')
if modal: if modal:
print("✓ Presets modal opened") print("✓ Presets modal opened")
@@ -590,7 +680,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
try: try:
if browser.click_element(By.ID, 'preset-add-btn'): if browser.click_element(By.ID, 'preset-add-btn'):
print(" ✓ Clicked Add Preset button") print(" ✓ Clicked Add Preset button")
time.sleep(0.5) _browser_sleep(0.5)
editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal') editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal')
if editor_modal: if editor_modal:
print("✓ Preset editor modal opened") print("✓ Preset editor modal opened")
@@ -628,7 +718,7 @@ def test_presets_ui(browser: BrowserTest) -> bool:
# Save preset # Save preset
if browser.click_element(By.ID, 'preset-save-btn'): if browser.click_element(By.ID, 'preset-save-btn'):
print(" ✓ Clicked save button") print(" ✓ Clicked save button")
time.sleep(1) _browser_sleep(1)
# Check if preset appears in list # Check if preset appears in list
presets_list = browser.wait_for_element(By.ID, 'presets-list') presets_list = browser.wait_for_element(By.ID, 'presets-list')
if presets_list and 'Browser Test Preset' in presets_list.text: 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 # Close editor modal
browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True) browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True)
time.sleep(0.5) _browser_sleep(0.5)
# Close presets modal # Close presets modal
browser.click_element(By.ID, 'presets-close-btn', use_js=True) 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 total += 1
if browser.click_element(By.ID, 'color-palette-btn'): if browser.click_element(By.ID, 'color-palette-btn'):
print("✓ Clicked Color Palette button") print("✓ Clicked Color Palette button")
time.sleep(0.5) _browser_sleep(0.5)
modal = browser.wait_for_element(By.ID, 'color-palette-modal') modal = browser.wait_for_element(By.ID, 'color-palette-modal')
if modal: if modal:
print("✓ Color palette modal opened") print("✓ Color palette modal opened")
@@ -703,7 +793,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
# Click add color button # Click add color button
if browser.click_element(By.ID, 'palette-add-color-btn'): if browser.click_element(By.ID, 'palette-add-color-btn'):
print(" ✓ Clicked Add Color button") print(" ✓ Clicked Add Color button")
time.sleep(0.5) _browser_sleep(0.5)
# Handle alert if color already exists # Handle alert if color already exists
browser.handle_alert(accept=True, timeout=1) browser.handle_alert(accept=True, timeout=1)
# Check if color appears in palette # Check if color appears in palette
@@ -735,7 +825,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool:
target = color_swatches[1] target = color_swatches[1]
if browser.drag_and_drop(source, target): if browser.drag_and_drop(source, target):
print("✓ Dragged color to reorder") print("✓ Dragged color to reorder")
time.sleep(0.5) _browser_sleep(0.5)
passed += 1 passed += 1
else: else:
print("✗ Drag and drop failed") print("✗ Drag and drop failed")
@@ -781,7 +871,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Test 2: Open zones modal and create/select a zone # Test 2: Open zones modal and create/select a zone
total += 1 total += 1
browser.click_element(By.ID, 'zones-btn') browser.click_element(By.ID, 'zones-btn')
time.sleep(0.5) _browser_sleep(0.5)
# Check if we have zones, if not create one # Check if we have zones, if not create one
tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal')
@@ -789,13 +879,13 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Create a zone # Create a zone
browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone') browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone')
browser.click_element(By.ID, 'create-zone-btn') browser.click_element(By.ID, 'create-zone-btn')
time.sleep(1) _browser_sleep(1)
# Select first zone (or the one we just created) # Select first zone (or the one we just created)
select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]") select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]")
if select_buttons: if select_buttons:
select_buttons[0].click() select_buttons[0].click()
time.sleep(1) _browser_sleep(1)
print("✓ Selected a zone") print("✓ Selected a zone")
passed += 1 passed += 1
else: else:
@@ -805,42 +895,42 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
return False return False
browser.click_element(By.ID, 'zones-close-btn', use_js=True) 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 # Test 3: Open presets modal and create presets
total += 1 total += 1
browser.click_element(By.ID, 'presets-btn') browser.click_element(By.ID, 'presets-btn')
time.sleep(0.5) _browser_sleep(0.5)
# Create first preset # Create first preset
browser.click_element(By.ID, 'preset-add-btn') 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-name-input', 'Preset 1')
browser.fill_input(By.ID, 'preset-new-color', '#ff0000') browser.fill_input(By.ID, 'preset-new-color', '#ff0000')
browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-btn') browser.click_element(By.ID, 'preset-save-btn')
time.sleep(1) _browser_sleep(1)
# Create second preset # Create second preset
browser.click_element(By.ID, 'preset-add-btn') 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-name-input', 'Preset 2')
browser.fill_input(By.ID, 'preset-new-color', '#00ff00') browser.fill_input(By.ID, 'preset-new-color', '#00ff00')
browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-btn') browser.click_element(By.ID, 'preset-save-btn')
time.sleep(1) _browser_sleep(1)
# Create third preset # Create third preset
browser.click_element(By.ID, 'preset-add-btn') 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-name-input', 'Preset 3')
browser.fill_input(By.ID, 'preset-new-color', '#0000ff') browser.fill_input(By.ID, 'preset-new-color', '#0000ff')
browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-add-color-btn')
browser.click_element(By.ID, 'preset-save-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) 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") print("✓ Created 3 presets for drag test")
passed += 1 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); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
zone_id zone_id
) )
time.sleep(1) _browser_sleep(1)
list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5) list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5)
if list_el: if list_el:
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 2: if len(select_buttons) >= 2:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) 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) browser.handle_alert(accept=True, timeout=1)
select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if len(select_buttons) >= 1: if len(select_buttons) >= 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) 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) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 2 presets to zone") print(" ✓ Added 2 presets to zone")
passed += 1 passed += 1
elif len(select_buttons) == 1: elif len(select_buttons) == 1:
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) 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) browser.handle_alert(accept=True, timeout=1)
print(" ✓ Added 1 preset to zone") print(" ✓ Added 1 preset to zone")
passed += 1 passed += 1
@@ -894,16 +984,16 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
# Wait for presets to load in the zone # Wait for presets to load in the zone
presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5) presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5)
if presets_list_tab: 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) # 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) 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': if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false':
mode_toggle.click() mode_toggle.click()
time.sleep(0.5) _browser_sleep(0.5)
# Find draggable preset elements - wait a bit more for rendering # 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') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(f" ✓ Found {len(draggable_presets)} draggable presets") 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 # Use ActionChains for drag and drop
actions = ActionChains(browser.driver) actions = ActionChains(browser.driver)
actions.click_and_hold(source).move_to_element(target).release().perform() 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 # Check if order changed
draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset') 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); }", "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }",
zone_id 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']") select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']")
if select_buttons: if select_buttons:
print(" Attempting to add another preset...") print(" Attempting to add another preset...")
browser.driver.execute_script("arguments[0].click();", select_buttons[0]) 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) browser.handle_alert(accept=True, timeout=1)
try: try:
browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');") browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');")
except Exception: except Exception:
pass pass
time.sleep(1) _browser_sleep(1)
draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset') draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset')
if len(draggable_presets) >= 2: if len(draggable_presets) >= 2:
print(" ✓ Added another preset, now testing drag...") print(" ✓ Added another preset, now testing drag...")
@@ -964,7 +1054,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool:
target = draggable_presets[1] target = draggable_presets[1]
actions = ActionChains(browser.driver) actions = ActionChains(browser.driver)
actions.click_and_hold(source).move_to_element(target).release().perform() actions.click_and_hold(source).move_to_element(target).release().perform()
time.sleep(1) _browser_sleep(1)
print("✓ Performed drag and drop") print("✓ Performed drag and drop")
passed += 1 passed += 1
else: else: