From 96712dda88cc5002b421e6c372f90080101c2d8f Mon Sep 17 00:00:00 2001 From: Jimmy Date: Tue, 14 Apr 2026 23:13:26 +1200 Subject: [PATCH] feat(controller): migrate wifi drivers from tcp to websocket clients --- .gitignore | 2 + Pipfile | 1 + Pipfile.lock | 711 ++++++++++++++------------ db/device.json | 2 +- db/zone.json | 2 +- docs/API.md | 6 +- led-driver | 2 +- led-tool | 2 +- src/controllers/device.py | 10 +- src/controllers/pattern.py | 2 +- src/main.py | 302 +++++------ src/models/device.py | 18 +- src/models/tcp_clients.py | 115 ----- src/models/wifi_ws_clients.py | 281 ++++++++++ src/settings.py | 14 +- src/util/device_status_broadcaster.py | 6 +- src/util/driver_delivery.py | 58 ++- tests/device_ws_cycle.py | 300 +++++++++++ tests/models/test_device.py | 34 +- 19 files changed, 1195 insertions(+), 673 deletions(-) delete mode 100644 src/models/tcp_clients.py create mode 100644 src/models/wifi_ws_clients.py create mode 100644 tests/device_ws_cycle.py diff --git a/.gitignore b/.gitignore index a1530de..ca109f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Python __pycache__/ +# led-driver/src is MicroPython source — never keep host __pycache__ there (see .cursor/rules/led-driver.mdc) +led-driver/src/__pycache__/ *.py[cod] *$py.class *.so diff --git a/Pipfile b/Pipfile index 3d02789..d8d9426 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,7 @@ requests = "*" selenium = "*" adafruit-ampy = "*" microdot = "*" +websockets = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 29710af..5f14569 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8" + "sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89" }, "pipfile-spec": 6, "requires": { @@ -42,112 +42,112 @@ }, "bitarray": { "hashes": [ - "sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199", - "sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a", - "sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", - "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", - "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", - "sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4", - "sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861", - "sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", - "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", - "sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d", - "sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55", - "sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", - "sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce", - "sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a", - "sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", - "sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e", - "sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b", - "sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30", - "sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", - "sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80", - "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", - "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", - "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", - "sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77", - "sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0", - "sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125", - "sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671", - "sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de", - "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", - "sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe", - "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", - "sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc", - "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", - "sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a", - "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", - "sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9", - "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", - "sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", - "sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf", - "sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", - "sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa", - "sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954", - "sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", - "sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d", - "sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", - "sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e", - "sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54", - "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", - "sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24", - "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", - "sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a", - "sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b", - "sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70", - "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", - "sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a", - "sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada", - "sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541", - "sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b", - "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", - "sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2", - "sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", - "sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646", - "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", - "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", - "sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd", - "sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1", - "sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", - "sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220", - "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", - "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", - "sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", - "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", - "sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c", - "sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a", - "sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", - "sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", - "sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8", - "sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", - "sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20", - "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", - "sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6", - "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", - "sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02", - "sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425", - "sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d", - "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", - "sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7", - "sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4", - "sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096", - "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", - "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", - "sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b", - "sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35", - "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", - "sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d", - "sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", - "sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741", - "sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f", - "sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8", - "sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f", - "sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148", - "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", - "sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97", - "sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe" + "sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80", + "sha256:03fe327549f177040b32f7faa736dc152be936d8b264d8b84f94c75f1379bfa1", + "sha256:07626f76a248fce5ebbb10fb0d4899d3c7f908ba21cb2fb4f5a7a9daf24c20cd", + "sha256:0793c51d3b1c7410bde1f7254fff71fabff1bc0cdeba1fa51319ac4e7931df3d", + "sha256:0a33f8931ac91ebc23ce4decb99ed8fdddba2bafd2af3bb2781bcfd9878d4822", + "sha256:0a661f3492462e7adf8a054fb7414a22fc8251f1e18b9d8cbcf008d2dc85f012", + "sha256:0c8c66f5d8055cb84ad0ea14af57b3579cb0b6db589f2086f5e33f0922cf2354", + "sha256:0da5f17bed67ffe1d72f79fbf98403513a6e51a4f9b8293c1ff8a64e121242be", + "sha256:0f099a4a77daf9bb99787070854894fe588c7d6988ea729f970ba2b3b82c7559", + "sha256:10c0caabff00ab0631d1e4fd25f56c7a5cf0f068426e5860d28dbbb972b509bf", + "sha256:133648c3405564e7fef9103f1768cb018de1b4976f3d8beff09cd4acea73bfe4", + "sha256:154a19e1dcd430494fdad7d1a0fb36383baaa363e1cb9d5a7b744cd2418c44d2", + "sha256:18f3a2c8908e63a66d3994808254397a5f989b1fb91087c33739f62bf1a1a064", + "sha256:190a3482818d69faef176171c7cae10d55cb4dd0c686b5aced7f592b5e5591c1", + "sha256:190b20cbffc9cd7f308f7a57d406119c3af3ae197613325fd2d92d99c8882ad6", + "sha256:1d7b786a1ddd9b8dda17c445060a94a465cba2e113603ae7bdc5364efc1efd11", + "sha256:20e412527ec1aac7e3a6542b32a9c34bb852c954676b05008f0e3d58c390a0ac", + "sha256:21add0aa968496a2bd8341d85720d09808e22e0adc7dbefc1e0f8f67c4b83f36", + "sha256:2762db8049b230520358ac742cbc57bceaacebe34e5d25c096f2b4bc3887a3a8", + "sha256:29c8c10a49d6a9586f592116618b99c3dabcb24d881b7a649e0691ef87f314c4", + "sha256:2b9916867fa1ed815739e3e37dda458f397dee25a0e293b808839cfc2a396ca0", + "sha256:2da2ca9495668ab77132a911f6bd530d2bfe686d10467584894efc3b66e9ffb5", + "sha256:2dc07dab252c63c4f6600e200b26fa05207db6b650d41ae88ab0cec4d6c59459", + "sha256:300e3026d17ae3328320ba78d3165bdb1c43d0dfdbc461a69ebbdc005d9ce0b3", + "sha256:30d42c34da2974a5e2e0b51c57ecf89892c1e83ed67e1084d1e27eefc27add91", + "sha256:329b994944993c45c3845047476ef4f231fe1a53972f18f8d005fd12fac163e1", + "sha256:3a5e594b4be2dbfe021cee8d6d7d96e9bb19dee7ed7be351f43bca7a0619b978", + "sha256:3b9358f6437a5fa0c765ffae5810c9830547baf4bcf469438b82845c3f33f998", + "sha256:3b9790ae107fc8648155f120e80a58ef8e94424efefff5b355de84061de6a18b", + "sha256:40d1b57012bf9b4fefd25345aaa95aab3ca510cc693f33c2cb02a4b771d8e51a", + "sha256:430fe5150816445c8294a36ce2612360037342d750cea179efe5de38c66670a8", + "sha256:4494c599effa16064f2b600f6eb28115182d6826847d795a55691339788d8a4d", + "sha256:478b9f0ea86f957624dd2b159066855716f78db94666e9b04babe85fc013e01b", + "sha256:4b7d7d10a1c82050efbb9a83d7a43974f70cf8f021afb86463b42e4ac4e5a46b", + "sha256:4c7ce072191ba23a4a4876452ccd5f2a67b926e66a248d052d39e9969cd3ab47", + "sha256:4da256fc567a57ded2a4aa962fc9e9d430ab740e5c67be9e98a63ef4eb467f2f", + "sha256:4e34f1cb6cdb036c5f4a839a2b74419f75fa36177a70c4bab2867f48973cbe44", + "sha256:4fb869faf4b484cb213199ced1e2732091559107637d429fc25d0a9731f5f630", + "sha256:539880ddf9a8cc54c9e6126e7d072c991563f0c90ef73b3519a783d53df00352", + "sha256:55f4b105a1686eb486069a9e578d502d1998e890d8144012225de9e0450aeabd", + "sha256:5743f532e408cfd716fa16776b5a6447b83ff2cf39021fb5f8d052aa0f331508", + "sha256:5b67b869f860eb19055e2560844d8c7d0935245938935bdb764b3e683e2014e2", + "sha256:5e30d8e399f38ae1ec86aa9be76d20ba15872dd0c41b4b46d1b78905857363b9", + "sha256:660e11b9932f58f10151d0febd11f77d3b0d48d6fa4dd4686d8983f40187101e", + "sha256:67125404d12547443d74113862a80c10310cf875aff8dbfc5548fee1d9737123", + "sha256:6956ef0259a037f10da767741aca82925f6f9978bb6dceb5344e56ce0629ab07", + "sha256:698c37fca3761af69a09a1d39cc0492f7e8cb9e263af39a288dce8f3b8a9e2bc", + "sha256:69c8298e8197b113f765a2ea60f49ceb8e1ea9eb308140b3cdc611e0d1de70b8", + "sha256:6ef49462a615de062dcac8281944d0b036fe1e9c96a6c690bf6cf5e4b5488f0e", + "sha256:6f92d12a46b2a67d56194bb5d226dabf586b386d1f1a5e25be5b745a3080dbba", + "sha256:70f70ea138e69ec3159e4a38fef52443cb8eb81388aeb241b273265ea16387c5", + "sha256:72a0e87b2196120523fc6194ca6b580fcffa12d7daa4d57a16d7838e60f82d0e", + "sha256:72b32d8c471930c95d49640ec99f7694f9b040ca1342ff03ed69d3aea90f9339", + "sha256:746e25f17ba4203b5933773782cf2d30bca5cdb66a9ba5d48a53a6c795aedc57", + "sha256:75e33c9187da271d1dbeb2582ab2df2e441346492098f67559b09173ea4edde4", + "sha256:7875abfd90f2ae3aa22d50f3fa1c93bbae456458cc73d3179b838f07bed1fc10", + "sha256:78ab0d4166cf35c73054d1e04f224af1edc3cb4d75da8b6f74f4cff7c300f358", + "sha256:78cbda57a2808d994517b53571eaa2d9299359f63aa71cf4bc94210169aad8b1", + "sha256:7c133052737c7c75bfa49f5ba71918166fe988995b26a0d2f263a79bf8fed58a", + "sha256:7eae9e763fbd32f19f2a66dfc2e37906f8422e0c4ad4a6c9dcf9d3246740812e", + "sha256:814bb54db2a016026efc055a3527461e5eb551c0d91b32eeade003829ff84311", + "sha256:81ede1f094f26eeaff62e029ff1bc4e84e9d568f20d4669f64dcf7c7b18a28fc", + "sha256:838fd67b3d00c5a64181073282a2c0bf8f76465da4844d5e79d2dbbc64c987dc", + "sha256:89c7c125a0913d71ba9cc1fa8e14c7cfe1517b1c1f45416e1f9babcedd3b545d", + "sha256:8a345b5dc8ab8cafdf338e08530d48fe3f73df27f4ff569be793c7a7e7bb6b6b", + "sha256:8c3fe25871f1758519a3ad8dcafb1bd95c5d1aaeb122e6492ac739ab11fa5907", + "sha256:8e12d50d4d65c74bd877e15c276992263b878456a7cfcf72521e7205a553557f", + "sha256:9adacf6fdadeeb96e6c902aef08d02d2f45429fdbf0a75b80307e435156066f8", + "sha256:9befda0dbd27ed95fba1c26be4bf98a49ba166b3c91beb5fc04364c130ce950c", + "sha256:9fa5620f7f352f9706924c0e2071a212be36421f09ee064b0fd7e1128289fcdb", + "sha256:a681bbf9f94027d66e15974cd207cec1a2993837b9c45acf5f6b22a67632b1c2", + "sha256:ab363a5baae965fb3438f2137583853ad9c77d7e45f2a62ba63e609a34d792ea", + "sha256:ac49519fcfeb4a7ecdf6b7d0ec6cac409e59f94c1bb54630db577a97893b6e38", + "sha256:ad5a71c1ef4a2e404c2c888db09226c821d9d14eff8813e1da873572f5fbb89d", + "sha256:af01133e78e5528ee282ceb1cf4bc54aecb937c2001913e751452ad7dffbbeb1", + "sha256:b3118ec012a799456f7fca6cc002c078590578b7640fbaab52d8ecb9a651f1c1", + "sha256:b46b7aec9272fd81c984e723e599957629a91204120b3e7f0933f138e0792fdf", + "sha256:ba0339d6aa80615a17f47fabc5700485e9469121d658458f95cdd2003288c28b", + "sha256:c08cd5b19c570e1e9e094a6ce70d35bb39d12360e0763474ed9374229f174fcc", + "sha256:c0b367a00e8c88a714b2384c97dedcc85340547b3a54b6037a42fca5554d0576", + "sha256:c263ed9922942353a954cfbcd5f81b7626c0e20dc7f3e53d4926e8bc560ab845", + "sha256:c3387c314695f9790dce12fcf44357197ebf773651b6a4195f5e091cf500ae73", + "sha256:c4fd3399eaf6f1c77ea3132611efbc3d7a8c0eb899793387b3266be221dc75fd", + "sha256:cac0145491619287ff893853bf3ca4d98d5ef94b617271184a5af68a06ac301a", + "sha256:cd9b848c17ef034f2ae31b2a1bd9276710c2baf03509f1f3fa4dc4382b0a1b53", + "sha256:ced27af6aee28782260bfa5643797937e96a6489bca972202834017208cf74f5", + "sha256:cf99e36c0f6ae5643ecef7ad7e1194aeb4a9798d9cff60b20ac041533fa6db0a", + "sha256:d7d5f7f6f80388ce94849775da5f4082ab5e123e259972961970e190d60f5d2b", + "sha256:dc2cab92c42991b711132bc52405680e075d1505d4356c4468bc6e9c93d49137", + "sha256:ddcd25a1f72b2b545fb27e17882046a6c161f3f24514b2e028c00c58ed73a2dd", + "sha256:defa3c12cb06b2fd2066a9e21bf00aab96465be84d9585c8c05195f080510506", + "sha256:df3ffa6ef88166bb36f5d1492e71e664868b9b8b6afd55821e0ac0cb96625441", + "sha256:e127b2e7fc533728295196f9265d12834530f475bc6cd6f74619df415d04b8b1", + "sha256:e9ff57452fcadfd1a379314234657b8f4e9967ae64480ddf7c2fd82139bc8cf8", + "sha256:eb9fa02b9f5bbdb1d036a0c68999337793fa244528e0ce825e4b97cb7f7db99f", + "sha256:ec3d0a6c37a816ea6e3550697c60d90861c9b0f982a98a40b59ac1f7a360bfa9", + "sha256:ef123b6aead12e0784f72970e8d94a96ac0d0aa4438c7ab9235e2f8669a0a5ae", + "sha256:f90bb3c680804ec9630bcf8c0965e54b4de84d33b17d7da57c87c30f0c64c6f5", + "sha256:fb1df55f5700187c6db4b47dbdaf8a0653a111341ac7fccc596b397aa3399e65", + "sha256:fd68db1a0f5d9374a7b735414efe48d2b3ecbf0adea39299bb48030988f16149", + "sha256:fd6b5b6df14f98b2e7e474c1c7ea55fc32dcab038b3b34b76a591dec8ba50915", + "sha256:fd7e3158be382f8f140caccc0dc7742a7553ce4bf2978982abe3054d2cedd705", + "sha256:fe989bbed9d6f332c1e24d333936f3fa1375f380cd8028da0b985dcdefa6015a", + "sha256:ff2ca039a161d49a8c713f5380def315c6f793df5fe348b94782b1dbee37a644" ], - "version": "==3.8.0" + "version": "==3.8.1" }, "bitstring": { "hashes": [ @@ -257,207 +257,208 @@ }, "charset-normalizer": { "hashes": [ - "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", - "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", - "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", - "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", - "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", - "sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0", - "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", - "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", - "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", - "sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8", - "sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264", - "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", - "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", - "sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4", - "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", - "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", - "sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa", - "sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95", - "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", - "sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297", - "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", - "sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e", - "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", - "sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8", - "sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0", - "sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9", - "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", - "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", - "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", - "sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565", - "sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7", - "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", - "sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b", - "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", - "sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687", - "sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9", - "sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14", - "sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89", - "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", - "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", - "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", - "sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a", - "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", - "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", - "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", - "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", - "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", - "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", - "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", - "sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532", - "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", - "sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae", - "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", - "sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64", - "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", - "sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557", - "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", - "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", - "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", - "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", - "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", - "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", - "sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597", - "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", - "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", - "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", - "sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54", - "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", - "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", - "sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4", - "sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7", - "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", - "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", - "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", - "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", - "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", - "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", - "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", - "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", - "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", - "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", - "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", - "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", - "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", - "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", - "sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6", - "sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc", - "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", - "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", - "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", - "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", - "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", - "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", - "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", - "sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237", - "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", - "sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778", - "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", - "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", - "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", - "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", - "sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f", - "sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5", - "sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611", - "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", - "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", - "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", - "sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b", - "sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db", - "sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e", - "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", - "sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd", - "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", - "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", - "sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8", - "sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe", - "sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058", - "sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17", - "sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833", - "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", - "sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550", - "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", - "sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2", - "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", - "sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982", - "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", - "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", - "sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104", - "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659" + "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", + "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", + "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67", + "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", + "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", + "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", + "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", + "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444", + "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", + "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9", + "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01", + "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217", + "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", + "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", + "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", + "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83", + "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5", + "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", + "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", + "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", + "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", + "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42", + "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", + "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", + "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", + "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207", + "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", + "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734", + "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", + "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", + "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", + "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", + "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", + "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", + "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", + "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", + "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", + "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", + "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", + "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", + "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", + "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", + "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", + "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", + "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", + "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", + "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776", + "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", + "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", + "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", + "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", + "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", + "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", + "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", + "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5", + "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", + "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", + "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", + "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", + "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", + "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", + "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", + "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", + "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", + "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", + "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4", + "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545", + "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706", + "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366", + "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", + "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a", + "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", + "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", + "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", + "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", + "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", + "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", + "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", + "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319", + "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", + "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", + "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", + "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", + "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", + "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0", + "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686", + "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", + "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", + "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c", + "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", + "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", + "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60", + "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", + "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274", + "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", + "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", + "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", + "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f", + "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", + "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", + "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", + "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", + "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", + "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", + "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", + "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00", + "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", + "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3", + "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7", + "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", + "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", + "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", + "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", + "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259", + "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", + "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", + "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30", + "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", + "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", + "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24", + "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", + "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", + "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc", + "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", + "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", + "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", + "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", + "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", + "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" ], "markers": "python_version >= '3.7'", - "version": "==3.4.6" + "version": "==3.4.7" }, "click": { "hashes": [ - "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", - "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" + "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", + "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" ], "markers": "python_version >= '3.10'", - "version": "==8.3.1" + "version": "==8.3.2" }, "cryptography": { "hashes": [ - "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", - "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", - "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", - "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", - "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", - "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", - "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", - "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", - "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", - "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", - "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", - "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", - "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", - "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", - "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", - "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", - "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", - "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", - "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", - "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", - "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", - "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", - "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", - "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", - "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", - "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", - "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", - "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", - "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", - "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", - "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", - "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", - "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", - "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", - "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", - "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", - "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", - "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", - "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", - "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", - "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", - "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", - "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", - "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", - "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", - "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", - "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", - "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", - "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87" + "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", + "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", + "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", + "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", + "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", + "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", + "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", + "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", + "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", + "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", + "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", + "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", + "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", + "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", + "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", + "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", + "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", + "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", + "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", + "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", + "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", + "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", + "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", + "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", + "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", + "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", + "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", + "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", + "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", + "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", + "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", + "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", + "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", + "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", + "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", + "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", + "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", + "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", + "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", + "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", + "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", + "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", + "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", + "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", + "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", + "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", + "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", + "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", + "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" ], "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.5" + "version": "==46.0.7" }, "esptool": { "hashes": [ "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" ], "index": "pypi", + "markers": "python_version >= '3.10'", "version": "==5.2.0" }, "h11": { @@ -505,15 +506,17 @@ "sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==2.6.0" }, "mpremote": { "hashes": [ - "sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4", - "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" + "sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334", + "sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31" ], "index": "pypi", - "version": "==1.27.0" + "markers": "python_version >= '3.4'", + "version": "==1.28.0" }, "outcome": { "hashes": [ @@ -525,11 +528,11 @@ }, "platformdirs": { "hashes": [ - "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", - "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" + "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", + "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917" ], "markers": "python_version >= '3.10'", - "version": "==4.9.4" + "version": "==4.9.6" }, "pycparser": { "hashes": [ @@ -541,11 +544,11 @@ }, "pygments": { "hashes": [ - "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" ], - "markers": "python_version >= '3.8'", - "version": "==2.19.2" + "markers": "python_version >= '3.9'", + "version": "==2.20.0" }, "pyjwt": { "hashes": [ @@ -553,6 +556,7 @@ "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" ], "index": "pypi", + "markers": "python_version >= '3.9'", "version": "==2.12.1" }, "pyserial": { @@ -667,19 +671,20 @@ }, "requests": { "hashes": [ - "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", - "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" + "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", + "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" ], "index": "pypi", - "version": "==2.32.5" + "markers": "python_version >= '3.10'", + "version": "==2.33.1" }, "rich": { "hashes": [ - "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", - "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" + "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", + "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36" ], - "markers": "python_full_version >= '3.8.0'", - "version": "==14.3.3" + "markers": "python_full_version >= '3.9.0'", + "version": "==15.0.0" }, "rich-click": { "hashes": [ @@ -691,11 +696,12 @@ }, "selenium": { "hashes": [ - "sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa", - "sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1" + "sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769", + "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e" ], "index": "pypi", - "version": "==4.41.0" + "markers": "python_version >= '3.10'", + "version": "==4.43.0" }, "sniffio": { "hashes": [ @@ -774,6 +780,9 @@ "version": "==4.15.0" }, "urllib3": { + "extras": [ + "socks" + ], "hashes": [ "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" @@ -894,6 +903,7 @@ "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" ], "index": "pypi", + "markers": "python_version >= '3.9'", "version": "==1.1.1" }, "websocket-client": { @@ -904,6 +914,74 @@ "markers": "python_version >= '3.9'", "version": "==1.9.0" }, + "websockets": { + "hashes": [ + "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", + "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", + "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", + "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", + "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", + "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", + "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", + "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", + "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", + "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", + "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", + "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", + "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", + "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", + "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", + "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", + "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", + "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", + "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", + "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", + "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", + "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", + "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", + "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", + "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", + "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", + "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", + "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", + "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", + "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", + "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", + "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", + "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", + "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", + "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", + "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", + "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", + "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", + "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", + "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", + "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", + "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", + "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", + "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", + "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", + "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", + "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", + "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", + "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", + "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", + "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", + "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", + "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", + "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", + "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", + "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", + "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", + "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", + "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", + "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", + "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" + ], + "index": "pypi", + "markers": "python_version >= '3.10'", + "version": "==16.0" + }, "wsproto": { "hashes": [ "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", @@ -940,19 +1018,20 @@ }, "pygments": { "hashes": [ - "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" + "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", + "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" ], - "markers": "python_version >= '3.8'", - "version": "==2.19.2" + "markers": "python_version >= '3.9'", + "version": "==2.20.0" }, "pytest": { "hashes": [ - "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", - "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" + "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", + "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" ], "index": "pypi", - "version": "==9.0.2" + "markers": "python_version >= '3.10'", + "version": "==9.0.3" } } } diff --git a/db/device.json b/db/device.json index 00834ca..d8703ad 100644 --- a/db/device.json +++ b/db/device.json @@ -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": []}} \ No newline at end of file +{"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": []}} \ No newline at end of file diff --git a/db/zone.json b/db/zone.json index 2385d06..b085d48 100644 --- a/db/zone.json +++ b/db/zone.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 3701bdf..fcb64cd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,11 +3,11 @@ 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. -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`). -**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 driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings). All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted. @@ -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) -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 diff --git a/led-driver b/led-driver index ded6e3d..87bd033 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit ded6e3d3605395206bbfe891eee25d7cd25c2871 +Subproject commit 87bd0338bd0302d9747c1b448d48f55e768308b5 diff --git a/led-tool b/led-tool index eee9327..9e72c62 160000 --- a/led-tool +++ b/led-tool @@ -1 +1 @@ -Subproject commit eee9327e15e64a147e831826c7bc8dfa0f1125be +Subproject commit 9e72c6248113b45aaa5c9699f422671512a8d41b diff --git a/src/controllers/device.py b/src/controllers/device.py index fca7f2e..935ff12 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -6,7 +6,7 @@ from models.device import ( validate_device_type, ) from models.transport import get_current_sender -from models.tcp_clients import ( +from models.wifi_ws_clients import ( normalize_tcp_peer_ip, send_json_line_to_ip, tcp_client_connected, @@ -56,8 +56,8 @@ devices = Device() def _device_live_connected(dev_dict): """ - Wi-Fi: whether a TCP client is registered for this device's address (IP). - ESP-NOW: None (no TCP session on the Pi for that transport). + Wi-Fi: whether the controller has an outbound WebSocket to this device's IP. + ESP-NOW: None (no Wi-Fi session on the Pi for that transport). """ tr = (dev_dict.get("transport") or "espnow").strip().lower() if tr != "wifi": @@ -114,7 +114,7 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam @controller.get("") async def list_devices(request): - """List all devices (includes ``connected`` for live Wi-Fi TCP presence).""" + """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" devices_data = {} for dev_id in devices.list(): d = devices.read(dev_id) @@ -125,7 +125,7 @@ async def list_devices(request): @controller.get("/") async def get_device(request, id): - """Get a device by ID (includes ``connected`` for live Wi-Fi TCP presence).""" + """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence).""" dev = devices.read(id) if dev: return json.dumps(_device_json_with_live_status(dev)), 200, { diff --git a/src/controllers/pattern.py b/src/controllers/pattern.py index ae98d78..f6ed694 100644 --- a/src/controllers/pattern.py +++ b/src/controllers/pattern.py @@ -1,7 +1,7 @@ from microdot import Microdot from models.pattern import Pattern from models.device import Device -from models.tcp_clients import send_json_line_to_ip +from models.wifi_ws_clients import send_json_line_to_ip from util.driver_patterns import ( driver_patterns_dir, is_firmware_builtin_pattern_module, diff --git a/src/main.py b/src/main.py index ee37f52..59b95bb 100644 --- a/src/main.py +++ b/src/main.py @@ -23,7 +23,7 @@ import controllers.settings as settings_controller import controllers.device as device_controller from models.transport import get_sender, set_sender, get_current_sender from models.device import Device, normalize_mac -from models import tcp_clients as tcp_client_registry +from models import wifi_ws_clients as tcp_client_registry from util.device_status_broadcaster import ( broadcast_device_tcp_snapshot_to, broadcast_device_tcp_status, @@ -33,82 +33,8 @@ from util.device_status_broadcaster import ( _tcp_device_lock = threading.Lock() -# Wi-Fi drivers send one hello line then stay quiet; periodic outbound data makes dead peers -# fail drain() within this interval (keepalive alone is often slow or ineffective). -TCP_LIVENESS_PING_INTERVAL_S = 12.0 DISCOVERY_UDP_PORT = 8766 -# Keepalive or lossy Wi-Fi can still surface OSError(110) / TimeoutError on recv or wait_closed. -_TCP_PEER_GONE = ( - BrokenPipeError, - ConnectionResetError, - ConnectionAbortedError, - ConnectionRefusedError, - TimeoutError, - OSError, -) - - -def _tcp_socket_from_writer(writer): - sock = writer.get_extra_info("socket") - if sock is not None: - return sock - transport = getattr(writer, "transport", None) - if transport is not None: - return transport.get_extra_info("socket") - return None - - -def _enable_tcp_keepalive(writer) -> None: - """ - Detect vanished peers (power off, Wi-Fi drop) without waiting for a send() failure. - Linux: shorten time before the first keepalive probe; other platforms: SO_KEEPALIVE only. - """ - sock = _tcp_socket_from_writer(writer) - if sock is None: - return - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - except OSError: - return - if hasattr(socket, "TCP_KEEPIDLE"): - try: - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120) - except OSError: - pass - if hasattr(socket, "TCP_KEEPINTVL"): - try: - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15) - except OSError: - pass - if hasattr(socket, "TCP_KEEPCNT"): - try: - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) - except OSError: - pass - # Do not set TCP_USER_TIMEOUT: a short value causes Errno 110 on recv for Wi-Fi peers - # when ACKs are delayed (ESP power save, lossy links). Liveness pings already clear dead - # sessions via drain(). - - -async def _tcp_liveness_ping_loop(writer, peer_ip: str) -> None: - """Send a bare newline so ``drain()`` fails soon after the peer disappears.""" - while True: - await asyncio.sleep(TCP_LIVENESS_PING_INTERVAL_S) - if writer.is_closing(): - return - try: - writer.write(b"\n") - await writer.drain() - except Exception as exc: - print(f"[TCP] liveness ping failed {peer_ip!r}: {exc!r}") - tcp_client_registry.unregister_tcp_writer(peer_ip, writer) - try: - writer.close() - except Exception: - pass - return - def _register_udp_device_sync( device_name: str, peer_ip: str, mac, device_type=None @@ -116,10 +42,10 @@ def _register_udp_device_sync( with _tcp_device_lock: try: d = Device() - did = d.upsert_wifi_tcp_client( + did, persisted = d.upsert_wifi_tcp_client( device_name, peer_ip, mac, device_type=device_type ) - if did: + if did and persisted: print( f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}" ) @@ -155,6 +81,8 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None: device_type = parsed.get("type") or parsed.get("device_type") if dns and normalize_mac(mac): _register_udp_device_sync(dns, peer_ip, mac, device_type) + if str(parsed.get("v") or "") == "1": + tcp_client_registry.ensure_driver_connection(peer_ip) except (UnicodeError, ValueError, TypeError): pass try: @@ -163,6 +91,109 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None: print(f"[UDP] echo send failed: {e!r}") +def _prime_wifi_outbound_driver_connections() -> None: + """ + For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the + outbound WebSocket task. The client loop reconnects automatically if the link + drops. Presets are not pushed automatically; use Send Presets / profile apply. + """ + n = 0 + try: + dev = Device() + for mac_key, doc in list(dev.items()): + if not isinstance(doc, dict): + continue + if doc.get("transport") != "wifi": + continue + ip = _ipv4_address(str(doc.get("address") or "")) + if not ip: + continue + tcp_client_registry.ensure_driver_connection(ip) + n += 1 + except Exception as e: + print(f"[startup] Wi-Fi driver connection prime failed: {e!r}") + traceback.print_exception(type(e), e, e.__traceback__) + return + if n: + print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)") + + +def _ipv4_address(addr: str) -> str | None: + """Return dotted IPv4 string or None (hostnames skipped for UDP nudge).""" + s = (addr or "").strip() + if not s: + return None + parts = s.split(".") + if len(parts) != 4: + return None + try: + nums = [int(p) for p in parts] + except ValueError: + return None + if not all(0 <= n <= 255 for n in nums): + return None + return s + + +async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None: + """ + While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on + UDP discovery port so the device can announce itself and we can reconnect. + """ + try: + interval = float(settings.get("wifi_driver_hello_interval_s", 10.0)) + except (TypeError, ValueError): + interval = 10.0 + if interval <= 0: + return + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + loop = asyncio.get_running_loop() + try: + while True: + await asyncio.sleep(interval) + if udp_holder.get("closing"): + break + try: + dev = Device() + except Exception as e: + print(f"[hello] device list failed: {e!r}") + continue + for _mac_key, doc in list(dev.items()): + if not isinstance(doc, dict): + continue + if doc.get("transport") != "wifi": + continue + ip = _ipv4_address(str(doc.get("address") or "")) + if not ip: + continue + if tcp_client_registry.tcp_client_connected(ip): + continue + name = (doc.get("name") or "").strip() + mac = normalize_mac(doc.get("id") or _mac_key) + if not name or not mac: + continue + line = ( + json.dumps( + {"m": "hello", "device_name": name, "mac": mac}, + separators=(",", ":"), + ) + + "\n" + ) + try: + await loop.sock_sendto( + sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT) + ) + except OSError as e: + print(f"[hello] UDP to {ip!r} failed: {e!r}") + finally: + try: + sock.close() + except OSError: + pass + + async def _run_udp_discovery_server(udp_holder=None) -> None: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setblocking(False) @@ -189,87 +220,6 @@ async def _run_udp_discovery_server(udp_holder=None) -> None: pass -async def _handle_tcp_client(reader, writer): - """Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge.""" - peer = writer.get_extra_info("peername") - peer_ip = peer[0] if peer else "" - peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?" - print(f"[TCP] client connected {peer_label}") - _enable_tcp_keepalive(writer) - tcp_client_registry.register_tcp_writer(peer_ip, writer) - ping_task = asyncio.create_task(_tcp_liveness_ping_loop(writer, peer_ip)) - sender = get_current_sender() - buf = b"" - try: - while True: - try: - chunk = await reader.read(4096) - except asyncio.CancelledError: - raise - except _TCP_PEER_GONE as e: - print(f"[TCP] read ended ({peer_label}): {e!r}") - tcp_client_registry.unregister_tcp_writer(peer_ip, writer) - break - if not chunk: - break - buf += chunk - while b"\n" in buf: - raw_line, buf = buf.split(b"\n", 1) - line = raw_line.strip() - if not line: - continue - try: - text = line.decode("utf-8") - except UnicodeDecodeError: - print( - f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}" - ) - continue - print(f"[TCP] recv {peer_label}: {text}") - try: - parsed = json.loads(text) - except json.JSONDecodeError: - if sender: - try: - await sender.send(text) - except Exception: - pass - continue - if isinstance(parsed, dict): - addr = parsed.pop("to", None) - payload = json.dumps(parsed) if parsed else "{}" - if sender: - try: - await sender.send(payload, addr=addr) - except Exception as e: - print(f"TCP forward to bridge failed: {e}") - elif sender: - try: - await sender.send(text) - except Exception: - pass - finally: - # Drop registry + broadcast connected:false before awaiting ping/close so the UI - # does not stay green if ping or wait_closed blocks on a timed-out peer. - outcome = tcp_client_registry.unregister_tcp_writer(peer_ip, writer) - if outcome == "superseded": - print( - f"[TCP] TCP session ended (same IP already has a newer connection): {peer_label}" - ) - ping_task.cancel() - try: - await ping_task - except asyncio.CancelledError: - pass - try: - writer.close() - await writer.wait_closed() - except asyncio.CancelledError: - raise - except _TCP_PEER_GONE: - tcp_client_registry.unregister_tcp_writer(peer_ip, writer) - - async def _send_bridge_wifi_channel(settings, sender): """Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW.""" try: @@ -285,23 +235,6 @@ async def _send_bridge_wifi_channel(settings, sender): print(f"[startup] bridge channel message failed: {e}") -async def _run_tcp_server(settings, tcp_holder=None): - if not settings.get("tcp_enabled", True): - print("TCP server disabled (tcp_enabled=false)") - return - port = int(settings.get("tcp_port", 8765)) - server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port) - print(f"TCP server listening on 0.0.0.0:{port}") - if tcp_holder is not None: - tcp_holder["server"] = server - try: - async with server: - await server.serve_forever() - finally: - if tcp_holder is not None: - tcp_holder.pop("server", None) - - async def main(port=80): settings = Settings() print(settings) @@ -341,6 +274,7 @@ async def main(port=80): app.mount(settings_controller.controller, '/settings') app.mount(device_controller.controller, '/devices') + tcp_client_registry.set_settings(settings) tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status) # Serve index.html at root (cwd is src/ when run via pipenv run run) @@ -407,11 +341,11 @@ async def main(port=80): - # Touch Device singleton early so db/device.json exists before first TCP hello. + # Touch Device singleton early so db/device.json exists before first UDP hello. Device() await _send_bridge_wifi_channel(settings, sender) + _prime_wifi_outbound_driver_connections() - tcp_holder = {} udp_holder = {"closing": False} loop = asyncio.get_running_loop() @@ -424,9 +358,7 @@ async def main(port=80): u.close() except OSError: pass - s = tcp_holder.get("server") - if s is not None: - s.close() + tcp_client_registry.cancel_all_driver_tasks() if getattr(app, "server", None) is not None: app.shutdown() @@ -439,22 +371,18 @@ async def main(port=80): except (NotImplementedError, RuntimeError): pass - # Await HTTP + driver TCP together so bind failures (e.g. port 80 in use) surface - # here instead of as an unretrieved Task exception; the UI WebSocket drops if HTTP - # never starts, which clears Wi-Fi presence dots. + # Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here. try: await asyncio.gather( app.start_server(host="0.0.0.0", port=port), - _run_tcp_server(settings, tcp_holder), _run_udp_discovery_server(udp_holder), + _periodic_wifi_driver_hello_loop(settings, udp_holder), ) except OSError as e: if e.errno == errno.EADDRINUSE: - tcp_p = int(settings.get("tcp_port", 8765)) print( f"[server] bind failed (address already in use): {e!s}\n" - f"[server] HTTP is configured for port {port} (env PORT); " - f"Wi-Fi LED drivers use tcp_port {tcp_p}. " + f"[server] HTTP is configured for port {port} (env PORT). " f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run" ) raise diff --git a/src/models/device.py b/src/models/device.py index bffd2d8..156d16e 100644 --- a/src/models/device.py +++ b/src/models/device.py @@ -237,16 +237,19 @@ class Device(Model): """ Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**, **address** (peer IP), and optionally **type** from the client hello when valid. + + Returns ``(mac_hex | None, persisted)`` where **persisted** is True iff ``save()`` + ran (new row or field changes). Duplicate hellos with identical data are no-ops. """ mac_hex = normalize_mac(mac) if not mac_hex: - return None + return None, False name = (device_name or "").strip() if not name: - return None + return None, False ip = normalize_address_for_transport(peer_ip, "wifi") if not ip: - return None + return None, False resolved_type = None if device_type is not None: try: @@ -254,7 +257,8 @@ class Device(Model): except ValueError: resolved_type = None if mac_hex in self: - merged = dict(self[mac_hex]) + prev = self[mac_hex] + merged = dict(prev) merged["name"] = name if resolved_type is not None: merged["type"] = resolved_type @@ -263,9 +267,11 @@ class Device(Model): merged["transport"] = "wifi" merged["address"] = ip merged["id"] = mac_hex + if merged == prev: + return mac_hex, False self[mac_hex] = merged self.save() - return mac_hex + return mac_hex, True self[mac_hex] = { "id": mac_hex, "name": name, @@ -276,4 +282,4 @@ class Device(Model): "zones": [], } self.save() - return mac_hex + return mac_hex, True diff --git a/src/models/tcp_clients.py b/src/models/tcp_clients.py deleted file mode 100644 index cb85288..0000000 --- a/src/models/tcp_clients.py +++ /dev/null @@ -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 diff --git a/src/models/wifi_ws_clients.py b/src/models/wifi_ws_clients.py new file mode 100644 index 0000000..1143d2a --- /dev/null +++ b/src/models/wifi_ws_clients.py @@ -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://: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() diff --git a/src/settings.py b/src/settings.py index 7bfa290..00267bd 100644 --- a/src/settings.py +++ b/src/settings.py @@ -48,11 +48,15 @@ class Settings(dict): # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11 if 'wifi_channel' not in self: self['wifi_channel'] = 6 - # Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport) - if 'tcp_enabled' not in self: - self['tcp_enabled'] = True - if 'tcp_port' not in self: - self['tcp_port'] = 8765 + # Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws) + if 'wifi_driver_ws_port' not in self: + self['wifi_driver_ws_port'] = 80 + if 'wifi_driver_ws_path' not in self: + self['wifi_driver_ws_path'] = '/ws' + # Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is + # down (0 disables). Helps drivers that reconnect after seeing traffic on 8766. + if 'wifi_driver_hello_interval_s' not in self: + self['wifi_driver_hello_interval_s'] = 10.0 def save(self): try: diff --git a/src/util/device_status_broadcaster.py b/src/util/device_status_broadcaster.py index 5d52a7c..b6d5d4a 100644 --- a/src/util/device_status_broadcaster.py +++ b/src/util/device_status_broadcaster.py @@ -1,4 +1,4 @@ -"""Push Wi-Fi TCP connect/disconnect updates to browser WebSocket clients.""" +"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients.""" import json import threading @@ -20,7 +20,7 @@ async def unregister_device_status_ws(ws: Any) -> None: async def broadcast_device_tcp_status(ip: str, connected: bool) -> None: - from models.tcp_clients import normalize_tcp_peer_ip + from models.wifi_ws_clients import normalize_tcp_peer_ip ip = normalize_tcp_peer_ip(ip) if not ip: @@ -42,7 +42,7 @@ async def broadcast_device_tcp_status(ip: str, connected: bool) -> None: async def broadcast_device_tcp_snapshot_to(ws: Any) -> None: - from models import tcp_clients as tcp + from models import wifi_ws_clients as tcp ips = tcp.list_connected_ips() msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips}) diff --git a/src/util/driver_delivery.py b/src/util/driver_delivery.py index 3c1922e..a02eb64 100644 --- a/src/util/driver_delivery.py +++ b/src/util/driver_delivery.py @@ -1,10 +1,10 @@ -"""Deliver driver JSON messages over serial (ESP-NOW) and/or TCP (Wi-Fi clients).""" +"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers).""" import asyncio import json from models.device import normalize_mac -from models.tcp_clients import send_json_line_to_ip +from models.wifi_ws_clients import send_json_line_to_ip # Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer. _SPLIT_MODE = "split" @@ -20,7 +20,7 @@ def _split_serial_envelope(inner_json_str, peer_hex_list): def _wifi_message_for_device(msg, device_name): """ - For Wi-Fi TCP fanout, narrow a v1 select map to a single device name. + For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name. Returns the original message when no narrowing applies. """ if not device_name: @@ -40,6 +40,33 @@ def _wifi_message_for_device(msg, device_name): return json.dumps(body, separators=(",", ":")) +def _combine_preset_chunks_for_wifi(chunk_messages): + """Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi.""" + merged_presets = {} + save_flag = False + default_id = None + for msg in chunk_messages: + try: + body = json.loads(msg) + except Exception: + continue + if not isinstance(body, dict): + continue + presets = body.get("presets") + if isinstance(presets, dict): + merged_presets.update(presets) + if body.get("save"): + save_flag = True + if body.get("default") is not None: + default_id = body.get("default") + out = {"v": "1", "presets": merged_presets} + if save_flag: + out["save"] = True + if default_id is not None: + out["default"] = default_id + return json.dumps(out, separators=(",", ":")) + + async def deliver_preset_broadcast_then_per_device( sender, chunk_messages, @@ -50,8 +77,8 @@ async def deliver_preset_broadcast_then_per_device( ): """ Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each - Wi-Fi driver over TCP. If default_id is set, send a per-target default message - (unicast serial or TCP) with targets=[device name] for each registry entry. + Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message + (unicast serial or WebSocket) with targets=[device name] for each registry entry. """ if not chunk_messages: return 0 @@ -72,17 +99,22 @@ async def deliver_preset_broadcast_then_per_device( wifi_ips.append(str(doc["address"]).strip()) deliveries = 0 + wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages) for msg in chunk_messages: tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)] - for ip in wifi_ips: - if ip: - tasks.append(send_json_line_to_ip(ip, msg)) results = await asyncio.gather(*tasks, return_exceptions=True) if results and results[0] is True: deliveries += 1 - for r in results[1:]: - if r is True: + await asyncio.sleep(delay_s) + + for ip in wifi_ips: + if not ip: + continue + try: + if await send_json_line_to_ip(ip, wifi_combined_msg): deliveries += 1 + except Exception as e: + print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}") await asyncio.sleep(delay_s) if default_id: @@ -98,7 +130,7 @@ async def deliver_preset_broadcast_then_per_device( if await send_json_line_to_ip(ip, out): deliveries += 1 except Exception as e: - print(f"[driver_delivery] default TCP failed: {e!r}") + print(f"[driver_delivery] default Wi-Fi send failed: {e!r}") else: try: await sender.send(out, addr=mac) @@ -112,10 +144,10 @@ async def deliver_preset_broadcast_then_per_device( async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1): """ - Send each message string to the bridge and/or TCP clients. + Send each message string to the bridge and/or Wi-Fi WebSocket clients. If target_macs is None or empty: one serial send per message (default/broadcast address). - Otherwise: Wi-Fi uses TCP in parallel. Multiple ESP-NOW peers are sent in **one** serial + Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial tasks run together in one asyncio.gather. diff --git a/tests/device_ws_cycle.py b/tests/device_ws_cycle.py new file mode 100644 index 0000000..42f3b9c --- /dev/null +++ b/tests/device_ws_cycle.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Discover a Wi‑Fi LED driver via UDP hello, then drive it over WebSocket. + +1. Listens on UDP (default port 8766) for the same JSON line the firmware sends + (``v``, ``device_name``, ``mac``, ``type``: ``led``). +2. Opens ``ws://:/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()) diff --git a/tests/models/test_device.py b/tests/models/test_device.py index 0f07b37..b3aadbd 100644 --- a/tests/models/test_device.py +++ b/tests/models/test_device.py @@ -93,39 +93,43 @@ def test_device(): def test_upsert_wifi_tcp_client(): devices = _fresh_device() - assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) is None - assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") is None + assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) == (None, False) + assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") == ( + None, + False, + ) m1 = "001122334455" m2 = "001122334466" - i1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1) - assert i1 == m1 + i1, p1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1) + assert i1 == m1 and p1 is True d = devices.read(i1) assert d["name"] == "kitchen" assert d["type"] == "led" assert d["transport"] == "wifi" assert d["address"] == "192.168.1.20" - i2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2) - assert i2 == m2 + noop_mac, noop_p = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1) + assert noop_mac == m1 and noop_p is False + + i2, p2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2) + assert i2 == m2 and p2 is True assert devices.read(m1)["address"] == "192.168.1.20" assert devices.read(m2)["address"] == "192.168.1.21" assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen" - again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1) - assert again == m1 + again, p_again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1) + assert again == m1 and p_again is True assert devices.read(m1)["address"] == "192.168.1.99" - assert ( - devices.upsert_wifi_tcp_client( - "kitchen", "192.168.1.100", m1, device_type="bogus" - ) - == m1 + bogus_mac, bogus_p = devices.upsert_wifi_tcp_client( + "kitchen", "192.168.1.100", m1, device_type="bogus" ) + assert bogus_mac == m1 and bogus_p is True assert devices.read(m1)["type"] == "led" - i3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe") - assert i3 == "deadbeefcafe" + i3, p3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe") + assert i3 == "deadbeefcafe" and p3 is True assert len(devices.list()) == 3