diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c870d58 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to .env and adjust. Used by firmware/Makefile. +# CLI overrides still win: make deploy PANEL_ID=1 NETWORK=dhcp +# +# Strip defaults: GP18, 19, 20, 21, 22, 26, 27, 28 + +PANEL_ID=0 + +# static | dhcp (static values below are also DHCP fallback) +NETWORK=static +IP=10.1.1.10 +NETMASK=255.255.255.0 +GATEWAY=10.1.1.1 +DNS=10.1.1.1 + +# Optional (override strip 0 / 1 only): +# WS2812_PIN=18 +# WS2812_PIN1=19 +# PICO_SDK_PATH=/home/you/pico/pico-sdk +# JOBS=4 +# SERIAL_PORT=/dev/ttyACM0 +# SERIAL_BAUD=115200 diff --git a/.gitignore b/.gitignore index 36f2ffa..939f00c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,17 @@ -# Build files +# Firmware build +firmware/build/ build/ sdkconfig sdkconfig.old +# Local make / deploy config +.env + # Binary files *.bin +*.uf2 +*.elf +*.hex # Python __pycache__/ @@ -16,7 +23,6 @@ env/ venv/ *.egg-info/ dist/ -build/ # IDE .vscode/ diff --git a/LICENSE b/LICENSE index f8a78ba..51a9472 100644 --- a/LICENSE +++ b/LICENSE @@ -219,8 +219,8 @@ If you develop a new program, and you want it to be of the greatest possible use To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - led-driver - Copyright (C) 2025 technicalkiwi + portal + Copyright (C) 2024 technicalkiwi This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 0ba5375..0000000 --- a/Pipfile +++ /dev/null @@ -1,24 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -mpremote = "*" -pyserial = "*" -esptool = "*" -watchfiles = "*" -fastapi = "*" -uvicorn = "*" -flask = "*" -serial = "*" - -[dev-packages] - -[requires] -python_version = "3" - -[scripts] -dev = 'watchfiles "./dev.py /dev/ttyACM0 src reset follow"' -web = "uvicorn tool:app --host 0.0.0.0 --port 8080" -install = "pipenv install" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 25c82e9..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,989 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "5d970f8c0ea9e8ffa98cf0ea5f791161589a97d953d2629da026d01fa7a8bce7" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "annotated-doc": { - "hashes": [ - "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", - "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" - ], - "markers": "python_version >= '3.8'", - "version": "==0.0.4" - }, - "annotated-types": { - "hashes": [ - "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", - "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" - ], - "markers": "python_version >= '3.8'", - "version": "==0.7.0" - }, - "anyio": { - "hashes": [ - "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", - "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" - ], - "markers": "python_version >= '3.9'", - "version": "==4.12.1" - }, - "bitarray": { - "hashes": [ - "sha256:004d518fa410e6da43386d20e07b576a41eb417ac67abf9f30fa75e125697199", - "sha256:014df8a9430276862392ac5d471697de042367996c49f32d0008585d2c60755a", - "sha256:01c5f0dc080b0ebb432f7a68ee1e88a76bd34f6d89c9568fcec65fb16ed71f0e", - "sha256:025d133bf4ca8cf75f904eeb8ea946228d7c043231866143f31946a6f4dd0bf3", - "sha256:0ca70ccf789446a6dfde40b482ec21d28067172cd1f8efd50d5548159fccad9e", - "sha256:0df69d26f21a9d2f1b20266f6737fa43f08aa5015c99900fb69f255fbe4dabb4", - "sha256:0f8069a807a3e6e3c361ce302ece4bf1c3b49962c1726d1d56587e8f48682861", - "sha256:15a2eff91f54d2b1f573cca8ca6fb58763ce8fea80e7899ab028f3987ef71cd5", - "sha256:15e8d0597cc6e8496de6f4dea2a6880c57e1251502a7072f5631108a1aa28521", - "sha256:165052a0e61c880f7093808a0c524ce1b3555bfa114c0dfb5c809cd07918a60d", - "sha256:178c5a4c7fdfb5cd79e372ae7f675390e670f3732e5bc68d327e01a5b3ff8d55", - "sha256:18214bac86341f1cc413772e66447d6cca10981e2880b70ecaf4e826c04f95e9", - "sha256:1a54d7e7999735faacdcbe8128e30207abc2caf9f9fd7102d180b32f1b78bfce", - "sha256:1a926fa554870642607fd10e66ee25b75fdd9a7ca4bbffa93d424e4ae2bf734a", - "sha256:1c8f2a5d8006db5a555e06f9437e76bf52537d3dfd130cb8ae2b30866aca32c9", - "sha256:21ca6a47bf20db9e7ad74ca04b3d479e4d76109b68333eb23535553d2705339e", - "sha256:22c540ed20167d3dbb1e2d868ca935180247d620c40eace90efa774504a40e3b", - "sha256:239578587b9c29469ab61149dda40a2fe714a6a4eca0f8ff9ea9439ec4b7bc30", - "sha256:25b9cff6c9856bc396232e2f609ea0c5ec1a8a24c500cee4cca96ba8a3cd50b6", - "sha256:26714898eb0d847aac8af94c4441c9cb50387847d0fe6b9fc4217c086cd68b80", - "sha256:28a85b056c0eb7f5d864c0ceef07034117e8ebfca756f50648c71950a568ba11", - "sha256:2a3d1b05ffdd3e95687942ae7b13c63689f85d3f15c39b33329e3cb9ce6c015f", - "sha256:2c3bb96b6026643ce24677650889b09073f60b9860a71765f843c99f9ab38b25", - "sha256:2fa23fdb3beab313950bbb49674e8a161e61449332d3997089fe3944953f1b77", - "sha256:2fe8c54b15a9cd4f93bc2aaceab354ec65af93370aa1496ba2f9c537a4855ee0", - "sha256:30989a2451b693c3f9359d91098a744992b5431a0be4858f1fdf0ec76b457125", - "sha256:31a4ad2b730128e273f1c22300da3e3631f125703e4fee0ac44d385abfb15671", - "sha256:337c8cd46a4c6568d367ed676cbf2d7de16f890bb31dbb54c44c1d6bb6d4a1de", - "sha256:33af25c4ff7723363cb8404dfc2eefeab4110b654f6c98d26aba8a08c745d860", - "sha256:3ea52df96566457735314794422274bd1962066bfb609e7eea9113d70cf04ffe", - "sha256:3eae38daffd77c9621ae80c16932eea3fb3a4af141fb7cc724d4ad93eff9210d", - "sha256:41b53711f89008ba2de62e4c2d2260a8b357072fd4f18e1351b28955db2719dc", - "sha256:451f9958850ea98440d542278368c8d1e1ea821e2494b204570ba34a340759df", - "sha256:46cf239856b87fe1c86dfbb3d459d840a8b1649e7922b1e0bfb6b6464692644a", - "sha256:4779f356083c62e29b4198d290b7b17a39a69702d150678b7efff0fdddf494a8", - "sha256:4902f4ecd5fcb6a5f482d7b0ae1c16c21f26fc5279b3b6127363d13ad8e7a9d9", - "sha256:4d73d4948dcc5591d880db8933004e01f1dd2296df9de815354d53469beb26fe", - "sha256:4d9984017314da772f5f7460add7a0301a4ffc06c72c2998bb16c300a6253607", - "sha256:4f298daaaea58d45e245a132d6d2bdfb6f856da50dc03d75ebb761439fb626cf", - "sha256:50ddbe3a7b4b6ab96812f5a4d570f401a2cdb95642fd04c062f98939610bbeee", - "sha256:5338a313f998e1be7267191b7caaae82563b4a2b42b393561055412a34042caa", - "sha256:5591daf81313096909d973fb2612fccd87528fdfdd39f6478bdce54543178954", - "sha256:56896ceeffe25946c4010320629e2d858ca763cd8ded273c81672a5edbcb1e0a", - "sha256:58a01ea34057463f7a98a4d6ff40160f65f945e924fec08a5b39e327e372875d", - "sha256:5bfac7f236ba1a4d402644bdce47fb9db02a7cf3214a1f637d3a88390f9e5428", - "sha256:5c5a8a83df95e51f7a7c2b083eaea134cbed39fc42c6aeb2e764ddb7ccccd43e", - "sha256:5f2fb10518f6b365f5b720e43a529c3b2324ca02932f609631a44edb347d8d54", - "sha256:64af877116edf051375b45f0bda648143176a017b13803ec7b3a3111dc05f4c5", - "sha256:6d70fa9c6d2e955bde8cd327ffc11f2cc34bc21944e5571a46ca501e7eadef24", - "sha256:6d79f659965290af60d6acc8e2716341865fe74609a7ede2a33c2f86ad893b8f", - "sha256:720963fee259291a88348ae9735d9deb5d334e84a016244f61c89f5a49aa400a", - "sha256:75a3b6e9c695a6570ea488db75b84bb592ff70a944957efa1c655867c575018b", - "sha256:792462abfeeca6cc8c6c1e6d27e14319682f0182f6b0ba37befe911af794db70", - "sha256:79ec4498a545733ecace48d780d22407411b07403a2e08b9a4d7596c0b97ebd7", - "sha256:7f14d6b303e55bd7d19b28309ef8014370e84a3806c5e452e078e7df7344d97a", - "sha256:7f65bd5d4cdb396295b6aa07f84ca659ac65c5c68b53956a6d95219e304b0ada", - "sha256:81c6b4a6c1af800d52a6fa32389ef8f4281583f4f99dc1a40f2bb47667281541", - "sha256:82a07de83dce09b4fa1bccbdc8bde8f188b131666af0dc9048ba0a0e448d8a3b", - "sha256:847c7f61964225fc489fe1d49eda7e0e0d253e98862c012cecf845f9ad45cdf4", - "sha256:84b52b2cf77bb7f703d16c4007b021078dbbe6cf8ffb57abe81a7bacfc175ef2", - "sha256:86685fa04067f7175f9718489ae755f6acde03593a1a9ca89305554af40e14fd", - "sha256:8a9c962c64a4c08def58b9799333e33af94ec53038cf151d36edacdb41f81646", - "sha256:8cbd4bfc933b33b85c43ef4c1f4d5e3e9d91975ea6368acf5fbac02bac06ea89", - "sha256:8ffe660e963ae711cb9e2b8d8461c9b1ad6167823837fc17d59d5e539fb898fa", - "sha256:94652da1a4ca7cfb69c15dd6986b205e0bd9c63a05029c3b48b4201085f527bd", - "sha256:969fd67de8c42affdb47b38b80f1eaa79ac0ef17d65407cdd931db1675315af1", - "sha256:9858dcbc23ba7eaadcd319786b982278a1a2b2020720b19db43e309579ff76fb", - "sha256:99d25aff3745c54e61ab340b98400c52ebec04290a62078155e0d7eb30380220", - "sha256:99f55e14e7c56f4fafe1343480c32b110ef03836c21ff7c48bae7add6818f77c", - "sha256:9d35d8f8a1c9ed4e2b08187b513f8a3c71958600129db3aa26d85ea3abfd1310", - "sha256:a2ba92f59e30ce915e9e79af37649432e3a212ddddf416d4d686b1b4825bcdb2", - "sha256:a2cb35a6efaa0e3623d8272471371a12c7e07b51a33e5efce9b58f655d864b4e", - "sha256:a358277122456666a8b2a0b9aa04f1b89d34e8aa41d08a6557d693e6abb6667c", - "sha256:a60da2f9efbed355edb35a1fb6829148676786c829fad708bb6bb47211b3593a", - "sha256:aa7dec53c25f1949513457ef8b0ea1fb40e76c672cc4d2daa8ad3c8d6b73491a", - "sha256:b1572ee0eb1967e71787af636bb7d1eb9c6735d5337762c450650e7f51844594", - "sha256:b4f10d3f304be7183fac79bf2cd997f82e16aa9a9f37343d76c026c6e435a8a8", - "sha256:bbbbfbb7d039b20d289ce56b1beb46138d65769d04af50c199c6ac4cb6054d52", - "sha256:c394a3f055b49f92626f83c1a0b6d6cd2c628f1ccd72481c3e3c6aa4695f3b20", - "sha256:c396358023b876cff547ce87f4e8ff8a2280598873a137e8cc69e115262260b8", - "sha256:c5ba07e58fd98c9782201e79eb8dd4225733d212a5a3700f9a84d329bd0463a6", - "sha256:c764fb167411d5afaef88138542a4bfa28bd5e5ded5e8e42df87cef965efd6e9", - "sha256:cbba763d99de0255a3e4938f25a8579930ac8aa089233cb2fb2ed7d04d4aff02", - "sha256:cbd1660fb48827381ce3a621a4fdc237959e1cd4e98b098952a8f624a0726425", - "sha256:cd761d158f67e288fd0ebe00c3b158095ce80a4bc7c32b60c7121224003ba70d", - "sha256:cdfbb27f2c46bb5bbdcee147530cbc5ca8ab858d7693924e88e30ada21b2c5e2", - "sha256:d2dbe8a3baf2d842e342e8acb06ae3844765d38df67687c144cdeb71f1bcb5d7", - "sha256:d5c931ec1c03111718cabf85f6012bb2815fa0ce578175567fa8d6f2cc15d3b4", - "sha256:df6d7bf3e15b7e6e202a16ff4948a51759354016026deb04ab9b5acbbe35e096", - "sha256:dfbe2aa45b273f49e715c5345d94874cb65a28482bf231af408891c260601b8d", - "sha256:e12769d3adcc419e65860de946df8d2ed274932177ac1cdb05186e498aaa9149", - "sha256:e5aed4754895942ae15ffa48c52d181e1c1463236fda68d2dba29c03aa61786b", - "sha256:e645b4c365d6f1f9e0799380ad6395268f3c3b898244a650aaeb8d9d27b74c35", - "sha256:ed3493a369fe849cce98542d7405c88030b355e4d2e113887cb7ecc86c205773", - "sha256:f08342dc8d19214faa7ef99574dea6c37a2790d6d04a9793ef8fa76c188dc08d", - "sha256:f0a55cf02d2cdd739b40ce10c09bbdd520e141217696add7a48b56e67bdfdfe6", - "sha256:f0ce9d9e07c75da8027c62b4c9f45771d1d8aae7dc9ad7fb606c6a5aedbe9741", - "sha256:f1f723e260c35e1c7c57a09d3a6ebe681bd56c83e1208ae3ce1869b7c0d10d4f", - "sha256:f2fcbe9b3a5996b417e030aa33a562e7e20dfc86271e53d7e841fc5df16268b8", - "sha256:f3fd8df63c41ff6a676d031956aebf68ebbc687b47c507da25501eb22eec341f", - "sha256:f8d3417db5e14a6789073b21ae44439a755289477901901bae378a57b905e148", - "sha256:fbf05678c2ae0064fb1b8de7e9e8f0fc30621b73c8477786dd0fb3868044a8c8", - "sha256:fc98ff43abad61f00515ad9a06213b7716699146e46eabd256cdfe7cb522bd97", - "sha256:ff1863f037dad765ef5963efc2e37d399ac023e192a6f2bb394e2377d023cefe" - ], - "version": "==3.8.0" - }, - "bitstring": { - "hashes": [ - "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37", - "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737" - ], - "markers": "python_version >= '3.8'", - "version": "==4.4.0" - }, - "blinker": { - "hashes": [ - "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", - "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc" - ], - "markers": "python_version >= '3.9'", - "version": "==1.9.0" - }, - "cffi": { - "hashes": [ - "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", - "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", - "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", - "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", - "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", - "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", - "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", - "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", - "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", - "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", - "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", - "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", - "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", - "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", - "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", - "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", - "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", - "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", - "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", - "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", - "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", - "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", - "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", - "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", - "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", - "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", - "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", - "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", - "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", - "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", - "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", - "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", - "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", - "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", - "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", - "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", - "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", - "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", - "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", - "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", - "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", - "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", - "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", - "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", - "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", - "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", - "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", - "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", - "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", - "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", - "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", - "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", - "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", - "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", - "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", - "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", - "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", - "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", - "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", - "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", - "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", - "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", - "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", - "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", - "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", - "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", - "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", - "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", - "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", - "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", - "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", - "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", - "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", - "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", - "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", - "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", - "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", - "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", - "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", - "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", - "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", - "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", - "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", - "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" - ], - "markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", - "version": "==2.0.0" - }, - "click": { - "hashes": [ - "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", - "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" - ], - "markers": "python_version >= '3.10'", - "version": "==8.3.1" - }, - "cryptography": { - "hashes": [ - "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", - "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", - "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", - "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", - "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", - "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", - "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", - "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", - "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", - "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", - "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", - "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", - "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", - "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", - "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", - "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", - "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", - "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", - "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", - "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", - "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", - "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", - "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", - "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", - "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", - "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", - "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", - "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", - "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", - "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", - "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", - "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", - "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", - "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", - "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", - "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", - "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", - "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", - "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", - "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", - "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", - "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", - "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", - "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", - "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", - "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", - "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", - "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", - "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87" - ], - "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", - "version": "==46.0.5" - }, - "esptool": { - "hashes": [ - "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" - ], - "index": "pypi", - "version": "==5.2.0" - }, - "fastapi": { - "hashes": [ - "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", - "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd" - ], - "index": "pypi", - "version": "==0.135.1" - }, - "flask": { - "hashes": [ - "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", - "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c" - ], - "index": "pypi", - "version": "==3.1.3" - }, - "future": { - "hashes": [ - "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", - "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.0" - }, - "h11": { - "hashes": [ - "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", - "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" - ], - "markers": "python_version >= '3.8'", - "version": "==0.16.0" - }, - "idna": { - "hashes": [ - "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", - "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" - ], - "markers": "python_version >= '3.8'", - "version": "==3.11" - }, - "intelhex": { - "hashes": [ - "sha256:87cc5225657524ec6361354be928adfd56bcf2a3dcc646c40f8f094c39c07db4", - "sha256:892b7361a719f4945237da8ccf754e9513db32f5628852785aea108dcd250093" - ], - "version": "==2.3.0" - }, - "iso8601": { - "hashes": [ - "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", - "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242" - ], - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==2.1.0" - }, - "itsdangerous": { - "hashes": [ - "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", - "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173" - ], - "markers": "python_version >= '3.8'", - "version": "==2.2.0" - }, - "jinja2": { - "hashes": [ - "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", - "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" - ], - "markers": "python_version >= '3.7'", - "version": "==3.1.6" - }, - "markdown-it-py": { - "hashes": [ - "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", - "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" - ], - "markers": "python_version >= '3.10'", - "version": "==4.0.0" - }, - "markupsafe": { - "hashes": [ - "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", - "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", - "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", - "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", - "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", - "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", - "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", - "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", - "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", - "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", - "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", - "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", - "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", - "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", - "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", - "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", - "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", - "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", - "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", - "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", - "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", - "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", - "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", - "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", - "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", - "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758", - "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", - "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", - "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", - "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", - "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", - "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", - "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", - "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", - "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", - "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", - "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2", - "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", - "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", - "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", - "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", - "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", - "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", - "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", - "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", - "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e", - "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", - "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", - "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", - "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", - "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", - "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", - "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b", - "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", - "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", - "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", - "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d", - "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", - "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", - "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", - "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", - "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", - "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", - "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", - "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", - "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", - "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", - "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", - "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", - "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", - "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", - "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", - "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", - "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7", - "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", - "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", - "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", - "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", - "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", - "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42", - "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", - "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", - "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", - "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", - "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", - "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", - "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", - "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", - "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" - ], - "markers": "python_version >= '3.9'", - "version": "==3.0.3" - }, - "mdurl": { - "hashes": [ - "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", - "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" - ], - "markers": "python_version >= '3.7'", - "version": "==0.1.2" - }, - "mpremote": { - "hashes": [ - "sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4", - "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" - ], - "index": "pypi", - "version": "==1.27.0" - }, - "platformdirs": { - "hashes": [ - "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", - "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868" - ], - "markers": "python_version >= '3.10'", - "version": "==4.9.4" - }, - "pycparser": { - "hashes": [ - "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", - "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992" - ], - "markers": "implementation_name != 'PyPy'", - "version": "==3.0" - }, - "pydantic": { - "hashes": [ - "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", - "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" - ], - "markers": "python_version >= '3.9'", - "version": "==2.12.5" - }, - "pydantic-core": { - "hashes": [ - "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", - "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", - "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", - "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", - "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", - "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", - "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", - "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", - "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", - "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", - "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", - "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", - "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", - "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", - "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", - "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", - "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", - "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", - "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", - "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", - "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", - "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", - "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", - "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", - "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", - "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", - "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", - "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", - "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", - "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", - "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", - "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", - "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", - "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", - "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", - "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", - "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", - "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", - "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", - "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", - "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", - "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", - "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", - "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", - "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", - "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", - "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", - "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", - "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", - "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", - "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", - "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", - "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", - "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", - "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", - "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", - "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", - "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", - "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", - "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", - "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", - "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", - "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", - "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", - "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", - "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", - "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", - "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", - "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", - "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", - "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", - "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", - "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", - "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", - "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", - "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", - "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", - "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", - "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", - "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", - "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", - "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", - "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", - "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", - "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", - "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", - "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", - "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", - "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", - "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", - "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", - "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", - "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", - "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", - "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", - "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", - "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", - "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", - "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", - "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", - "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", - "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", - "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", - "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", - "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", - "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", - "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", - "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", - "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", - "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", - "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", - "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", - "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", - "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", - "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", - "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", - "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", - "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", - "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", - "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", - "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52" - ], - "markers": "python_version >= '3.9'", - "version": "==2.41.5" - }, - "pygments": { - "hashes": [ - "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", - "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" - ], - "markers": "python_version >= '3.8'", - "version": "==2.19.2" - }, - "pyserial": { - "hashes": [ - "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", - "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" - ], - "index": "pypi", - "version": "==3.5" - }, - "pyyaml": { - "hashes": [ - "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", - "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", - "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", - "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", - "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", - "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", - "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", - "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", - "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", - "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", - "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", - "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", - "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", - "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", - "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", - "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", - "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", - "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", - "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", - "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", - "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", - "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", - "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", - "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", - "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", - "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", - "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", - "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", - "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", - "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", - "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", - "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", - "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", - "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", - "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", - "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", - "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", - "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", - "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", - "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", - "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", - "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", - "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", - "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", - "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", - "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", - "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", - "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", - "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", - "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", - "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", - "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", - "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", - "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", - "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", - "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", - "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", - "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", - "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", - "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", - "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", - "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", - "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", - "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", - "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", - "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", - "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", - "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", - "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", - "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", - "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", - "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", - "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" - ], - "markers": "python_version >= '3.8'", - "version": "==6.0.3" - }, - "reedsolo": { - "hashes": [ - "sha256:2b6a3e402a1ee3e1eea3f932f81e6c0b7bbc615588074dca1dbbcdeb055002bd", - "sha256:c1359f02742751afe0f1c0de9f0772cc113835aa2855d2db420ea24393c87732" - ], - "version": "==1.7.0" - }, - "rich": { - "hashes": [ - "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", - "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b" - ], - "markers": "python_full_version >= '3.8.0'", - "version": "==14.3.3" - }, - "rich-click": { - "hashes": [ - "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", - "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b" - ], - "markers": "python_version >= '3.8'", - "version": "==1.9.7" - }, - "serial": { - "hashes": [ - "sha256:542150a127ddbf5ed2acc3a6ac4ce807cbcdae3b197acf785bbda6565c94f848", - "sha256:e887f06e07e190e39174b694eee6724e3c48bd361be1d97964caef5d5b61c73b" - ], - "index": "pypi", - "version": "==0.0.97" - }, - "starlette": { - "hashes": [ - "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", - "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933" - ], - "markers": "python_version >= '3.10'", - "version": "==0.52.1" - }, - "tibs": { - "hashes": [ - "sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a", - "sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e", - "sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7", - "sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb", - "sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0", - "sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b", - "sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54", - "sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02", - "sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037", - "sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a", - "sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f", - "sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392", - "sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac", - "sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb", - "sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215", - "sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2", - "sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f", - "sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f", - "sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3", - "sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98", - "sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c", - "sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2", - "sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44", - "sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452", - "sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf", - "sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3", - "sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99", - "sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2", - "sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41", - "sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa", - "sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f" - ], - "markers": "python_version >= '3.8'", - "version": "==0.5.7" - }, - "typing-extensions": { - "hashes": [ - "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", - "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" - ], - "markers": "python_version >= '3.9'", - "version": "==4.15.0" - }, - "typing-inspection": { - "hashes": [ - "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", - "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" - ], - "markers": "python_version >= '3.9'", - "version": "==0.4.2" - }, - "uvicorn": { - "hashes": [ - "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", - "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775" - ], - "index": "pypi", - "version": "==0.42.0" - }, - "watchfiles": { - "hashes": [ - "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", - "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", - "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", - "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", - "sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2", - "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", - "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", - "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", - "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", - "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", - "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", - "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", - "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", - "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", - "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", - "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", - "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", - "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", - "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", - "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", - "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", - "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", - "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", - "sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34", - "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", - "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", - "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", - "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", - "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", - "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", - "sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e", - "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", - "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", - "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", - "sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc", - "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", - "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", - "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", - "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", - "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", - "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", - "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", - "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", - "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", - "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", - "sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02", - "sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b", - "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", - "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", - "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", - "sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957", - "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", - "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", - "sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956", - "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", - "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", - "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", - "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", - "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", - "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", - "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", - "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", - "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", - "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", - "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", - "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", - "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", - "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", - "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", - "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", - "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", - "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", - "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", - "sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3", - "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", - "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", - "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", - "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", - "sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c", - "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", - "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", - "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", - "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", - "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", - "sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c", - "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", - "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", - "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", - "sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70", - "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", - "sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f", - "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", - "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", - "sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be", - "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", - "sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e", - "sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f", - "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", - "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", - "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", - "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", - "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", - "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", - "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", - "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", - "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", - "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", - "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", - "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" - ], - "index": "pypi", - "version": "==1.1.1" - }, - "werkzeug": { - "hashes": [ - "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", - "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131" - ], - "markers": "python_version >= '3.9'", - "version": "==3.1.6" - } - }, - "develop": {} -} diff --git a/README.md b/README.md index 14564a4..209ba7b 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,75 @@ -# LED Driver — MicroPython +# Portal panel firmware (Pico + W5500 + WS2812) -MicroPython LED driver for ESP32: presets, patterns, **Wi-Fi** (TCP + UDP discovery) or **ESP-NOW** transport, optional HTTP polling, and dynamic pattern modules under `src/patterns/`. +UDP RGB panel adapter for the [portal](https://git.technical.kiwi/technicalkiwi/portal.git) project. +Host renders patterns; this firmware only receives frames on **UDP 50007**. -## Prerequisites +## Layout -- MicroPython firmware on the ESP32 -- USB cable for programming -- Python 3 with pipenv (on the host, for `dev.py` / tests) - -## Setup - -1. Install dependencies: - - ```bash - pipenv install - ``` - -2. Deploy to the device: - - ```bash - pipenv run dev - ``` - -## Project layout - -``` -led-driver/ -├── src/ -│ ├── main.py # Entry: Wi-Fi/TCP or ESP-NOW path, process_data(), manifest OTA -│ ├── presets.py # Preset runtime + Presets class -│ ├── preset.py # Single preset helpers -│ ├── settings.py # settings.json -│ ├── hello.py # UDP discovery (port 8766) / hello payloads -│ ├── http_poll.py # Optional HTTP polling helper -│ ├── utils.py # Colour conversion / ordering -│ ├── presets.json # Default preset file (on device) -│ └── patterns/ # Pattern modules (.py), loaded dynamically -├── tests/ # Host-side helpers (e.g. udp_client.py, test_mdns.py) -├── test/ # On-device style pattern tests (all.py, patterns/) -├── dev.py # Deploy / sync to serial device -├── docs/API.md # Wire format (long keys); Pi app docs short keys -├── msg.json # Sample message -├── Pipfile -└── LICENSE +```text +led-driver-8/ +├── firmware/ # Pico SDK C + W5500 +├── examples/test_strips.py +├── .env.example +└── README.md ``` -**Transport:** `settings.json` **`transport_type`** is typically **`wifi`** (TCP to the Pi on port **8765**, discovery on **8766**) or **`espnow`**. ESP-NOW code paths are loaded only when needed so a Wi-Fi-only image stays smaller. +## Hardware -## Further reading +| Signal | GPIO | +|--------|------| +| W5500 CS | GP13 | +| SPI1 SCK | GP10 | +| SPI1 MOSI | GP11 | +| SPI1 MISO | GP12 | +| W5500 RST | GP9 | +| Status LED | GP25 | +| WS2812 strip 0–7 | GP18, 19, 20, 21, 22, 26, 27, 28 | -- **`docs/API.md`** — JSON message fields as used in examples (`pattern`, `colors`, …). The Pi app may send **short keys** (`p`, `c`, …); behaviour matches once normalised on device. +Eight strips (one PIO SM each). Pixel output uses **DMA → PIO**. Pixel count comes from each UDP frame. `MAX_LEDS` (512) is capacity only. + +## Build / flash + +```bash +cd firmware +make deploy PANEL_ID=0 +make reset && make monitor +``` + +Network options live in `.env` (see `.env.example`): + +```env +PANEL_ID=0 +NETWORK=static +IP=10.1.1.10 +NETMASK=255.255.255.0 +GATEWAY=10.1.1.1 +DNS=10.1.1.1 +``` + +| Panel | IP | MAC last byte | +|-------|-----|---------------| +| 0 | 10.1.1.10 | `00` | +| … | … | … | +| 4 | 10.1.1.14 | `04` | +| 255 (bench) | 10.1.1.19 | `FF` | + +## Test + +Self-contained (stdlib only): + +```bash +python3 examples/test_strips.py +python3 examples/test_strips.py --host 10.1.1.10 --leds 64 +``` + +Lights each strip alone with a unique colour, then flashes all strips R/G/B/white. + +## UDP protocol (port 50007) + +| Payload | Action | +|---------|--------| +| N×3 bytes | Raw RGB → strip 0 | +| 1 + N×3 bytes | `strip_id` + RGB (`0`–`7`, or `255` = all) | +| 5 bytes `PIN\0` + gpio | Bind strip 0 | +| 8 bytes `PIN\0` + strip + gpio + len_lo + len_hi | Bind strip | +| 4 bytes `SHOW` | Reserved | diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index e69de29..0000000 diff --git a/dev.py b/dev.py deleted file mode 100755 index 0d9fd7f..0000000 --- a/dev.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 - -import shutil -import subprocess -import serial -import sys -from pathlib import Path - - -def mpremote_base(): - """mpremote on PATH, or same interpreter as this script (e.g. pipenv venv).""" - exe = shutil.which("mpremote") - if exe: - return [exe] - return [sys.executable, "-m", "mpremote"] - - -print(sys.argv) - -# Extract port (first arg if it's not a command) -commands = ["src", "lib", "ls", "reset", "follow", "db", "test"] -port = None -if len(sys.argv) > 1 and sys.argv[1] not in commands: - port = sys.argv[1] - - -for cmd in sys.argv[1:]: - print(cmd) - match cmd: - case "src": - if port: - subprocess.call( - [*mpremote_base(), "connect", port, "fs", "cp", "-r", ".", ":"], - cwd="src", - ) - else: - print("Error: Port required for 'src' command") - case "lib": - if port: - subprocess.call([*mpremote_base(), "connect", port, "fs", "cp", "-r", "lib", ":"]) - else: - print("Error: Port required for 'lib' command") - case "ls": - if port: - subprocess.call([*mpremote_base(), "connect", port, "fs", "ls", ":"]) - else: - print("Error: Port required for 'ls' command") - case "reset": - if port: - with serial.Serial(port, baudrate=115200) as ser: - ser.write(b'\x03\x03\x04') - else: - print("Error: Port required for 'reset' command") - case "follow": - if port: - with serial.Serial(port, baudrate=115200) as ser: - while True: - if ser.in_waiting > 0: # Check if there is data in the buffer - data = ser.readline().decode('utf-8').strip() # Read and decode the data - print(data) - else: - print("Error: Port required for 'follow' command") - case "db": - if port: - subprocess.call([*mpremote_base(), "connect", port, "fs", "cp", "-r", "db", ":"]) - else: - print("Error: Port required for 'db' command") - case "test": - if port: - if "all" in sys.argv[1:]: - test_files = sorted( - str(path) - for path in Path("test").rglob("*.py") - if path.is_file() - ) - failed = [] - for test_file in test_files: - print(f"Running {test_file}") - code = subprocess.call( - [*mpremote_base(), "connect", port, "run", test_file] - ) - if code != 0: - failed.append((test_file, code)) - if failed: - print("Some tests failed:") - for test_file, code in failed: - print(f" {test_file} (exit {code})") - else: - subprocess.call( - [*mpremote_base(), "connect", port, "run", "test/all.py"] - ) - else: - print("Error: Port required for 'test' command") diff --git a/docs/API.md b/docs/API.md deleted file mode 100644 index 92f3eb5..0000000 --- a/docs/API.md +++ /dev/null @@ -1,263 +0,0 @@ -# LED Driver API (message format) - -This document describes the **JSON message format** for controlling LED driver devices. The same object is accepted from **ESP-NOW** (when that transport is enabled) and as **one JSON value per line** over **TCP** in **Wi-Fi** mode (see `src/main.py` on the device). - -## Message Format - -All messages are JSON objects with the following structure: - -```json -{ - "v": "1", - "presets": { ... }, - "select": { ... } -} -``` - -### Version Field - -- **`v`** (required): Message version, must be `"1"`. Messages with other versions are ignored. - -## Presets - -Presets define LED patterns with their configuration. Each preset has a name and contains pattern-specific settings. - -### Preset Structure - -```json -{ - "presets": { - "preset_name": { - "pattern": "pattern_type", - "colors": ["#RRGGBB", ...], - "delay": 100, - "brightness": 127, - "auto": true, - "n1": 0, - "n2": 0, - "n3": 0, - "n4": 0, - "n5": 0, - "n6": 0 - } - } -} -``` - -### Preset Fields - -- **`pattern`** (required): Pattern type. Options: - - `"off"` - Turn off all LEDs - - `"on"` - Solid colour - - `"blink"` - Blinking pattern - - `"rainbow"` - Rainbow colour cycle - - `"pulse"` - Pulse/fade pattern - - `"transition"` - Colour transition - - `"chase"` - Chasing pattern - - `"circle"` - Circle loading pattern - -- **`colors`** (optional): Array of hex colour strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]` - - Colours are automatically converted from hex to RGB and reordered based on device colour order setting - - Supports multiple colours for patterns that use them - -- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100` - -- **`brightness`** (optional): Brightness level (0-255). Default: `127` - -- **`auto`** (optional): Auto mode flag. Default: `true` - - `true`: Pattern runs continuously - - `false`: Pattern advances one step per beat (manual mode) - -- **`n1` through `n6`** (optional): Pattern-specific numeric parameters. Default: `0` - - See pattern-specific documentation below - -### Pattern-Specific Parameters - -#### Rainbow -- **`n1`**: Step increment (how many colour wheel positions to advance per update). Default: `1` - -#### Pulse -- **`n1`**: Attack time in milliseconds (fade in) -- **`n2`**: Hold time in milliseconds (full brightness) -- **`n3`**: Decay time in milliseconds (fade out) -- **`delay`**: Delay time in milliseconds (off between pulses) - -#### Transition -- **`delay`**: Transition duration in milliseconds - -#### Chase -- **`n1`**: Number of LEDs with first colour -- **`n2`**: Number of LEDs with second colour -- **`n3`**: Movement amount on even steps (can be negative) -- **`n4`**: Movement amount on odd steps (can be negative) - -#### Circle -- **`n1`**: Head movement rate (LEDs per second) -- **`n2`**: Maximum length -- **`n3`**: Tail movement rate (LEDs per second) -- **`n4`**: Minimum length - -## Select Messages - -Select messages control which preset is active on which device. The format uses a list to support step synchronization. - -### Select Format - -```json -{ - "select": { - "device_name": ["preset_name"], - "device_name2": ["preset_name2", step_value] - } -} -``` - -### Select Fields - -- **`select`**: Object mapping device names to selection lists - - **Key**: Device name (as configured in device settings) - - **Value**: List with one or two elements: - - `["preset_name"]` - Select preset (uses default step behavior) - - `["preset_name", step]` - Select preset with explicit step value (for synchronization) - -### Step Synchronization - -The step value allows precise synchronization across multiple devices: - -- **Without step**: `["preset_name"]` - - If switching to different preset: step resets to 0 - - If selecting "off" pattern: step resets to 0 - - If selecting same preset (beat): step is preserved, pattern restarts - -- **With step**: `["preset_name", 10]` - - Explicitly sets step to the specified value - - Useful for synchronizing multiple devices to the same step - -### Beat Functionality - -Calling `select()` again with the same preset name acts as a "beat" - it restarts the pattern generator: - -- **Single-tick patterns** (rainbow, chase in manual mode): Advance one step per beat -- **Multi-tick patterns** (pulse in manual mode): Run through full cycle per beat - -Example beat sequence: -```json -// Beat 1 -{"select": {"device1": ["rainbow_preset"]}} - -// Beat 2 (same preset = beat) -{"select": {"device1": ["rainbow_preset"]}} - -// Beat 3 -{"select": {"device1": ["rainbow_preset"]}} -``` - -## Synchronization - -### Using "off" Pattern - -Selecting the "off" pattern resets the step counter to 0, providing a synchronization point: - -```json -{ - "select": { - "device1": ["off"], - "device2": ["off"] - } -} -``` - -After all devices are "off", switching to a pattern ensures they all start from step 0: - -```json -{ - "select": { - "device1": ["rainbow_preset"], - "device2": ["rainbow_preset"] - } -} -``` - -### Using Step Parameter - -For precise synchronization, use the step parameter: - -```json -{ - "select": { - "device1": ["rainbow_preset", 10], - "device2": ["rainbow_preset", 10], - "device3": ["rainbow_preset", 10] - } -} -``` - -All devices will start at step 10 and advance together on subsequent beats. - -## Complete Example - -```json -{ - "v": "1", - "presets": { - "red_blink": { - "pattern": "blink", - "colors": ["#FF0000"], - "delay": 200, - "brightness": 255, - "auto": true - }, - "rainbow_manual": { - "pattern": "rainbow", - "delay": 100, - "n1": 2, - "auto": false - }, - "pulse_slow": { - "pattern": "pulse", - "colors": ["#00FF00"], - "delay": 500, - "n1": 1000, - "n2": 500, - "n3": 1000, - "auto": false - } - }, - "select": { - "device1": ["red_blink"], - "device2": ["rainbow_manual", 0], - "device3": ["pulse_slow"] - } -} -``` - -## Message Processing - -1. **Version Check**: Messages with `v != "1"` are rejected -2. **Preset Processing**: Presets are created or updated (upsert behavior) -3. **Colour Conversion**: Hex colours are converted to RGB tuples and reordered based on device colour order -4. **Selection**: Devices select their assigned preset, optionally with step value - -## Best Practices - -1. **Always include version**: Set `"v": "1"` in all messages -2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns -3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns -4. **Step for precision**: Use step parameter when exact synchronization is required -5. **Colour format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic - -## Error Handling - -- Invalid version: Message is ignored -- Missing preset: Selection fails, device keeps current preset -- Invalid pattern: Selection fails, device keeps current preset -- Missing colours: Pattern uses default white colour -- Invalid step: Step value is used as-is (may cause unexpected behavior) - -## Notes - -- Colours are automatically converted from hex strings to RGB tuples -- Colour order reordering happens automatically based on device settings -- Step counter wraps around (0-255 for rainbow, unbounded for others) -- Manual mode patterns stop after one step/cycle, waiting for next beat -- Auto mode patterns run continuously until changed diff --git a/docs/pattern-contract.md b/docs/pattern-contract.md deleted file mode 100644 index 5e6cb77..0000000 --- a/docs/pattern-contract.md +++ /dev/null @@ -1,51 +0,0 @@ -# Pattern Contract (Important) - -Pattern classes are loaded dynamically by `Presets._load_dynamic_patterns()`. - -Patterns must follow this contract exactly. - -## Required class shape - -- File name is the pattern id (for example `blink.py` -> pattern name `blink`). -- Module exports a class with: - - `__init__(self, driver)` where `driver` is the `Presets` instance. - - `run(self, preset)` that returns a generator. - -`Presets` binds patterns like this: - -- `pattern_class(self).run` -- then calls `self.patterns[preset.p](preset)` and stores that generator. -- every frame, `Presets.tick()` does `next(self.generator)`. - -## `run()` generator rules - -- `run()` must `yield` frequently (normally once per tick loop). -- Do not block inside `run()`: - - no `sleep()` / `sleep_ms()` / long loops without `yield`. - - no network or file I/O. -- Use time checks (`utime.ticks_ms()` + `utime.ticks_diff(...)`) to schedule updates. -- Keep pattern state inside local variables in `run()` (or object fields if needed). - -## Drawing and brightness - -- Use `self.driver.apply_brightness(color, preset.b)` for per-preset brightness. -- Write pixels through `self.driver.n[...]` / `self.driver.n.fill(...)`. -- Flush frame with `self.driver.n.write()`. -- If a pattern needs to clear, use black `(0, 0, 0)`. - -## Step semantics - -- `self.driver.step` is shared pattern state managed by `Presets.select(...)` and patterns. -- Patterns that use step-based progression should update `self.driver.step` themselves. -- `select(..., step=...)` may set an explicit starting step. - -## Error handling - -- Let unexpected errors raise inside the generator. -- `Presets.tick()` catches exceptions, logs, and stops the active generator. -- Pattern code should not swallow broad exceptions unless there is a clear recovery path. - -## Built-ins - -- `off` and `on` are built-in methods on `Presets`, not loaded from this folder. -- `__init__.py` is ignored by dynamic loader. diff --git a/examples/test_strips.py b/examples/test_strips.py new file mode 100755 index 0000000..262284b --- /dev/null +++ b/examples/test_strips.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Drive-test all 8 WS2812 strips on led-driver-8 over UDP. + +Protocol (port 50007): + - 1 + N×3 bytes: strip_id (0–7, or 255=all) + RGB + - 8 bytes PIN\\0 + strip + gpio + len_lo + len_hi + +Defaults match firmware board_config.h strip map. + +Usage: + python3 examples/test_strips.py + python3 examples/test_strips.py --host 10.1.1.10 --leds 64 + python3 examples/test_strips.py --host 10.1.1.10 --all-only +""" + +from __future__ import annotations + +import argparse +import socket +import time + +DEFAULT_STRIPS = ( + # (strip_id, gpio) + (0, 18), + (1, 19), + (2, 20), + (3, 21), + (4, 22), + (5, 26), + (6, 27), + (7, 28), +) + +IDENTITY_COLOURS = ( + (255, 0, 0), # red + (0, 255, 0), # green + (0, 0, 255), # blue + (255, 255, 0), # yellow + (255, 0, 255), # magenta + (0, 255, 255), # cyan + (255, 128, 0), # orange + (255, 255, 255), # white +) + +ALL_COLOURS = ( + ("RED", (255, 0, 0)), + ("GREEN", (0, 255, 0)), + ("BLUE", (0, 0, 255)), + ("WHITE", (255, 255, 255)), +) + + +def pin_bind(sock: socket.socket, addr: tuple[str, int], strip: int, gpio: int, leds: int) -> None: + payload = bytes( + [ + ord("P"), + ord("I"), + ord("N"), + 0, + strip & 0xFF, + gpio & 0xFF, + leds & 0xFF, + (leds >> 8) & 0xFF, + ] + ) + sock.sendto(payload, addr) + + +def send_solid( + sock: socket.socket, + addr: tuple[str, int], + strip_id: int, + leds: int, + rgb: tuple[int, int, int], +) -> None: + sock.sendto(bytes([strip_id & 0xFF]) + bytes(rgb) * leds, addr) + + +def off_all(sock: socket.socket, addr: tuple[str, int], leds: int) -> None: + for strip, _gpio in DEFAULT_STRIPS: + send_solid(sock, addr, strip, leds, (0, 0, 0)) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Test all 8 led-driver-8 strips over UDP") + parser.add_argument("--host", default="10.1.1.10", help="Panel IP (default: 10.1.1.10)") + parser.add_argument("--port", type=int, default=50007, help="UDP RGB port") + parser.add_argument("--leds", type=int, default=64, help="LEDs lit per strip (capacity test)") + parser.add_argument("--pause", type=float, default=0.7, help="Seconds between steps") + parser.add_argument( + "--all-only", + action="store_true", + help="Skip per-strip identity; only flash all strips together", + ) + parser.add_argument("--no-bind", action="store_true", help="Skip PIN bind commands") + args = parser.parse_args() + + if args.leds < 1: + parser.error("--leds must be >= 1") + + addr = (args.host, args.port) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + print(f"Testing {len(DEFAULT_STRIPS)} strips @ {args.host}:{args.port} ({args.leds} LEDs each)") + + try: + if not args.no_bind: + for strip, gpio in DEFAULT_STRIPS: + pin_bind(sock, addr, strip, gpio, args.leds) + print(f" bind strip {strip} GP{gpio}") + time.sleep(0.02) + + if not args.all_only: + print(" per-strip identity colours") + for (strip, gpio), rgb in zip(DEFAULT_STRIPS, IDENTITY_COLOURS): + off_all(sock, addr, args.leds) + send_solid(sock, addr, strip, args.leds, rgb) + print(f" strip {strip} GP{gpio} -> {rgb}") + time.sleep(args.pause) + + print(" all strips together (strip_id=255)") + for label, rgb in ALL_COLOURS: + print(f" {label}") + send_solid(sock, addr, 255, args.leds, rgb) + time.sleep(args.pause) + + off_all(sock, addr, args.leds) + print("Done.") + except OSError as exc: + print(f"Network error: {exc}") + return 1 + finally: + sock.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt new file mode 100644 index 0000000..427f4d7 --- /dev/null +++ b/firmware/CMakeLists.txt @@ -0,0 +1,89 @@ +cmake_minimum_required(VERSION 3.13) + +set(PICO_BOARD pico CACHE STRING "Board type") + +include(pico_sdk_import.cmake) + +project(portal_panel C CXX ASM) +set(CMAKE_C_STANDARD 11) +set(CMAKE_CXX_STANDARD 17) + +pico_sdk_init() + +include(FetchContent) +FetchContent_Declare( + iolibrary + GIT_REPOSITORY https://github.com/Wiznet/ioLibrary_Driver.git + GIT_TAG master +) +FetchContent_MakeAvailable(iolibrary) + +set(IOLIB_DIR ${iolibrary_SOURCE_DIR}) + +add_executable(panel + main.c + wizchip_spi.c + timer.c + ws2812_led.cpp + WS2812.cpp + ${IOLIB_DIR}/Ethernet/socket.c + ${IOLIB_DIR}/Ethernet/wizchip_conf.c + ${IOLIB_DIR}/Ethernet/W5500/w5500.c + ${IOLIB_DIR}/Internet/DHCP/dhcp.c +) + +pico_generate_pio_header(panel ${CMAKE_CURRENT_LIST_DIR}/WS2812.pio) + +target_include_directories(panel PRIVATE + ${CMAKE_CURRENT_LIST_DIR} + ${IOLIB_DIR}/Ethernet + ${IOLIB_DIR}/Internet/DHCP +) + +target_compile_definitions(panel PRIVATE + _WIZCHIP_=W5500 +) + +if (DEFINED PANEL_ID_BUILD) + target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD}) +endif () + +if (DEFINED WS2812_PIN_BUILD) + target_compile_definitions(panel PRIVATE PIN_WS2812=${WS2812_PIN_BUILD}) +endif () + +if (DEFINED WS2812_PIN1_BUILD) + target_compile_definitions(panel PRIVATE PIN_WS2812_1=${WS2812_PIN1_BUILD}) +endif () + +if (DEFINED STATIC_IP_BUILD) + target_compile_definitions(panel PRIVATE "STATIC_IP={${STATIC_IP_BUILD}}") +endif () +if (DEFINED STATIC_SN_BUILD) + target_compile_definitions(panel PRIVATE "STATIC_SN={${STATIC_SN_BUILD}}") +endif () +if (DEFINED STATIC_GW_BUILD) + target_compile_definitions(panel PRIVATE "STATIC_GW={${STATIC_GW_BUILD}}") +endif () +if (DEFINED STATIC_DNS_BUILD) + target_compile_definitions(panel PRIVATE "STATIC_DNS={${STATIC_DNS_BUILD}}") +endif () + +option(PORTAL_USE_DHCP "Use DHCP for W5500" OFF) +if (PORTAL_USE_DHCP) + target_compile_definitions(panel PRIVATE USE_DHCP=1) +else () + target_compile_definitions(panel PRIVATE USE_DHCP=0) +endif () + +target_link_libraries(panel + pico_stdlib + hardware_spi + hardware_pio + hardware_dma +) + +pico_enable_stdio_usb(panel 1) +pico_enable_stdio_uart(panel 0) + +pico_add_extra_outputs(panel) diff --git a/firmware/Makefile b/firmware/Makefile new file mode 100644 index 0000000..f2b27a8 --- /dev/null +++ b/firmware/Makefile @@ -0,0 +1,147 @@ +# Portal panel firmware — build and USB deploy +# +# make # build only +# make deploy # build + flash over USB (picotool) +# +# Options (also via repo-root .env — see .env.example): +# make deploy PANEL_ID=0 +# make deploy NETWORK=dhcp +# make deploy IP=10.1.1.10 GATEWAY=10.1.1.1 +# make deploy WS2812_PIN=26 WS2812_PIN1=27 +# +# Environment: +# PICO_SDK_PATH default: ~/pico-sdk (fallback ~/pico/pico-sdk) + +MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +-include $(MAKEFILE_DIR)../.env +-include $(MAKEFILE_DIR).env + +ifeq ($(origin PICO_SDK_PATH), undefined) + ifneq ($(wildcard $(HOME)/pico-sdk/pico_sdk_init.cmake),) + PICO_SDK_PATH := $(HOME)/pico-sdk + else + PICO_SDK_PATH := $(HOME)/pico/pico-sdk + endif +endif + +BUILD_DIR := build +UF2 := $(BUILD_DIR)/panel.uf2 +CMAKE_STAMP := $(BUILD_DIR)/.cmake_stamp +JOBS ?= $(shell nproc 2>/dev/null || echo 4) +SERIAL_PORT ?= $(shell ls /dev/ttyACM* 2>/dev/null | head -1) +ifeq ($(SERIAL_PORT),) +SERIAL_PORT := /dev/ttyACM0 +endif +SERIAL_BAUD ?= 115200 +NETWORK ?= static + +comma := , +dot_to_csv = $(subst .,$(comma),$(1)) + +CMAKE_ARGS := +ifdef PANEL_ID +CMAKE_ARGS += -DPANEL_ID_BUILD=$(PANEL_ID) +endif +ifdef WS2812_PIN +CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN) +endif +ifdef WS2812_PIN1 +CMAKE_ARGS += -DWS2812_PIN1_BUILD=$(WS2812_PIN1) +endif +ifdef IP +CMAKE_ARGS += -DSTATIC_IP_BUILD=$(call dot_to_csv,$(IP)) +endif +ifdef NETMASK +CMAKE_ARGS += -DSTATIC_SN_BUILD=$(call dot_to_csv,$(NETMASK)) +endif +ifdef GATEWAY +CMAKE_ARGS += -DSTATIC_GW_BUILD=$(call dot_to_csv,$(GATEWAY)) +endif +ifdef DNS +CMAKE_ARGS += -DSTATIC_DNS_BUILD=$(call dot_to_csv,$(DNS)) +endif +ifeq ($(NETWORK),dhcp) +CMAKE_ARGS += -DPORTAL_USE_DHCP=ON +else ifeq ($(NO_DHCP),0) +CMAKE_ARGS += -DPORTAL_USE_DHCP=ON +else +CMAKE_ARGS += -DPORTAL_USE_DHCP=OFF +endif + +CMAKE_STAMP_BODY := $(strip $(CMAKE_ARGS)) + +.PHONY: all build configure reconfigure deploy flash upload reset monitor clean help + +all: build + +help: + @echo "Targets:" + @echo " make build Build panel.uf2" + @echo " make deploy Build and flash over USB" + @echo " make reset USB reboot Pico" + @echo " make monitor USB serial console" + @echo " make clean Remove build directory" + +build: configure + @echo "==> Building panel firmware" + # Host GCC 15 needs cstdint for pico-sdk pioasm + CXXFLAGS='-include cstdint' PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake --build $(BUILD_DIR) -j$(JOBS) + @test -f "$(UF2)" || (echo "Error: build did not produce panel.uf2" && exit 1) + @echo "==> Build OK: $(UF2)" + +configure: + @test -d "$(PICO_SDK_PATH)" || (echo "Error: pico-sdk not found at $(PICO_SDK_PATH)" && exit 1) + @command -v cmake >/dev/null || (echo "Error: cmake is required" && exit 1) + @mkdir -p $(BUILD_DIR) + @STAMP='$(CMAKE_STAMP_BODY)'; \ + if [ ! -f "$(BUILD_DIR)/CMakeCache.txt" ] || [ ! -f "$(CMAKE_STAMP)" ] || [ "$$(cat $(CMAKE_STAMP))" != "$$STAMP" ]; then \ + echo "==> Configuring cmake in $(BUILD_DIR) $(CMAKE_ARGS)"; \ + CXXFLAGS='-include cstdint' PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS); \ + echo "$$STAMP" > "$(CMAKE_STAMP)"; \ + fi + +reconfigure: + @mkdir -p $(BUILD_DIR) + CXXFLAGS='-include cstdint' PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS) + @echo '$(CMAKE_STAMP_BODY)' > $(CMAKE_STAMP) + +deploy: build + @command -v picotool >/dev/null || { echo "Error: picotool not found"; exit 1; } + @echo "==> Uploading $(UF2) over USB" + @picotool load -x -f "$(UF2)" + +flash: deploy +upload: deploy + +reset: + @command -v picotool >/dev/null || { echo "Error: picotool not found"; exit 1; } + @picotool reboot -f + @i=0; last=; \ + while [ $$i -lt 50 ]; do \ + cur=$$(ls /dev/ttyACM* 2>/dev/null | head -1); \ + if [ -n "$$cur" ] && [ "$$cur" = "$$last" ]; then \ + sleep 0.5; \ + if [ -e "$$cur" ]; then echo "==> Ready ($$cur)"; exit 0; fi; \ + fi; \ + last=$$cur; \ + i=$$((i + 1)); sleep 0.2; \ + done; \ + echo "==> Warning: serial not ready yet"; exit 0 + +monitor: + @command -v picocom >/dev/null || { echo "Error: picocom not found"; exit 1; } + @SERIAL=; i=0; \ + while [ $$i -lt 40 ]; do \ + for d in $(SERIAL_PORT) $$(ls /dev/ttyACM* 2>/dev/null); do \ + [ -n "$$d" ] && [ -e "$$d" ] || continue; \ + SERIAL=$$d; break; \ + done; \ + [ -n "$$SERIAL" ] && [ -e "$$SERIAL" ] && break; \ + i=$$((i + 1)); sleep 0.25; \ + done; \ + if [ -z "$$SERIAL" ] || [ ! -e "$$SERIAL" ]; then echo "Error: no serial port"; exit 1; fi; \ + echo "==> monitor $$SERIAL"; \ + exec picocom --baud $(SERIAL_BAUD) --flow n --echo --noreset "$$SERIAL" + +clean: + rm -rf $(BUILD_DIR) diff --git a/firmware/WS2812.cpp b/firmware/WS2812.cpp new file mode 100644 index 0000000..fa2b878 --- /dev/null +++ b/firmware/WS2812.cpp @@ -0,0 +1,170 @@ +/* WS2812 driver — from https://github.com/ForsakenNGS/Pico_WS2812 (BSD-style) + * Pixel push uses DMA → PIO TX FIFO (DREQ-paced). + */ + +#include "WS2812.hpp" + +#include "WS2812.pio.h" + +#include "hardware/clocks.h" +#include "hardware/dma.h" +#include "hardware/gpio.h" + +#include + +WS2812::WS2812(uint pin, uint length, PIO pio, uint sm) { + initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE); +} + +WS2812::WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format) { + switch (format) { + case FORMAT_RGB: + initialize(pin, length, pio, sm, NONE, RED, GREEN, BLUE); + break; + case FORMAT_GRB: + initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE); + break; + case FORMAT_WRGB: + initialize(pin, length, pio, sm, WHITE, RED, GREEN, BLUE); + break; + } +} + +WS2812::~WS2812() { + if (dma_chan >= 0) { + dma_channel_abort(dma_chan); + dma_channel_unclaim((uint)dma_chan); + dma_chan = -1; + } + delete[] data; +} + +void WS2812::initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2, + DataByte b3, DataByte b4) { + this->pin = pin; + this->length = length; + this->pio = pio; + this->sm = sm; + this->data = new uint32_t[length]; + this->bytes[0] = b1; + this->bytes[1] = b2; + this->bytes[2] = b3; + this->bytes[3] = b4; + this->bits = (b1 == NONE ? 24 : 32); + this->dma_chan = (int)dma_claim_unused_channel(true); + /* One copy of the program per PIO block — shared by all strips on that PIO. */ + static int pio0_offset = -1; + static int pio1_offset = -1; + int *cached = (pio == pio0) ? &pio0_offset : &pio1_offset; + if (*cached < 0) { + *cached = (int)pio_add_program(pio, &ws2812_program); + } + this->offset = (uint)*cached; + applyPinConfig(); +} + +void WS2812::applyPinConfig() { + pio_sm_set_enabled(pio, sm, false); + pio_gpio_init(pio, pin); + pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); + + pio_sm_config c = ws2812_program_get_default_config(offset); + sm_config_set_sideset_pins(&c, pin); + sm_config_set_out_shift(&c, false, true, bits); + sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); + + int cycles_per_bit = ws2812_T1 + ws2812_T2 + ws2812_T3; + float div = (float)clock_get_hz(clk_sys) / (800000.f * (float)cycles_per_bit); + sm_config_set_clkdiv(&c, div); + + pio_sm_init(pio, sm, offset, &c); + pio_sm_set_enabled(pio, sm, true); +} + +void WS2812::setPin(uint new_pin) { + if (new_pin == pin) { + return; + } + dma_wait(); + pio_sm_set_enabled(pio, sm, false); + gpio_set_function(pin, GPIO_FUNC_SIO); + gpio_set_dir(pin, GPIO_IN); + pin = new_pin; + applyPinConfig(); +} + +uint32_t WS2812::convertData(uint32_t rgbw) { + uint32_t result = 0; + for (uint b = 0; b < 4; b++) { + switch (bytes[b]) { + case RED: + result |= (rgbw & 0xFF); + break; + case GREEN: + result |= (rgbw & 0xFF00) >> 8; + break; + case BLUE: + result |= (rgbw & 0xFF0000) >> 16; + break; + case WHITE: + result |= (rgbw & 0xFF000000) >> 24; + break; + default: + break; + } + result <<= 8; + } + return result; +} + +void WS2812::setPixelColor(uint index, uint32_t color) { + if (index < length) { + data[index] = convertData(color); + } +} + +void WS2812::setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue) { + setPixelColor(index, RGB(red, green, blue)); +} + +void WS2812::fill(uint32_t color) { + color = convertData(color); + for (uint i = 0; i < length; i++) { + data[i] = color; + } +} + +void WS2812::dma_wait(void) { + if (dma_chan >= 0 && dma_channel_is_busy((uint)dma_chan)) { + dma_channel_wait_for_finish_blocking((uint)dma_chan); + } +} + +void WS2812::show() { + show(length); +} + +void WS2812::show(uint count) { + if (count > length) { + count = length; + } + if (count == 0 || dma_chan < 0 || data == nullptr) { + return; + } + + dma_wait(); + + dma_channel_config c = dma_channel_get_default_config((uint)dma_chan); + channel_config_set_transfer_data_size(&c, DMA_SIZE_32); + channel_config_set_read_increment(&c, true); + channel_config_set_write_increment(&c, false); + channel_config_set_dreq(&c, pio_get_dreq(pio, sm, true)); + + dma_channel_configure((uint)dma_chan, &c, + &pio->txf[sm], /* write address */ + data, /* read address */ + count, /* transfer count */ + true); /* start immediately */ + + dma_channel_wait_for_finish_blocking((uint)dma_chan); +} diff --git a/firmware/WS2812.hpp b/firmware/WS2812.hpp new file mode 100644 index 0000000..13cecb9 --- /dev/null +++ b/firmware/WS2812.hpp @@ -0,0 +1,46 @@ +#ifndef WS2812_H +#define WS2812_H + +#include "hardware/pio.h" +#include "pico/types.h" + +class WS2812 { + public: + enum DataByte { NONE = 0, RED = 1, GREEN = 2, BLUE = 3, WHITE = 4 }; + enum DataFormat { FORMAT_RGB = 0, FORMAT_GRB = 1, FORMAT_WRGB = 2 }; + + WS2812(uint pin, uint length, PIO pio, uint sm); + WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format); + ~WS2812(); + + static uint32_t RGB(uint8_t red, uint8_t green, uint8_t blue) { + return (uint32_t)(blue) << 16 | (uint32_t)(green) << 8 | (uint32_t)(red); + } + + void setPixelColor(uint index, uint32_t color); + void setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue); + void fill(uint32_t color); + void show(); + void show(uint count); + void setPin(uint pin); + uint getPin() const { return pin; } + + private: + uint pin; + uint length; + PIO pio; + uint sm; + uint offset; + uint bits; + int dma_chan; + DataByte bytes[4]; + uint32_t *data; + + void initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2, + DataByte b3, DataByte b4); + uint32_t convertData(uint32_t rgbw); + void applyPinConfig(); + void dma_wait(void); +}; + +#endif diff --git a/firmware/WS2812.pio b/firmware/WS2812.pio new file mode 100644 index 0000000..61c7927 --- /dev/null +++ b/firmware/WS2812.pio @@ -0,0 +1,44 @@ +; +; WS2812 PIO — from https://github.com/ForsakenNGS/Pico_WS2812 +; +.program ws2812 +.side_set 1 + +.define public T1 2 +.define public T2 5 +.define public T3 3 + +.lang_opt python sideset_init = pico.PIO.OUT_HIGH +.lang_opt python out_init = pico.PIO.OUT_HIGH +.lang_opt python out_shiftdir = 1 + +.wrap_target +bitloop: + out x, 1 side 0 [T3 - 1] + jmp !x send_zero side 1 [T1 - 1] +send_one: + jmp bitloop side 1 [T2 - 1] +send_zero: + nop side 0 [T2 - 1] +.wrap + +% c-sdk { +#include "hardware/clocks.h" + +static inline void ws2812_program_init(PIO pio, uint sm, uint offset, uint pin, float freq, uint bits) { + pio_gpio_init(pio, pin); + pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); + + pio_sm_config c = ws2812_program_get_default_config(offset); + sm_config_set_sideset_pins(&c, pin); + sm_config_set_out_shift(&c, false, true, bits); + sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); + + int cycles_per_bit = ws2812_T1 + ws2812_T2 + ws2812_T3; + float div = (float)clock_get_hz(clk_sys) / (freq * (float)cycles_per_bit); + sm_config_set_clkdiv(&c, div); + + pio_sm_init(pio, sm, offset, &c); + pio_sm_set_enabled(pio, sm, true); +} +%} diff --git a/firmware/board_config.h b/firmware/board_config.h new file mode 100644 index 0000000..00a2532 --- /dev/null +++ b/firmware/board_config.h @@ -0,0 +1,74 @@ +#ifndef BOARD_CONFIG_H +#define BOARD_CONFIG_H + +/* W5500 on SPI1; eight WS2812 strips (portal UDP protocol) */ + +#define PIN_LED_STATUS 25 + +#define SPI_PORT spi1 +#define SPI_CLK_MHZ 15 + +/* This adapter board: CS=13, RST=9 (some portal PCBs use CS=9 / RST=13) */ +#define PIN_CS 13 +#define PIN_SCK 10 +#define PIN_MOSI 11 +#define PIN_MISO 12 +#define PIN_RST 9 + +/* Strip 0 / 1 overridable; remaining pins fixed in DEFAULT_STRIP_PINS */ +#ifndef PIN_WS2812 +#define PIN_WS2812 18 +#endif +#ifndef PIN_WS2812_1 +#define PIN_WS2812_1 19 +#endif + +/* Strip / buffer capacity. Pixel count comes from each UDP frame length. */ +#ifndef MAX_LEDS +#define MAX_LEDS 512 +#endif + +#define LED_RGB_BYTES (MAX_LEDS * 3) + +/* Eight WS2812 outputs (one PIO SM each on RP2040). */ +#ifndef MAX_STRIPS +#define MAX_STRIPS 8 +#endif + +#define DEFAULT_STRIP_PINS \ + { PIN_WS2812, PIN_WS2812_1, 20, 21, 22, 26, 27, 28 } + +/* W5500 socket assignments */ +#define SOCKET_DHCP 0 +#define SOCKET_UDP 1 + +/* UDP port (same as CircuitPython adapter / portal) */ +#define UDP_PORT 50007 + +/* Compile-time panel index for filtered frames (0–4). 255 = accept all. */ +#ifndef PANEL_ID +#define PANEL_ID 255 +#endif + +#ifndef USE_DHCP +#define USE_DHCP 0 +#endif + +#ifndef STATIC_IP +#define STATIC_IP_OCT4 ((PANEL_ID) == 255 ? 19u : (10u + (unsigned)(PANEL_ID))) +#define STATIC_IP {10, 1, 1, (uint8_t)STATIC_IP_OCT4} +#endif +#ifndef STATIC_SN +#define STATIC_SN {255, 255, 255, 0} +#endif +#ifndef STATIC_GW +#define STATIC_GW {10, 1, 1, 1} +#endif +#ifndef STATIC_DNS +#define STATIC_DNS {10, 1, 1, 1} +#endif + +/* Locally administered; last byte = PANEL_ID (unique per panel 0–4). */ +#define MAC_ADDR {0x02, 0x50, 0x52, 0x54, 0x4C, (uint8_t)PANEL_ID} + +#endif diff --git a/firmware/main.c b/firmware/main.c new file mode 100644 index 0000000..7ef0d3d --- /dev/null +++ b/firmware/main.c @@ -0,0 +1,365 @@ +/** + * Portal panel firmware — W5500 UDP + WS2812 (Pico SDK). + * + * UDP protocol (port 50007): + * - N×3 bytes: raw RGB → strip 0 (N = 1…MAX_LEDS) + * - 1 + N×3 bytes: strip_id (byte 0) + RGB + * strip_id 0…MAX_STRIPS-1 → that strip + * strip_id 255 → all active strips + * - 5 bytes "PIN\\0" + gpio: bind strip 0 to gpio + * - 8 bytes "PIN\\0" + strip + gpio + len_lo + len_hi: bind strip (len=0 → unset) + * - 4 bytes "SHOW": reserved (frames already show on receive) + * + * One Pico can drive up to MAX_STRIPS displays on separate GPIOs. + * Each strip’s pixel count comes from its own UDP frame length (strips may differ). + * MAX_LEDS is per-strip capacity. + */ + +#include +#include +#include + +#include "board_config.h" +#include "wizchip_conf.h" +#include "dhcp.h" +#include "socket.h" +#include "timer.h" +#include "wizchip_spi.h" +#include "ws2812_led.h" + +#include "hardware/gpio.h" +#include "pico/stdlib.h" + +#define SOCKET_UDP 1 +#define SOCKET_DHCP 0 +#define ETH_BUF_SIZE 2048 +#define DHCP_RETRY_MAX 10 + +static wiz_NetInfo g_net_info = { + .mac = MAC_ADDR, + .ip = STATIC_IP, + .sn = STATIC_SN, + .gw = STATIC_GW, + .dns = STATIC_DNS, +#if USE_DHCP + .dhcp = NETINFO_DHCP, +#else + .dhcp = NETINFO_STATIC, +#endif +}; + +static uint8_t g_eth_buf[ETH_BUF_SIZE]; +static uint8_t g_pixel_buf[MAX_STRIPS][LED_RGB_BYTES]; +static uint16_t g_frame_pixels[MAX_STRIPS]; +static uint16_t g_strip_leds[MAX_STRIPS]; +static uint8_t g_frame_dirty[MAX_STRIPS]; +static volatile uint16_t g_ms_tick; +static uint8_t g_dhcp_ready; + +static void dhcp_timer_cb(void) { + g_ms_tick++; + if (g_ms_tick >= 999) { + g_ms_tick = 0; + DHCP_time_handler(); + } +} + +static void dhcp_assign(void) { + getIPfromDHCP(g_net_info.ip); + getGWfromDHCP(g_net_info.gw); + getSNfromDHCP(g_net_info.sn); + getDNSfromDHCP(g_net_info.dns); + g_net_info.dhcp = NETINFO_DHCP; + network_initialize(g_net_info); + print_network_information(g_net_info); + g_dhcp_ready = 1; +} + +static void dhcp_conflict(void) { + printf("DHCP conflict\n"); +} + +static int network_bring_up(void) { +#if USE_DHCP + uint8_t retries = 0; + DHCP_init(SOCKET_DHCP, g_eth_buf); + reg_dhcp_cbfunc(dhcp_assign, dhcp_assign, dhcp_conflict); + wizchip_1ms_timer_initialize(dhcp_timer_cb); + + while (!g_dhcp_ready && retries < DHCP_RETRY_MAX) { + int8_t rv = DHCP_run(); + if (rv == DHCP_IP_LEASED) { + g_dhcp_ready = 1; + break; + } + if (rv == DHCP_FAILED) { + retries++; + } + wizchip_delay_ms(250); + } + if (!g_dhcp_ready) { + printf("DHCP failed, using static IP\n"); + g_net_info.dhcp = NETINFO_STATIC; + network_initialize(g_net_info); + print_network_information(g_net_info); + } +#else + network_initialize(g_net_info); + print_network_information(g_net_info); +#endif + return 0; +} + +static int udp_socket_open(void) { + int8_t sn = socket(SOCKET_UDP, Sn_MR_UDP, UDP_PORT, 0); + if (sn != SOCKET_UDP) { + printf("UDP socket open failed: %d\n", sn); + return -1; + } + printf("UDP listening on port %d\n", UDP_PORT); + return 0; +} + +static void led_boot_test(unsigned int strip) { + if (!ws2812_strip_active(strip)) { + return; + } + uint8_t one[3] = {255, 0, 0}; + ws2812_set_rgb(strip, one, 1); + ws2812_show(strip); + sleep_ms(150); + one[0] = 0; + one[1] = 255; + ws2812_set_rgb(strip, one, 1); + ws2812_show(strip); + sleep_ms(150); + one[1] = 0; + one[2] = 255; + ws2812_set_rgb(strip, one, 1); + ws2812_show(strip); + sleep_ms(150); + one[2] = 0; + ws2812_set_rgb(strip, one, 1); + ws2812_show(strip); +} + +static int buffer_frame(unsigned int strip, const uint8_t *rgb, uint16_t nbytes) { + if (strip >= MAX_STRIPS || !ws2812_strip_active(strip)) { + return -1; + } + if (nbytes == 0 || (nbytes % 3u) != 0) { + return -1; + } + uint16_t pixels = nbytes / 3u; + if (pixels > MAX_LEDS) { + pixels = MAX_LEDS; + nbytes = (uint16_t)(pixels * 3u); + } + memcpy(g_pixel_buf[strip], rgb, nbytes); + if (nbytes < LED_RGB_BYTES) { + memset(g_pixel_buf[strip] + nbytes, 0, LED_RGB_BYTES - nbytes); + } + g_frame_pixels[strip] = pixels; + g_frame_dirty[strip] = 1; + return 0; +} + +static void buffer_frame_all(const uint8_t *rgb, uint16_t nbytes) { + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (ws2812_strip_active(s)) { + buffer_frame(s, rgb, nbytes); + } + } +} + +static void show_dirty_frames(void) { + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (!g_frame_dirty[s]) { + continue; + } + ws2812_set_rgb(s, g_pixel_buf[s], g_frame_pixels[s]); + ws2812_show(s); + g_frame_dirty[s] = 0; + } +} + +static int pin_is_reserved(uint8_t pin) { + switch (pin) { + case PIN_CS: + case PIN_SCK: + case PIN_MOSI: + case PIN_MISO: + case PIN_RST: + case PIN_LED_STATUS: + return 1; + default: + return pin > 29; + } +} + +static void handle_pin_command(uint8_t strip, uint8_t pin, uint16_t leds) { + if (strip >= MAX_STRIPS) { + printf("PIN strip %u out of range (max %u)\n", (unsigned)strip, MAX_STRIPS); + return; + } + if (pin_is_reserved(pin)) { + printf("PIN GP%u rejected (reserved)\n", (unsigned)pin); + return; + } + if (leds > MAX_LEDS) { + printf("PIN leds %u clamped to max_leds=%u\n", (unsigned)leds, MAX_LEDS); + leds = MAX_LEDS; + } + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (s != strip && ws2812_strip_active(s) && ws2812_get_pin(s) == pin) { + printf("PIN GP%u: releasing strip %u\n", (unsigned)pin, s); + ws2812_release_strip(s); + g_strip_leds[s] = 0; + g_frame_pixels[s] = 0; + g_frame_dirty[s] = 0; + } + } + if (ws2812_set_pin(strip, pin) != 0) { + printf("PIN strip %u GP%u failed (no PIO SM?)\n", (unsigned)strip, (unsigned)pin); + return; + } + g_strip_leds[strip] = leds; + if (leds) { + printf("strip %u ws2812=GP%u leds=%u\n", (unsigned)strip, + (unsigned)ws2812_get_pin(strip), (unsigned)leds); + } else { + printf("strip %u ws2812=GP%u\n", (unsigned)strip, (unsigned)ws2812_get_pin(strip)); + } +} + +static void handle_packet(const uint8_t *data, int16_t len) { + if (len == 4 && memcmp(data, "SHOW", 4) == 0) { + return; + } + + if (len == 5 && data[0] == 'P' && data[1] == 'I' && data[2] == 'N' && data[3] == 0) { + handle_pin_command(0, data[4], 0); + return; + } + + if (len == 8 && data[0] == 'P' && data[1] == 'I' && data[2] == 'N' && data[3] == 0) { + uint16_t leds = (uint16_t)data[6] | ((uint16_t)data[7] << 8); + handle_pin_command(data[4], data[5], leds); + return; + } + + if (len >= 3 && (len % 3) == 0) { + buffer_frame(0, data, (uint16_t)len); + return; + } + + if (len >= 4 && ((len - 1) % 3) == 0) { + uint8_t id = data[0]; + uint16_t nbytes = (uint16_t)(len - 1); + if (id == 255) { + buffer_frame_all(data + 1, nbytes); + return; + } + if (id < MAX_STRIPS) { + buffer_frame(id, data + 1, nbytes); + return; + } + if (id == (uint8_t)PANEL_ID) { + buffer_frame(0, data + 1, nbytes); + return; + } + } +} + +static void poll_udp(void) { + while (1) { + uint16_t rx = getSn_RX_RSR(SOCKET_UDP); + if (rx == 0) { + return; + } + if (rx > ETH_BUF_SIZE) { + rx = ETH_BUF_SIZE; + } + + uint8_t src_ip[4]; + uint16_t src_port; + int16_t n = recvfrom(SOCKET_UDP, g_eth_buf, rx, src_ip, &src_port); + if (n <= 0) { + return; + } + handle_packet(g_eth_buf, n); + } +} + +static void print_strip_map(void) { + printf("strips=%d", MAX_STRIPS); + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (ws2812_strip_active(s)) { + if (g_strip_leds[s]) { + printf(" [%u]=GP%u/%u", s, ws2812_get_pin(s), g_strip_leds[s]); + } else { + printf(" [%u]=GP%u", s, ws2812_get_pin(s)); + } + } + } + printf("\n"); +} + +int main(void) { + stdio_init_all(); + sleep_ms(2000); + + gpio_init(PIN_LED_STATUS); + gpio_set_dir(PIN_LED_STATUS, GPIO_OUT); + + printf("portal panel firmware (Pico SDK)\n"); + printf("max_leds=%d max_strips=%d panel_id=%d mac=02:50:52:54:4C:%02X " + "ip=%u.%u.%u.%u %s\n", + MAX_LEDS, MAX_STRIPS, PANEL_ID, (unsigned)PANEL_ID, g_net_info.ip[0], + g_net_info.ip[1], g_net_info.ip[2], g_net_info.ip[3], + USE_DHCP ? "dhcp" : "static"); + + wizchip_spi_initialize(); + wizchip_cris_initialize(); + wizchip_reset(); + wizchip_initialize(); + wizchip_check(); + + ws2812_init(MAX_LEDS); + memset(g_pixel_buf, 0, sizeof(g_pixel_buf)); + memset(g_frame_pixels, 0, sizeof(g_frame_pixels)); + memset(g_strip_leds, 0, sizeof(g_strip_leds)); + memset(g_frame_dirty, 0, sizeof(g_frame_dirty)); + + static const unsigned int default_pins[MAX_STRIPS] = DEFAULT_STRIP_PINS; + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (ws2812_set_pin(s, default_pins[s]) == 0) { + led_boot_test(s); + } else { + printf("default strip %u GP%u failed\n", s, default_pins[s]); + } + } + print_strip_map(); + + network_bring_up(); + if (udp_socket_open() != 0) { + while (1) { + gpio_xor_mask(1u << PIN_LED_STATUS); + sleep_ms(100); + } + } + + uint32_t heartbeat = 0; + while (1) { +#if USE_DHCP + DHCP_run(); +#endif + poll_udp(); + show_dirty_frames(); + if (++heartbeat >= 500) { + gpio_xor_mask(1u << PIN_LED_STATUS); + heartbeat = 0; + } + sleep_ms(1); + } +} diff --git a/firmware/pico_sdk_import.cmake b/firmware/pico_sdk_import.cmake new file mode 100644 index 0000000..613e9aa --- /dev/null +++ b/firmware/pico_sdk_import.cmake @@ -0,0 +1,54 @@ +# This can be dropped into any project as a standalone CMake file +# SPDX-License-Identifier: BSD-3-Clause + +if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH)) + set(PICO_SDK_PATH $ENV{PICO_SDK_PATH}) +endif () + +if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT)) + set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT}) +endif () + +if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH)) + set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH}) +endif () + +set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK") +set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch Pico SDK from git") +set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "Location to download Pico SDK") + +if (NOT PICO_SDK_PATH) + if (PICO_SDK_FETCH_FROM_GIT) + include(FetchContent) + set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR}) + if (PICO_SDK_FETCH_FROM_GIT_PATH) + get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH) + endif () + FetchContent_Declare( + pico_sdk + GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk + GIT_TAG master + ) + if (NOT pico_sdk) + FetchContent_Populate(pico_sdk) + set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR}) + endif () + set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE}) + else () + message(FATAL_ERROR + "PICO_SDK_PATH is not set. Clone pico-sdk and export PICO_SDK_PATH, " + "or set PICO_SDK_FETCH_FROM_GIT=ON.") + endif () +endif () + +get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}") +if (NOT EXISTS ${PICO_SDK_PATH}) + message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found") +endif () + +set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake) +if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE}) + message(FATAL_ERROR "pico_sdk_init.cmake not found in ${PICO_SDK_PATH}") +endif () + +include(${PICO_SDK_INIT_CMAKE_FILE}) diff --git a/firmware/timer.c b/firmware/timer.c new file mode 100644 index 0000000..d90d0af --- /dev/null +++ b/firmware/timer.c @@ -0,0 +1,21 @@ +#include "timer.h" + +static struct repeating_timer g_timer; +static void (*g_callback)(void); + +void wizchip_1ms_timer_initialize(void (*callback)(void)) { + g_callback = callback; + add_repeating_timer_us(-1000, wizchip_1ms_timer_callback, NULL, &g_timer); +} + +bool wizchip_1ms_timer_callback(struct repeating_timer *t) { + (void)t; + if (g_callback != NULL) { + g_callback(); + } + return true; +} + +void wizchip_delay_ms(uint32_t ms) { + sleep_ms(ms); +} diff --git a/firmware/timer.h b/firmware/timer.h new file mode 100644 index 0000000..590a84e --- /dev/null +++ b/firmware/timer.h @@ -0,0 +1,10 @@ +#ifndef PANEL_TIMER_H +#define PANEL_TIMER_H + +#include "pico/stdlib.h" + +void wizchip_1ms_timer_initialize(void (*callback)(void)); +bool wizchip_1ms_timer_callback(struct repeating_timer *t); +void wizchip_delay_ms(uint32_t ms); + +#endif diff --git a/firmware/wizchip_spi.c b/firmware/wizchip_spi.c new file mode 100644 index 0000000..29bbf50 --- /dev/null +++ b/firmware/wizchip_spi.c @@ -0,0 +1,121 @@ +/** + * W5500 SPI port for portal panel adapter (SPI1, GP9–13). + * Derived from WIZnet-PICO-C port/ioLibrary_Driver (BSD-3-Clause). + */ + +#include + +#include "board_config.h" +#include "wizchip_conf.h" +#include "wizchip_spi.h" + +#include "hardware/gpio.h" +#include "hardware/spi.h" +#include "pico/binary_info.h" +#include "pico/critical_section.h" +#include "pico/stdlib.h" + +static critical_section_t g_wizchip_cri_sec; + +static inline void wizchip_select(void) { + gpio_put(PIN_CS, 0); +} + +static inline void wizchip_deselect(void) { + gpio_put(PIN_CS, 1); +} + +static uint8_t wizchip_read(void) { + uint8_t rx = 0; + uint8_t tx = 0xff; + spi_read_blocking(SPI_PORT, tx, &rx, 1); + return rx; +} + +static void wizchip_write(uint8_t tx) { + spi_write_blocking(SPI_PORT, &tx, 1); +} + +static void wizchip_critical_section_lock(void) { + critical_section_enter_blocking(&g_wizchip_cri_sec); +} + +static void wizchip_critical_section_unlock(void) { + critical_section_exit(&g_wizchip_cri_sec); +} + +void wizchip_reset(void) { + gpio_init(PIN_RST); + gpio_set_dir(PIN_RST, GPIO_OUT); + gpio_put(PIN_RST, 0); + sleep_ms(100); + gpio_put(PIN_RST, 1); + sleep_ms(100); + bi_decl(bi_1pin_with_name(PIN_RST, "W5500 RESET")); +} + +void wizchip_spi_initialize(void) { + spi_init(SPI_PORT, SPI_CLK_MHZ * 1000 * 1000); + + gpio_set_function(PIN_SCK, GPIO_FUNC_SPI); + gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI); + gpio_set_function(PIN_MISO, GPIO_FUNC_SPI); + + bi_decl(bi_3pins_with_func(PIN_MISO, PIN_MOSI, PIN_SCK, GPIO_FUNC_SPI)); + + gpio_init(PIN_CS); + gpio_set_dir(PIN_CS, GPIO_OUT); + gpio_put(PIN_CS, 1); + bi_decl(bi_1pin_with_name(PIN_CS, "W5500 CS")); +} + +void wizchip_cris_initialize(void) { + critical_section_init(&g_wizchip_cri_sec); + reg_wizchip_cris_cbfunc(wizchip_critical_section_lock, wizchip_critical_section_unlock); +} + +void wizchip_initialize(void) { + wizchip_deselect(); + reg_wizchip_cs_cbfunc(wizchip_select, wizchip_deselect); + reg_wizchip_spi_cbfunc(wizchip_read, wizchip_write); + + uint8_t memsize[2][8] = { + {2, 2, 2, 2, 2, 2, 2, 2}, + {2, 2, 2, 2, 2, 2, 2, 2}, + }; + + if (ctlwizchip(CW_INIT_WIZCHIP, (void *)memsize) == -1) { + printf("W5500 init failed\n"); + return; + } + + uint8_t link = PHY_LINK_OFF; + do { + if (ctlwizchip(CW_GET_PHYLINK, (void *)&link) == -1) { + printf("PHY link unknown\n"); + return; + } + } while (link == PHY_LINK_OFF); +} + +void wizchip_check(void) { + if (getVERSIONR() != 0x04) { + printf("W5500 version mismatch: 0x%02x\n", getVERSIONR()); + while (1) { + tight_loop_contents(); + } + } +} + +void network_initialize(wiz_NetInfo net_info) { + ctlnetwork(CN_SET_NETINFO, (void *)&net_info); +} + +void print_network_information(wiz_NetInfo net_info) { + ctlnetwork(CN_GET_NETINFO, (void *)&net_info); + printf("MAC %02X:%02X:%02X:%02X:%02X:%02X\n", + net_info.mac[0], net_info.mac[1], net_info.mac[2], + net_info.mac[3], net_info.mac[4], net_info.mac[5]); + printf("IP %d.%d.%d.%d\n", + net_info.ip[0], net_info.ip[1], net_info.ip[2], net_info.ip[3]); +} diff --git a/firmware/wizchip_spi.h b/firmware/wizchip_spi.h new file mode 100644 index 0000000..0112539 --- /dev/null +++ b/firmware/wizchip_spi.h @@ -0,0 +1,14 @@ +#ifndef WIZCHIP_SPI_H +#define WIZCHIP_SPI_H + +#include "wizchip_conf.h" + +void wizchip_spi_initialize(void); +void wizchip_cris_initialize(void); +void wizchip_reset(void); +void wizchip_initialize(void); +void wizchip_check(void); +void network_initialize(wiz_NetInfo net_info); +void print_network_information(wiz_NetInfo net_info); + +#endif diff --git a/firmware/ws2812_led.cpp b/firmware/ws2812_led.cpp new file mode 100644 index 0000000..245d09a --- /dev/null +++ b/firmware/ws2812_led.cpp @@ -0,0 +1,176 @@ +/* Multi-strip WS2812 wrapper around ForsakenNGS/Pico_WS2812 */ + +#include "ws2812_led.h" + +#include "board_config.h" +#include "WS2812.hpp" + +#include "hardware/pio.h" +#include "pico/stdlib.h" + +typedef struct { + WS2812 *strip; + PIO pio; + uint sm; + unsigned int pin; + unsigned int lit; + unsigned int show_n; + uint8_t active; +} strip_slot_t; + +static strip_slot_t s_slots[MAX_STRIPS]; +static unsigned int s_max_leds; + +static int claim_sm(PIO *pio_out, uint *sm_out) { + int sm = pio_claim_unused_sm(pio0, false); + if (sm >= 0) { + *pio_out = pio0; + *sm_out = (uint)sm; + return 0; + } + sm = pio_claim_unused_sm(pio1, false); + if (sm >= 0) { + *pio_out = pio1; + *sm_out = (uint)sm; + return 0; + } + return -1; +} + +extern "C" void ws2812_init(unsigned int max_leds) { + s_max_leds = max_leds; + for (unsigned int i = 0; i < MAX_STRIPS; i++) { + s_slots[i].strip = nullptr; + s_slots[i].pin = 0; + s_slots[i].lit = 0; + s_slots[i].show_n = 0; + s_slots[i].active = 0; + } +} + +extern "C" void ws2812_release_strip(unsigned int strip) { + if (strip >= MAX_STRIPS) { + return; + } + strip_slot_t *slot = &s_slots[strip]; + if (!slot->active) { + return; + } + pio_sm_set_enabled(slot->pio, slot->sm, false); + gpio_set_function(slot->pin, GPIO_FUNC_SIO); + gpio_set_dir(slot->pin, GPIO_IN); + delete slot->strip; + slot->strip = nullptr; + pio_sm_unclaim(slot->pio, slot->sm); + slot->pin = 0; + slot->lit = 0; + slot->show_n = 0; + slot->active = 0; +} + +extern "C" int ws2812_set_pin(unsigned int strip, unsigned int pin) { + if (strip >= MAX_STRIPS || pin > 29) { + return -1; + } + strip_slot_t *slot = &s_slots[strip]; + if (slot->active && slot->pin == pin) { + return 0; + } + /* Steal pin from any other strip that already owns it. */ + for (unsigned int s = 0; s < MAX_STRIPS; s++) { + if (s != strip && s_slots[s].active && s_slots[s].pin == pin) { + ws2812_release_strip(s); + } + } + if (slot->active) { + /* Tear down and rebuild on the same PIO SM — more reliable than setPin alone. */ + PIO pio = slot->pio; + uint sm = slot->sm; + unsigned int old_pin = slot->pin; + pio_sm_set_enabled(pio, sm, false); + gpio_set_function(old_pin, GPIO_FUNC_SIO); + gpio_set_dir(old_pin, GPIO_IN); + delete slot->strip; + slot->strip = new WS2812(pin, s_max_leds, pio, sm, WS2812::FORMAT_GRB); + slot->pin = pin; + slot->lit = 0; + slot->show_n = 0; + return 0; + } + PIO pio; + uint sm; + if (claim_sm(&pio, &sm) != 0) { + return -1; + } + slot->strip = new WS2812(pin, s_max_leds, pio, sm, WS2812::FORMAT_GRB); + slot->pio = pio; + slot->sm = sm; + slot->pin = pin; + slot->lit = 0; + slot->show_n = 0; + slot->active = 1; + return 0; +} + +extern "C" unsigned int ws2812_get_pin(unsigned int strip) { + if (strip >= MAX_STRIPS || !s_slots[strip].active) { + return 0; + } + return s_slots[strip].pin; +} + +extern "C" int ws2812_strip_active(unsigned int strip) { + return strip < MAX_STRIPS && s_slots[strip].active; +} + +extern "C" void ws2812_set_rgb(unsigned int strip, const uint8_t *rgb, unsigned int count) { + if (strip >= MAX_STRIPS || !s_slots[strip].active || rgb == nullptr) { + return; + } + strip_slot_t *slot = &s_slots[strip]; + if (count > s_max_leds) { + count = s_max_leds; + } + unsigned int old = slot->lit; + unsigned int i = 0; + for (; i < count; i++) { + slot->strip->setPixelColor(i, rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]); + } + for (; i < old; i++) { + slot->strip->setPixelColor(i, 0, 0, 0); + } + slot->lit = count; + slot->show_n = count > old ? count : old; +} + +extern "C" void ws2812_fill(unsigned int strip, uint8_t r, uint8_t g, uint8_t b) { + if (strip >= MAX_STRIPS || !s_slots[strip].active) { + return; + } + strip_slot_t *slot = &s_slots[strip]; + slot->strip->fill(WS2812::RGB(r, g, b)); + slot->lit = s_max_leds; + slot->show_n = s_max_leds; +} + +extern "C" void ws2812_show(unsigned int strip) { + if (strip >= MAX_STRIPS || !s_slots[strip].active) { + return; + } + strip_slot_t *slot = &s_slots[strip]; + unsigned int n = slot->show_n; + if (n == 0) { + n = 1; + } + slot->strip->show(n); + sleep_us(300); + slot->show_n = slot->lit; +} + +extern "C" void ws2812_show_all(void) { + for (unsigned int i = 0; i < MAX_STRIPS; i++) { + if (s_slots[i].active) { + ws2812_show(i); + } + } +} diff --git a/firmware/ws2812_led.h b/firmware/ws2812_led.h new file mode 100644 index 0000000..7fd5659 --- /dev/null +++ b/firmware/ws2812_led.h @@ -0,0 +1,33 @@ +#ifndef WS2812_LED_H +#define WS2812_LED_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef MAX_STRIPS +#define MAX_STRIPS 8 +#endif + +/* Create strip slots (none active until ws2812_set_pin). max_leds = capacity each. */ +void ws2812_init(unsigned int max_leds); + +/* Bind strip to gpio (creates/rebinds PIO SM). strip = 0 … MAX_STRIPS-1. */ +int ws2812_set_pin(unsigned int strip, unsigned int pin); +/* Free PIO SM + GPIO so another strip can take the pin. */ +void ws2812_release_strip(unsigned int strip); +unsigned int ws2812_get_pin(unsigned int strip); +int ws2812_strip_active(unsigned int strip); + +void ws2812_set_rgb(unsigned int strip, const uint8_t *rgb, unsigned int count); +void ws2812_fill(unsigned int strip, uint8_t r, uint8_t g, uint8_t b); +void ws2812_show(unsigned int strip); +void ws2812_show_all(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/install.sh b/install.sh deleted file mode 100755 index ce3a525..0000000 --- a/install.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Install script - runs pipenv install - -pipenv install "$@" diff --git a/lib/microdot/__init__.py b/lib/microdot/__init__.py deleted file mode 100644 index 68cb381..0000000 --- a/lib/microdot/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from microdot.microdot import Microdot, Request, Response, abort, redirect, \ - send_file # noqa: F401 \ No newline at end of file diff --git a/lib/microdot/helpers.py b/lib/microdot/helpers.py deleted file mode 100644 index 664e58c..0000000 --- a/lib/microdot/helpers.py +++ /dev/null @@ -1,8 +0,0 @@ -try: - from functools import wraps -except ImportError: # pragma: no cover - # MicroPython does not currently implement functools.wraps - def wraps(wrapped): - def _(wrapper): - return wrapper - return _ diff --git a/lib/microdot/microdot.py b/lib/microdot/microdot.py deleted file mode 100644 index 0513f21..0000000 --- a/lib/microdot/microdot.py +++ /dev/null @@ -1,1450 +0,0 @@ -""" -microdot --------- - -The ``microdot`` module defines a few classes that help implement HTTP-based -servers for MicroPython and standard Python. -""" -import asyncio -import io -import json -import time - -try: - from inspect import iscoroutinefunction, iscoroutine - from functools import partial - - async def invoke_handler(handler, *args, **kwargs): - """Invoke a handler and return the result. - - This method runs sync handlers in a thread pool executor. - """ - if iscoroutinefunction(handler): - ret = await handler(*args, **kwargs) - else: - ret = await asyncio.get_running_loop().run_in_executor( - None, partial(handler, *args, **kwargs)) - return ret -except ImportError: # pragma: no cover - def iscoroutine(coro): - return hasattr(coro, 'send') and hasattr(coro, 'throw') - - async def invoke_handler(handler, *args, **kwargs): - """Invoke a handler and return the result. - - This method runs sync handlers in the asyncio thread, which can - potentially cause blocking and performance issues. - """ - ret = handler(*args, **kwargs) - if iscoroutine(ret): - ret = await ret - return ret - -try: - from sys import print_exception -except ImportError: # pragma: no cover - import traceback - - def print_exception(exc): - traceback.print_exc() - -MUTED_SOCKET_ERRORS = [ - 32, # Broken pipe - 54, # Connection reset by peer - 104, # Connection reset by peer - 128, # Operation on closed socket -] - - -def urldecode_str(s): - s = s.replace('+', ' ') - parts = s.split('%') - if len(parts) == 1: - return s - result = [parts[0]] - for item in parts[1:]: - if item == '': - result.append('%') - else: - code = item[:2] - result.append(chr(int(code, 16))) - result.append(item[2:]) - return ''.join(result) - - -def urldecode_bytes(s): - s = s.replace(b'+', b' ') - parts = s.split(b'%') - if len(parts) == 1: - return s.decode() - result = [parts[0]] - for item in parts[1:]: - if item == b'': - result.append(b'%') - else: - code = item[:2] - result.append(bytes([int(code, 16)])) - result.append(item[2:]) - return b''.join(result).decode() - - -def urlencode(s): - return s.replace('+', '%2B').replace(' ', '+').replace( - '%', '%25').replace('?', '%3F').replace('#', '%23').replace( - '&', '%26').replace('=', '%3D') - - -class NoCaseDict(dict): - """A subclass of dictionary that holds case-insensitive keys. - - :param initial_dict: an initial dictionary of key/value pairs to - initialize this object with. - - Example:: - - >>> d = NoCaseDict() - >>> d['Content-Type'] = 'text/html' - >>> print(d['Content-Type']) - text/html - >>> print(d['content-type']) - text/html - >>> print(d['CONTENT-TYPE']) - text/html - >>> del d['cOnTeNt-TyPe'] - >>> print(d) - {} - """ - def __init__(self, initial_dict=None): - super().__init__(initial_dict or {}) - self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} - - def __setitem__(self, key, value): - kl = key.lower() - key = self.keymap.get(kl, key) - if kl != key: - self.keymap[kl] = key - super().__setitem__(key, value) - - def __getitem__(self, key): - kl = key.lower() - return super().__getitem__(self.keymap.get(kl, kl)) - - def __delitem__(self, key): - kl = key.lower() - super().__delitem__(self.keymap.get(kl, kl)) - - def __contains__(self, key): - kl = key.lower() - return self.keymap.get(kl, kl) in self.keys() - - def get(self, key, default=None): - kl = key.lower() - return super().get(self.keymap.get(kl, kl), default) - - def update(self, other_dict): - for key, value in other_dict.items(): - self[key] = value - - -def mro(cls): # pragma: no cover - """Return the method resolution order of a class. - - This is a helper function that returns the method resolution order of a - class. It is used by Microdot to find the best error handler to invoke for - the raised exception. - - In CPython, this function returns the ``__mro__`` attribute of the class. - In MicroPython, this function implements a recursive depth-first scanning - of the class hierarchy. - """ - if hasattr(cls, 'mro'): - return cls.__mro__ - - def _mro(cls): - m = [cls] - for base in cls.__bases__: - m += _mro(base) - return m - - mro_list = _mro(cls) - - # If a class appears multiple times (due to multiple inheritance) remove - # all but the last occurence. This matches the method resolution order - # of MicroPython, but not CPython. - mro_pruned = [] - for i in range(len(mro_list)): - base = mro_list.pop(0) - if base not in mro_list: - mro_pruned.append(base) - return mro_pruned - - -class MultiDict(dict): - """A subclass of dictionary that can hold multiple values for the same - key. It is used to hold key/value pairs decoded from query strings and - form submissions. - - :param initial_dict: an initial dictionary of key/value pairs to - initialize this object with. - - Example:: - - >>> d = MultiDict() - >>> d['sort'] = 'name' - >>> d['sort'] = 'email' - >>> print(d['sort']) - 'name' - >>> print(d.getlist('sort')) - ['name', 'email'] - """ - def __init__(self, initial_dict=None): - super().__init__() - if initial_dict: - for key, value in initial_dict.items(): - self[key] = value - - def __setitem__(self, key, value): - if key not in self: - super().__setitem__(key, []) - super().__getitem__(key).append(value) - - def __getitem__(self, key): - return super().__getitem__(key)[0] - - def get(self, key, default=None, type=None): - """Return the value for a given key. - - :param key: The key to retrieve. - :param default: A default value to use if the key does not exist. - :param type: A type conversion callable to apply to the value. - - If the multidict contains more than one value for the requested key, - this method returns the first value only. - - Example:: - - >>> d = MultiDict() - >>> d['age'] = '42' - >>> d.get('age') - '42' - >>> d.get('age', type=int) - 42 - >>> d.get('name', default='noname') - 'noname' - """ - if key not in self: - return default - value = self[key] - if type is not None: - value = type(value) - return value - - def getlist(self, key, type=None): - """Return all the values for a given key. - - :param key: The key to retrieve. - :param type: A type conversion callable to apply to the values. - - If the requested key does not exist in the dictionary, this method - returns an empty list. - - Example:: - - >>> d = MultiDict() - >>> d.getlist('items') - [] - >>> d['items'] = '3' - >>> d.getlist('items') - ['3'] - >>> d['items'] = '56' - >>> d.getlist('items') - ['3', '56'] - >>> d.getlist('items', type=int) - [3, 56] - """ - if key not in self: - return [] - values = super().__getitem__(key) - if type is not None: - values = [type(value) for value in values] - return values - - -class AsyncBytesIO: - """An async wrapper for BytesIO.""" - def __init__(self, data): - self.stream = io.BytesIO(data) - - async def read(self, n=-1): - return self.stream.read(n) - - async def readline(self): # pragma: no cover - return self.stream.readline() - - async def readexactly(self, n): # pragma: no cover - return self.stream.read(n) - - async def readuntil(self, separator=b'\n'): # pragma: no cover - return self.stream.readuntil(separator=separator) - - async def awrite(self, data): # pragma: no cover - return self.stream.write(data) - - async def aclose(self): # pragma: no cover - pass - - -class Request: - """An HTTP request.""" - #: Specify the maximum payload size that is accepted. Requests with larger - #: payloads will be rejected with a 413 status code. Applications can - #: change this maximum as necessary. - #: - #: Example:: - #: - #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed - max_content_length = 16 * 1024 - - #: Specify the maximum payload size that can be stored in ``body``. - #: Requests with payloads that are larger than this size and up to - #: ``max_content_length`` bytes will be accepted, but the application will - #: only be able to access the body of the request by reading from - #: ``stream``. Set to 0 if you always access the body as a stream. - #: - #: Example:: - #: - #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read - max_body_length = 16 * 1024 - - #: Specify the maximum length allowed for a line in the request. Requests - #: with longer lines will not be correctly interpreted. Applications can - #: change this maximum as necessary. - #: - #: Example:: - #: - #: Request.max_readline = 16 * 1024 # 16KB lines allowed - max_readline = 2 * 1024 - - class G: - pass - - def __init__(self, app, client_addr, method, url, http_version, headers, - body=None, stream=None, sock=None): - #: The application instance to which this request belongs. - self.app = app - #: The address of the client, as a tuple (host, port). - self.client_addr = client_addr - #: The HTTP method of the request. - self.method = method - #: The request URL, including the path and query string. - self.url = url - #: The path portion of the URL. - self.path = url - #: The query string portion of the URL. - self.query_string = None - #: The parsed query string, as a - #: :class:`MultiDict ` object. - self.args = {} - #: A dictionary with the headers included in the request. - self.headers = headers - #: A dictionary with the cookies included in the request. - self.cookies = {} - #: The parsed ``Content-Length`` header. - self.content_length = 0 - #: The parsed ``Content-Type`` header. - self.content_type = None - #: A general purpose container for applications to store data during - #: the life of the request. - self.g = Request.G() - - self.http_version = http_version - if '?' in self.path: - self.path, self.query_string = self.path.split('?', 1) - self.args = self._parse_urlencoded(self.query_string) - - if 'Content-Length' in self.headers: - self.content_length = int(self.headers['Content-Length']) - if 'Content-Type' in self.headers: - self.content_type = self.headers['Content-Type'] - if 'Cookie' in self.headers: - for cookie in self.headers['Cookie'].split(';'): - name, value = cookie.strip().split('=', 1) - self.cookies[name] = value - - self._body = body - self.body_used = False - self._stream = stream - self.sock = sock - self._json = None - self._form = None - self.after_request_handlers = [] - - @staticmethod - async def create(app, client_reader, client_writer, client_addr): - """Create a request object. - - :param app: The Microdot application instance. - :param client_reader: An input stream from where the request data can - be read. - :param client_writer: An output stream where the response data can be - written. - :param client_addr: The address of the client, as a tuple. - - This method is a coroutine. It returns a newly created ``Request`` - object. - """ - # request line - line = (await Request._safe_readline(client_reader)).strip().decode() - if not line: # pragma: no cover - return None - method, url, http_version = line.split() - http_version = http_version.split('/', 1)[1] - - # headers - headers = NoCaseDict() - content_length = 0 - while True: - line = (await Request._safe_readline( - client_reader)).strip().decode() - if line == '': - break - header, value = line.split(':', 1) - value = value.strip() - headers[header] = value - if header.lower() == 'content-length': - content_length = int(value) - - # body - body = b'' - if content_length and content_length <= Request.max_body_length: - body = await client_reader.readexactly(content_length) - stream = None - else: - body = b'' - stream = client_reader - - return Request(app, client_addr, method, url, http_version, headers, - body=body, stream=stream, - sock=(client_reader, client_writer)) - - def _parse_urlencoded(self, urlencoded): - data = MultiDict() - if len(urlencoded) > 0: # pragma: no branch - if isinstance(urlencoded, str): - for kv in [pair.split('=', 1) - for pair in urlencoded.split('&') if pair]: - data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \ - if len(kv) > 1 else '' - elif isinstance(urlencoded, bytes): # pragma: no branch - for kv in [pair.split(b'=', 1) - for pair in urlencoded.split(b'&') if pair]: - data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \ - if len(kv) > 1 else b'' - return data - - @property - def body(self): - """The body of the request, as bytes.""" - return self._body - - @property - def stream(self): - """The body of the request, as a bytes stream.""" - if self._stream is None: - self._stream = AsyncBytesIO(self._body) - return self._stream - - @property - def json(self): - """The parsed JSON body, or ``None`` if the request does not have a - JSON body.""" - if self._json is None: - if self.content_type is None: - return None - mime_type = self.content_type.split(';')[0] - if mime_type != 'application/json': - return None - self._json = json.loads(self.body.decode()) - return self._json - - @property - def form(self): - """The parsed form submission body, as a - :class:`MultiDict ` object, or ``None`` if the - request does not have a form submission.""" - if self._form is None: - if self.content_type is None: - return None - mime_type = self.content_type.split(';')[0] - if mime_type != 'application/x-www-form-urlencoded': - return None - self._form = self._parse_urlencoded(self.body) - return self._form - - def after_request(self, f): - """Register a request-specific function to run after the request is - handled. Request-specific after request handlers run at the very end, - after the application's own after request handlers. The function must - take two arguments, the request and response objects. The return value - of the function must be the updated response object. - - Example:: - - @app.route('/') - def index(request): - # register a request-specific after request handler - @req.after_request - def func(request, response): - # ... - return response - - return 'Hello, World!' - - Note that the function is not called if the request handler raises an - exception and an error response is returned instead. - """ - self.after_request_handlers.append(f) - return f - - @staticmethod - async def _safe_readline(stream): - line = (await stream.readline()) - if len(line) > Request.max_readline: - raise ValueError('line too long') - return line - - -class Response: - """An HTTP response class. - - :param body: The body of the response. If a dictionary or list is given, - a JSON formatter is used to generate the body. If a file-like - object or an async generator is given, a streaming response is - used. If a string is given, it is encoded from UTF-8. Else, - the body should be a byte sequence. - :param status_code: The numeric HTTP status code of the response. The - default is 200. - :param headers: A dictionary of headers to include in the response. - :param reason: A custom reason phrase to add after the status code. The - default is "OK" for responses with a 200 status code and - "N/A" for any other status codes. - """ - types_map = { - 'css': 'text/css', - 'gif': 'image/gif', - 'html': 'text/html', - 'jpg': 'image/jpeg', - 'js': 'application/javascript', - 'json': 'application/json', - 'png': 'image/png', - 'txt': 'text/plain', - } - - send_file_buffer_size = 1024 - - #: The content type to use for responses that do not explicitly define a - #: ``Content-Type`` header. - default_content_type = 'text/plain' - - #: The default cache control max age used by :meth:`send_file`. A value - #: of ``None`` means that no ``Cache-Control`` header is added. - default_send_file_max_age = None - - #: Special response used to signal that a response does not need to be - #: written to the client. Used to exit WebSocket connections cleanly. - already_handled = None - - def __init__(self, body='', status_code=200, headers=None, reason=None): - if body is None and status_code == 200: - body = '' - status_code = 204 - self.status_code = status_code - self.headers = NoCaseDict(headers or {}) - self.reason = reason - if isinstance(body, (dict, list)): - self.body = json.dumps(body).encode() - self.headers['Content-Type'] = 'application/json; charset=UTF-8' - elif isinstance(body, str): - self.body = body.encode() - else: - # this applies to bytes, file-like objects or generators - self.body = body - self.is_head = False - - def set_cookie(self, cookie, value, path=None, domain=None, expires=None, - max_age=None, secure=False, http_only=False, - partitioned=False): - """Add a cookie to the response. - - :param cookie: The cookie's name. - :param value: The cookie's value. - :param path: The cookie's path. - :param domain: The cookie's domain. - :param expires: The cookie expiration time, as a ``datetime`` object - or a correctly formatted string. - :param max_age: The cookie's ``Max-Age`` value. - :param secure: The cookie's ``secure`` flag. - :param http_only: The cookie's ``HttpOnly`` flag. - :param partitioned: Whether the cookie is partitioned. - """ - http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) - if path: - http_cookie += '; Path=' + path - if domain: - http_cookie += '; Domain=' + domain - if expires: - if isinstance(expires, str): - http_cookie += '; Expires=' + expires - else: # pragma: no cover - http_cookie += '; Expires=' + time.strftime( - '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) - if max_age is not None: - http_cookie += '; Max-Age=' + str(max_age) - if secure: - http_cookie += '; Secure' - if http_only: - http_cookie += '; HttpOnly' - if partitioned: - http_cookie += '; Partitioned' - if 'Set-Cookie' in self.headers: - self.headers['Set-Cookie'].append(http_cookie) - else: - self.headers['Set-Cookie'] = [http_cookie] - - def delete_cookie(self, cookie, **kwargs): - """Delete a cookie. - - :param cookie: The cookie's name. - :param kwargs: Any cookie opens and flags supported by - ``set_cookie()`` except ``expires`` and ``max_age``. - """ - self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', - max_age=0, **kwargs) - - def complete(self): - if isinstance(self.body, bytes) and \ - 'Content-Length' not in self.headers: - self.headers['Content-Length'] = str(len(self.body)) - if 'Content-Type' not in self.headers: - self.headers['Content-Type'] = self.default_content_type - if 'charset=' not in self.headers['Content-Type']: - self.headers['Content-Type'] += '; charset=UTF-8' - - async def write(self, stream): - self.complete() - - try: - # status code - reason = self.reason if self.reason is not None else \ - ('OK' if self.status_code == 200 else 'N/A') - await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( - status_code=self.status_code, reason=reason).encode()) - - # headers - for header, value in self.headers.items(): - values = value if isinstance(value, list) else [value] - for value in values: - await stream.awrite('{header}: {value}\r\n'.format( - header=header, value=value).encode()) - await stream.awrite(b'\r\n') - - # body - if not self.is_head: - iter = self.body_iter() - async for body in iter: - if isinstance(body, str): # pragma: no cover - body = body.encode() - try: - await stream.awrite(body) - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS or \ - exc.args[0] == 'Connection lost': - if hasattr(iter, 'aclose'): - await iter.aclose() - raise - if hasattr(iter, 'aclose'): # pragma: no branch - await iter.aclose() - - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS or \ - exc.args[0] == 'Connection lost': - pass - else: - raise - - def body_iter(self): - if hasattr(self.body, '__anext__'): - # response body is an async generator - return self.body - - response = self - - class iter: - ITER_UNKNOWN = 0 - ITER_SYNC_GEN = 1 - ITER_FILE_OBJ = 2 - ITER_NO_BODY = -1 - - def __aiter__(self): - if response.body: - self.i = self.ITER_UNKNOWN # need to determine type - else: - self.i = self.ITER_NO_BODY - return self - - async def __anext__(self): - if self.i == self.ITER_NO_BODY: - await self.aclose() - raise StopAsyncIteration - if self.i == self.ITER_UNKNOWN: - if hasattr(response.body, 'read'): - self.i = self.ITER_FILE_OBJ - elif hasattr(response.body, '__next__'): - self.i = self.ITER_SYNC_GEN - return next(response.body) - else: - self.i = self.ITER_NO_BODY - return response.body - elif self.i == self.ITER_SYNC_GEN: - try: - return next(response.body) - except StopIteration: - await self.aclose() - raise StopAsyncIteration - buf = response.body.read(response.send_file_buffer_size) - if iscoroutine(buf): # pragma: no cover - buf = await buf - if len(buf) < response.send_file_buffer_size: - self.i = self.ITER_NO_BODY - return buf - - async def aclose(self): - if hasattr(response.body, 'close'): - result = response.body.close() - if iscoroutine(result): # pragma: no cover - await result - - return iter() - - @classmethod - def redirect(cls, location, status_code=302): - """Return a redirect response. - - :param location: The URL to redirect to. - :param status_code: The 3xx status code to use for the redirect. The - default is 302. - """ - if '\x0d' in location or '\x0a' in location: - raise ValueError('invalid redirect URL') - return cls(status_code=status_code, headers={'Location': location}) - - @classmethod - def send_file(cls, filename, status_code=200, content_type=None, - stream=None, max_age=None, compressed=False, - file_extension=''): - """Send file contents in a response. - - :param filename: The filename of the file. - :param status_code: The 3xx status code to use for the redirect. The - default is 302. - :param content_type: The ``Content-Type`` header to use in the - response. If omitted, it is generated - automatically from the file extension of the - ``filename`` parameter. - :param stream: A file-like object to read the file contents from. If - a stream is given, the ``filename`` parameter is only - used when generating the ``Content-Type`` header. - :param max_age: The ``Cache-Control`` header's ``max-age`` value in - seconds. If omitted, the value of the - :attr:`Response.default_send_file_max_age` attribute is - used. - :param compressed: Whether the file is compressed. If ``True``, the - ``Content-Encoding`` header is set to ``gzip``. A - string with the header value can also be passed. - Note that when using this option the file must have - been compressed beforehand. This option only sets - the header. - :param file_extension: A file extension to append to the ``filename`` - parameter when opening the file, including the - dot. The extension given here is not considered - when generating the ``Content-Type`` header. - - Security note: The filename is assumed to be trusted. Never pass - filenames provided by the user without validating and sanitizing them - first. - """ - if content_type is None: - if compressed and filename.endswith('.gz'): - ext = filename[:-3].split('.')[-1] - else: - ext = filename.split('.')[-1] - if ext in Response.types_map: - content_type = Response.types_map[ext] - else: - content_type = 'application/octet-stream' - headers = {'Content-Type': content_type} - - if max_age is None: - max_age = cls.default_send_file_max_age - if max_age is not None: - headers['Cache-Control'] = 'max-age={}'.format(max_age) - - if compressed: - headers['Content-Encoding'] = compressed \ - if isinstance(compressed, str) else 'gzip' - - f = stream or open(filename + file_extension, 'rb') - return cls(body=f, status_code=status_code, headers=headers) - - -class URLPattern(): - def __init__(self, url_pattern): - self.url_pattern = url_pattern - self.segments = [] - self.regex = None - pattern = '' - use_regex = False - for segment in url_pattern.lstrip('/').split('/'): - if segment and segment[0] == '<': - if segment[-1] != '>': - raise ValueError('invalid URL pattern') - segment = segment[1:-1] - if ':' in segment: - type_, name = segment.rsplit(':', 1) - else: - type_ = 'string' - name = segment - parser = None - if type_ == 'string': - parser = self._string_segment - pattern += '/([^/]+)' - elif type_ == 'int': - parser = self._int_segment - pattern += '/(-?\\d+)' - elif type_ == 'path': - use_regex = True - pattern += '/(.+)' - elif type_.startswith('re:'): - use_regex = True - pattern += '/({pattern})'.format(pattern=type_[3:]) - else: - raise ValueError('invalid URL segment type') - self.segments.append({'parser': parser, 'name': name, - 'type': type_}) - else: - pattern += '/' + segment - self.segments.append({'parser': self._static_segment(segment)}) - if use_regex: - import re - self.regex = re.compile('^' + pattern + '$') - - def match(self, path): - args = {} - if self.regex: - g = self.regex.match(path) - if not g: - return - i = 1 - for segment in self.segments: - if 'name' not in segment: - continue - value = g.group(i) - if segment['type'] == 'int': - value = int(value) - args[segment['name']] = value - i += 1 - else: - if len(path) == 0 or path[0] != '/': - return - path = path[1:] - args = {} - for segment in self.segments: - if path is None: - return - arg, path = segment['parser'](path) - if arg is None: - return - if 'name' in segment: - args[segment['name']] = arg - if path is not None: - return - return args - - def _static_segment(self, segment): - def _static(value): - s = value.split('/', 1) - if s[0] == segment: - return '', s[1] if len(s) > 1 else None - return None, None - return _static - - def _string_segment(self, value): - s = value.split('/', 1) - if len(s[0]) == 0: - return None, None - return s[0], s[1] if len(s) > 1 else None - - def _int_segment(self, value): - s = value.split('/', 1) - try: - return int(s[0]), s[1] if len(s) > 1 else None - except ValueError: - return None, None - - -class HTTPException(Exception): - def __init__(self, status_code, reason=None): - self.status_code = status_code - self.reason = reason or str(status_code) + ' error' - - def __repr__(self): # pragma: no cover - return 'HTTPException: {}'.format(self.status_code) - - -class Microdot: - """An HTTP application class. - - This class implements an HTTP application instance and is heavily - influenced by the ``Flask`` class of the Flask framework. It is typically - declared near the start of the main application script. - - Example:: - - from microdot import Microdot - - app = Microdot() - """ - - def __init__(self): - self.url_map = [] - self.before_request_handlers = [] - self.after_request_handlers = [] - self.after_error_request_handlers = [] - self.error_handlers = {} - self.shutdown_requested = False - self.options_handler = self.default_options_handler - self.debug = False - self.server = None - - def route(self, url_pattern, methods=None): - """Decorator that is used to register a function as a request handler - for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - :param methods: The list of HTTP methods to be handled by the - decorated function. If omitted, only ``GET`` requests - are handled. - - The URL pattern can be a static path (for example, ``/users`` or - ``/api/invoices/search``) or a path with dynamic components enclosed - in ``<`` and ``>`` (for example, ``/users/`` or - ``/invoices//products``). Dynamic path components can also - include a type prefix, separated from the name with a colon (for - example, ``/users/``). The type can be ``string`` (the - default), ``int``, ``path`` or ``re:[regular-expression]``. - - The first argument of the decorated function must be - the request object. Any path arguments that are specified in the URL - pattern are passed as keyword arguments. The return value of the - function must be a :class:`Response` instance, or the arguments to - be passed to this class. - - Example:: - - @app.route('/') - def index(request): - return 'Hello, world!' - """ - def decorated(f): - self.url_map.append( - ([m.upper() for m in (methods or ['GET'])], - URLPattern(url_pattern), f)) - return f - return decorated - - def get(self, url_pattern): - """Decorator that is used to register a function as a ``GET`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['GET']``. - - Example:: - - @app.get('/users/') - def get_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['GET']) - - def post(self, url_pattern): - """Decorator that is used to register a function as a ``POST`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the``route`` decorator with - ``methods=['POST']``. - - Example:: - - @app.post('/users') - def create_user(request): - # ... - """ - return self.route(url_pattern, methods=['POST']) - - def put(self, url_pattern): - """Decorator that is used to register a function as a ``PUT`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['PUT']``. - - Example:: - - @app.put('/users/') - def edit_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['PUT']) - - def patch(self, url_pattern): - """Decorator that is used to register a function as a ``PATCH`` request - handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['PATCH']``. - - Example:: - - @app.patch('/users/') - def edit_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['PATCH']) - - def delete(self, url_pattern): - """Decorator that is used to register a function as a ``DELETE`` - request handler for a given URL. - - :param url_pattern: The URL pattern that will be compared against - incoming requests. - - This decorator can be used as an alias to the ``route`` decorator with - ``methods=['DELETE']``. - - Example:: - - @app.delete('/users/') - def delete_user(request, id): - # ... - """ - return self.route(url_pattern, methods=['DELETE']) - - def before_request(self, f): - """Decorator to register a function to run before each request is - handled. The decorated function must take a single argument, the - request object. - - Example:: - - @app.before_request - def func(request): - # ... - """ - self.before_request_handlers.append(f) - return f - - def after_request(self, f): - """Decorator to register a function to run after each request is - handled. The decorated function must take two arguments, the request - and response objects. The return value of the function must be an - updated response object. - - Example:: - - @app.after_request - def func(request, response): - # ... - return response - """ - self.after_request_handlers.append(f) - return f - - def after_error_request(self, f): - """Decorator to register a function to run after an error response is - generated. The decorated function must take two arguments, the request - and response objects. The return value of the function must be an - updated response object. The handler is invoked for error responses - generated by Microdot, as well as those returned by application-defined - error handlers. - - Example:: - - @app.after_error_request - def func(request, response): - # ... - return response - """ - self.after_error_request_handlers.append(f) - return f - - def errorhandler(self, status_code_or_exception_class): - """Decorator to register a function as an error handler. Error handler - functions for numeric HTTP status codes must accept a single argument, - the request object. Error handler functions for Python exceptions - must accept two arguments, the request object and the exception - object. - - :param status_code_or_exception_class: The numeric HTTP status code or - Python exception class to - handle. - - Examples:: - - @app.errorhandler(404) - def not_found(request): - return 'Not found' - - @app.errorhandler(RuntimeError) - def runtime_error(request, exception): - return 'Runtime error' - """ - def decorated(f): - self.error_handlers[status_code_or_exception_class] = f - return f - return decorated - - def mount(self, subapp, url_prefix=''): - """Mount a sub-application, optionally under the given URL prefix. - - :param subapp: The sub-application to mount. - :param url_prefix: The URL prefix to mount the application under. - """ - for methods, pattern, handler in subapp.url_map: - self.url_map.append( - (methods, URLPattern(url_prefix + pattern.url_pattern), - handler)) - for handler in subapp.before_request_handlers: - self.before_request_handlers.append(handler) - for handler in subapp.after_request_handlers: - self.after_request_handlers.append(handler) - for handler in subapp.after_error_request_handlers: - self.after_error_request_handlers.append(handler) - for status_code, handler in subapp.error_handlers.items(): - self.error_handlers[status_code] = handler - - @staticmethod - def abort(status_code, reason=None): - """Abort the current request and return an error response with the - given status code. - - :param status_code: The numeric status code of the response. - :param reason: The reason for the response, which is included in the - response body. - - Example:: - - from microdot import abort - - @app.route('/users/') - def get_user(id): - user = get_user_by_id(id) - if user is None: - abort(404) - return user.to_dict() - """ - raise HTTPException(status_code, reason) - - async def start_server(self, host='0.0.0.0', port=5000, debug=False, - ssl=None): - """Start the Microdot web server as a coroutine. This coroutine does - not normally return, as the server enters an endless listening loop. - The :func:`shutdown` function provides a method for terminating the - server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - This method is a coroutine. - - Example:: - - import asyncio - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - async def main(): - await app.start_server(debug=True) - - asyncio.run(main()) - """ - self.debug = debug - - async def serve(reader, writer): - if not hasattr(writer, 'awrite'): # pragma: no cover - # CPython provides the awrite and aclose methods in 3.8+ - async def awrite(self, data): - self.write(data) - await self.drain() - - async def aclose(self): - self.close() - await self.wait_closed() - - from types import MethodType - writer.awrite = MethodType(awrite, writer) - writer.aclose = MethodType(aclose, writer) - - await self.handle_request(reader, writer) - - if self.debug: # pragma: no cover - print('Starting async server on {host}:{port}...'.format( - host=host, port=port)) - - try: - self.server = await asyncio.start_server(serve, host, port, - ssl=ssl) - except TypeError: # pragma: no cover - self.server = await asyncio.start_server(serve, host, port) - - while True: - try: - if hasattr(self.server, 'serve_forever'): # pragma: no cover - try: - await self.server.serve_forever() - except asyncio.CancelledError: - pass - await self.server.wait_closed() - break - except AttributeError: # pragma: no cover - # the task hasn't been initialized in the server object yet - # wait a bit and try again - await asyncio.sleep(0.1) - - def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): - """Start the web server. This function does not normally return, as - the server enters an endless listening loop. The :func:`shutdown` - function provides a method for terminating the server gracefully. - - :param host: The hostname or IP address of the network interface that - will be listening for requests. A value of ``'0.0.0.0'`` - (the default) indicates that the server should listen for - requests on all the available interfaces, and a value of - ``127.0.0.1`` indicates that the server should listen - for requests only on the internal networking interface of - the host. - :param port: The port number to listen for requests. The default is - port 5000. - :param debug: If ``True``, the server logs debugging information. The - default is ``False``. - :param ssl: An ``SSLContext`` instance or ``None`` if the server should - not use TLS. The default is ``None``. - - Example:: - - from microdot import Microdot - - app = Microdot() - - @app.route('/') - async def index(request): - return 'Hello, world!' - - app.run(debug=True) - """ - asyncio.run(self.start_server(host=host, port=port, debug=debug, - ssl=ssl)) # pragma: no cover - - def shutdown(self): - """Request a server shutdown. The server will then exit its request - listening loop and the :func:`run` function will return. This function - can be safely called from a route handler, as it only schedules the - server to terminate as soon as the request completes. - - Example:: - - @app.route('/shutdown') - def shutdown(request): - request.app.shutdown() - return 'The server is shutting down...' - """ - self.server.close() - - def find_route(self, req): - method = req.method.upper() - if method == 'OPTIONS' and self.options_handler: - return self.options_handler(req) - if method == 'HEAD': - method = 'GET' - f = 404 - for route_methods, route_pattern, route_handler in self.url_map: - req.url_args = route_pattern.match(req.path) - if req.url_args is not None: - if method in route_methods: - f = route_handler - break - else: - f = 405 - return f - - def default_options_handler(self, req): - allow = [] - for route_methods, route_pattern, route_handler in self.url_map: - if route_pattern.match(req.path) is not None: - allow.extend(route_methods) - if 'GET' in allow: - allow.append('HEAD') - allow.append('OPTIONS') - return {'Allow': ', '.join(allow)} - - async def handle_request(self, reader, writer): - req = None - try: - req = await Request.create(self, reader, writer, - writer.get_extra_info('peername')) - except Exception as exc: # pragma: no cover - print_exception(exc) - - res = await self.dispatch_request(req) - if res != Response.already_handled: # pragma: no branch - await res.write(writer) - try: - await writer.aclose() - except OSError as exc: # pragma: no cover - if exc.errno in MUTED_SOCKET_ERRORS: - pass - else: - raise - if self.debug and req: # pragma: no cover - print('{method} {path} {status_code}'.format( - method=req.method, path=req.path, - status_code=res.status_code)) - - async def dispatch_request(self, req): - after_request_handled = False - if req: - if req.content_length > req.max_content_length: - if 413 in self.error_handlers: - res = await invoke_handler(self.error_handlers[413], req) - else: - res = 'Payload too large', 413 - else: - f = self.find_route(req) - try: - res = None - if callable(f): - for handler in self.before_request_handlers: - res = await invoke_handler(handler, req) - if res: - break - if res is None: - res = await invoke_handler( - f, req, **req.url_args) - if isinstance(res, int): - res = '', res - if isinstance(res, tuple): - if isinstance(res[0], int): - res = ('', res[0], - res[1] if len(res) > 1 else {}) - body = res[0] - if isinstance(res[1], int): - status_code = res[1] - headers = res[2] if len(res) > 2 else {} - else: - status_code = 200 - headers = res[1] - res = Response(body, status_code, headers) - elif not isinstance(res, Response): - res = Response(res) - for handler in self.after_request_handlers: - res = await invoke_handler( - handler, req, res) or res - for handler in req.after_request_handlers: - res = await invoke_handler( - handler, req, res) or res - after_request_handled = True - elif isinstance(f, dict): - res = Response(headers=f) - elif f in self.error_handlers: - res = await invoke_handler(self.error_handlers[f], req) - else: - res = 'Not found', f - except HTTPException as exc: - if exc.status_code in self.error_handlers: - res = self.error_handlers[exc.status_code](req) - else: - res = exc.reason, exc.status_code - except Exception as exc: - print_exception(exc) - exc_class = None - res = None - if exc.__class__ in self.error_handlers: - exc_class = exc.__class__ - else: - for c in mro(exc.__class__)[1:]: - if c in self.error_handlers: - exc_class = c - break - if exc_class: - try: - res = await invoke_handler( - self.error_handlers[exc_class], req, exc) - except Exception as exc2: # pragma: no cover - print_exception(exc2) - if res is None: - if 500 in self.error_handlers: - res = await invoke_handler( - self.error_handlers[500], req) - else: - res = 'Internal server error', 500 - else: - if 400 in self.error_handlers: - res = await invoke_handler(self.error_handlers[400], req) - else: - res = 'Bad request', 400 - if isinstance(res, tuple): - res = Response(*res) - elif not isinstance(res, Response): - res = Response(res) - if not after_request_handled: - for handler in self.after_error_request_handlers: - res = await invoke_handler( - handler, req, res) or res - res.is_head = (req and req.method == 'HEAD') - return res - - -Response.already_handled = Response() - -abort = Microdot.abort -redirect = Response.redirect -send_file = Response.send_file \ No newline at end of file diff --git a/lib/microdot/session.py b/lib/microdot/session.py deleted file mode 100644 index 78ce2e6..0000000 --- a/lib/microdot/session.py +++ /dev/null @@ -1,225 +0,0 @@ -try: - import jwt - HAS_JWT = True -except ImportError: - HAS_JWT = False - try: - import ubinascii - except ImportError: - import binascii as ubinascii - try: - import uhashlib as hashlib - except ImportError: - import hashlib - try: - import uhmac as hmac - except ImportError: - try: - import hmac - except ImportError: - hmac = None - import json - -from microdot.microdot import invoke_handler -from microdot.helpers import wraps - - -class SessionDict(dict): - """A session dictionary. - - The session dictionary is a standard Python dictionary that has been - extended with convenience ``save()`` and ``delete()`` methods. - """ - def __init__(self, request, session_dict): - super().__init__(session_dict) - self.request = request - - def save(self): - """Update the session cookie.""" - self.request.app._session.update(self.request, self) - - def delete(self): - """Delete the session cookie.""" - self.request.app._session.delete(self.request) - - -class Session: - """Session handling - - :param app: The application instance. - :param secret_key: The secret key, as a string or bytes object. - :param cookie_options: A dictionary with cookie options to pass as - arguments to :meth:`Response.set_cookie() - `. - """ - secret_key = None - - def __init__(self, app=None, secret_key=None, cookie_options=None): - self.secret_key = secret_key - self.cookie_options = cookie_options or {} - if app is not None: - self.initialize(app) - - def initialize(self, app, secret_key=None, cookie_options=None): - if secret_key is not None: - self.secret_key = secret_key - if cookie_options is not None: - self.cookie_options = cookie_options - if 'path' not in self.cookie_options: - self.cookie_options['path'] = '/' - if 'http_only' not in self.cookie_options: - self.cookie_options['http_only'] = True - app._session = self - - def get(self, request): - """Retrieve the user session. - - :param request: The client request. - - The return value is a session dictionary with the data stored in the - user's session, or ``{}`` if the session data is not available or - invalid. - """ - if not self.secret_key: - raise ValueError('The session secret key is not configured') - if hasattr(request.g, '_session'): - return request.g._session - session = request.cookies.get('session') - if session is None: - request.g._session = SessionDict(request, {}) - return request.g._session - request.g._session = SessionDict(request, self.decode(session)) - return request.g._session - - def update(self, request, session): - """Update the user session. - - :param request: The client request. - :param session: A dictionary with the update session data for the user. - - Applications would normally not call this method directly, instead they - would use the :meth:`SessionDict.save` method on the session - dictionary, which calls this method. For example:: - - @app.route('/') - @with_session - def index(request, session): - session['foo'] = 'bar' - session.save() - return 'Hello, World!' - - Calling this method adds a cookie with the updated session to the - request currently being processed. - """ - if not self.secret_key: - raise ValueError('The session secret key is not configured') - - encoded_session = self.encode(session) - - @request.after_request - def _update_session(request, response): - response.set_cookie('session', encoded_session, - **self.cookie_options) - return response - - def delete(self, request): - """Remove the user session. - - :param request: The client request. - - Applications would normally not call this method directly, instead they - would use the :meth:`SessionDict.delete` method on the session - dictionary, which calls this method. For example:: - - @app.route('/') - @with_session - def index(request, session): - session.delete() - return 'Hello, World!' - - Calling this method adds a cookie removal header to the request - currently being processed. - """ - @request.after_request - def _delete_session(request, response): - response.delete_cookie('session', **self.cookie_options) - return response - - def encode(self, payload, secret_key=None): - """Encode session data using JWT if available, otherwise use simple HMAC.""" - if HAS_JWT: - return jwt.encode(payload, secret_key or self.secret_key, - algorithm='HS256') - else: - # Simple encoding for MicroPython: base64(json) + HMAC signature - key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key) - payload_json = json.dumps(payload) - payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip() - - # Create HMAC signature - if hmac: - # Use hmac module if available - h = hmac.new(key, payload_json.encode(), hashlib.sha256) - else: - # Fallback: simple SHA256(key + message) - h = hashlib.sha256(key + payload_json.encode()) - signature = ubinascii.b2a_base64(h.digest()).decode().strip() - - return f"{payload_b64}.{signature}" - - def decode(self, session, secret_key=None): - """Decode session data using JWT if available, otherwise use simple HMAC.""" - if HAS_JWT: - try: - payload = jwt.decode(session, secret_key or self.secret_key, - algorithms=['HS256']) - except jwt.exceptions.PyJWTError: # pragma: no cover - return {} - return payload - else: - try: - # Simple decoding for MicroPython - if '.' not in session: - return {} - - payload_b64, signature = session.rsplit('.', 1) - payload_json = ubinascii.a2b_base64(payload_b64).decode() - - # Verify HMAC signature - key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key) - if hmac: - # Use hmac module if available - h = hmac.new(key, payload_json.encode(), hashlib.sha256) - else: - # Fallback: simple SHA256(key + message) - h = hashlib.sha256(key + payload_json.encode()) - expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip() - - if signature != expected_signature: - return {} - - return json.loads(payload_json) - except Exception: - return {} - - -def with_session(f): - """Decorator that passes the user session to the route handler. - - The session dictionary is passed to the decorated function as an argument - after the request object. Example:: - - @app.route('/') - @with_session - def index(request, session): - return 'Hello, World!' - - Note that the decorator does not save the session. To update the session, - call the :func:`session.save() ` method. - """ - @wraps(f) - async def wrapper(request, *args, **kwargs): - return await invoke_handler( - f, request, request.app._session.get(request), *args, **kwargs) - - return wrapper diff --git a/lib/microdot/utemplate.py b/lib/microdot/utemplate.py deleted file mode 100644 index 16d0398..0000000 --- a/lib/microdot/utemplate.py +++ /dev/null @@ -1,70 +0,0 @@ -from utemplate import recompile - -_loader = None - - -class Template: - """A template object. - - :param template: The filename of the template to render, relative to the - configured template directory. - """ - @classmethod - def initialize(cls, template_dir='templates', - loader_class=recompile.Loader): - """Initialize the templating subsystem. - - :param template_dir: the directory where templates are stored. This - argument is optional. The default is to load - templates from a *templates* subdirectory. - :param loader_class: the ``utemplate.Loader`` class to use when loading - templates. This argument is optional. The default - is the ``recompile.Loader`` class, which - automatically recompiles templates when they - change. - """ - global _loader - _loader = loader_class(None, template_dir) - - def __init__(self, template): - if _loader is None: # pragma: no cover - self.initialize() - #: The name of the template - self.name = template - self.template = _loader.load(template) - - def generate(self, *args, **kwargs): - """Return a generator that renders the template in chunks, with the - given arguments.""" - return self.template(*args, **kwargs) - - def render(self, *args, **kwargs): - """Render the template with the given arguments and return it as a - string.""" - return ''.join(self.generate(*args, **kwargs)) - - def generate_async(self, *args, **kwargs): - """Return an asynchronous generator that renders the template in - chunks, using the given arguments.""" - class sync_to_async_iter(): - def __init__(self, iter): - self.iter = iter - - def __aiter__(self): - return self - - async def __anext__(self): - try: - return next(self.iter) - except StopIteration: - raise StopAsyncIteration - - return sync_to_async_iter(self.generate(*args, **kwargs)) - - async def render_async(self, *args, **kwargs): - """Render the template with the given arguments asynchronously and - return it as a string.""" - response = '' - async for chunk in self.generate_async(*args, **kwargs): - response += chunk - return response diff --git a/lib/microdot/websocket.py b/lib/microdot/websocket.py deleted file mode 100644 index 0fb6f7c..0000000 --- a/lib/microdot/websocket.py +++ /dev/null @@ -1,231 +0,0 @@ -import binascii -import hashlib -from microdot import Request, Response -from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception -from microdot.helpers import wraps - - -class WebSocketError(Exception): - """Exception raised when an error occurs in a WebSocket connection.""" - pass - - -class WebSocket: - """A WebSocket connection object. - - An instance of this class is sent to handler functions to manage the - WebSocket connection. - """ - CONT = 0 - TEXT = 1 - BINARY = 2 - CLOSE = 8 - PING = 9 - PONG = 10 - - #: Specify the maximum message size that can be received when calling the - #: ``receive()`` method. Messages with payloads that are larger than this - #: size will be rejected and the connection closed. Set to 0 to disable - #: the size check (be aware of potential security issues if you do this), - #: or to -1 to use the value set in - #: ``Request.max_body_length``. The default is -1. - #: - #: Example:: - #: - #: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages - max_message_length = -1 - - def __init__(self, request): - self.request = request - self.closed = False - - async def handshake(self): - response = self._handshake_response() - await self.request.sock[1].awrite( - b'HTTP/1.1 101 Switching Protocols\r\n') - await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') - await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') - await self.request.sock[1].awrite( - b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') - - async def receive(self): - """Receive a message from the client.""" - while True: - opcode, payload = await self._read_frame() - send_opcode, data = self._process_websocket_frame(opcode, payload) - if send_opcode: # pragma: no cover - await self.send(data, send_opcode) - elif data: # pragma: no branch - return data - - async def send(self, data, opcode=None): - """Send a message to the client. - - :param data: the data to send, given as a string or bytes. - :param opcode: a custom frame opcode to use. If not given, the opcode - is ``TEXT`` or ``BINARY`` depending on the type of the - data. - """ - frame = self._encode_websocket_frame( - opcode or (self.TEXT if isinstance(data, str) else self.BINARY), - data) - await self.request.sock[1].awrite(frame) - - async def close(self): - """Close the websocket connection.""" - if not self.closed: # pragma: no cover - self.closed = True - await self.send(b'', self.CLOSE) - - def _handshake_response(self): - connection = False - upgrade = False - websocket_key = None - for header, value in self.request.headers.items(): - h = header.lower() - if h == 'connection': - connection = True - if 'upgrade' not in value.lower(): - return self.request.app.abort(400) - elif h == 'upgrade': - upgrade = True - if not value.lower() == 'websocket': - return self.request.app.abort(400) - elif h == 'sec-websocket-key': - websocket_key = value - if not connection or not upgrade or not websocket_key: - return self.request.app.abort(400) - d = hashlib.sha1(websocket_key.encode()) - d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') - return binascii.b2a_base64(d.digest())[:-1] - - @classmethod - def _parse_frame_header(cls, header): - fin = header[0] & 0x80 - opcode = header[0] & 0x0f - if fin == 0 or opcode == cls.CONT: # pragma: no cover - raise WebSocketError('Continuation frames not supported') - has_mask = header[1] & 0x80 - length = header[1] & 0x7f - if length == 126: - length = -2 - elif length == 127: - length = -8 - return fin, opcode, has_mask, length - - def _process_websocket_frame(self, opcode, payload): - if opcode == self.TEXT: - payload = payload.decode() - elif opcode == self.BINARY: - pass - elif opcode == self.CLOSE: - raise WebSocketError('Websocket connection closed') - elif opcode == self.PING: - return self.PONG, payload - elif opcode == self.PONG: # pragma: no branch - return None, None - return None, payload - - @classmethod - def _encode_websocket_frame(cls, opcode, payload): - frame = bytearray() - frame.append(0x80 | opcode) - if opcode == cls.TEXT: - payload = payload.encode() - if len(payload) < 126: - frame.append(len(payload)) - elif len(payload) < (1 << 16): - frame.append(126) - frame.extend(len(payload).to_bytes(2, 'big')) - else: - frame.append(127) - frame.extend(len(payload).to_bytes(8, 'big')) - frame.extend(payload) - return frame - - async def _read_frame(self): - header = await self.request.sock[0].read(2) - if len(header) != 2: # pragma: no cover - raise WebSocketError('Websocket connection closed') - fin, opcode, has_mask, length = self._parse_frame_header(header) - if length == -2: - length = await self.request.sock[0].read(2) - length = int.from_bytes(length, 'big') - elif length == -8: - length = await self.request.sock[0].read(8) - length = int.from_bytes(length, 'big') - max_allowed_length = Request.max_body_length \ - if self.max_message_length == -1 else self.max_message_length - if length > max_allowed_length: - raise WebSocketError('Message too large') - if has_mask: # pragma: no cover - mask = await self.request.sock[0].read(4) - payload = await self.request.sock[0].read(length) - if has_mask: # pragma: no cover - payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) - return opcode, payload - - -async def websocket_upgrade(request): - """Upgrade a request handler to a websocket connection. - - This function can be called directly inside a route function to process a - WebSocket upgrade handshake, for example after the user's credentials are - verified. The function returns the websocket object:: - - @app.route('/echo') - async def echo(request): - if not authenticate_user(request): - abort(401) - ws = await websocket_upgrade(request) - while True: - message = await ws.receive() - await ws.send(message) - """ - ws = WebSocket(request) - await ws.handshake() - - @request.after_request - async def after_request(request, response): - return Response.already_handled - - return ws - - -def websocket_wrapper(f, upgrade_function): - @wraps(f) - async def wrapper(request, *args, **kwargs): - ws = await upgrade_function(request) - try: - await f(request, ws, *args, **kwargs) - except OSError as exc: - if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover - raise - except WebSocketError: - pass - except Exception as exc: - print_exception(exc) - finally: # pragma: no cover - try: - await ws.close() - except Exception: - pass - return Response.already_handled - return wrapper - - -def with_websocket(f): - """Decorator to make a route a WebSocket endpoint. - - This decorator is used to define a route that accepts websocket - connections. The route then receives a websocket object as a second - argument that it can use to send and receive messages:: - - @app.route('/echo') - @with_websocket - async def echo(request, ws): - while True: - message = await ws.receive() - await ws.send(message) - """ - return websocket_wrapper(f, websocket_upgrade) diff --git a/lib/utemplate/__init__.py b/lib/utemplate/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/lib/utemplate/compiled.py b/lib/utemplate/compiled.py deleted file mode 100644 index 006e6f5..0000000 --- a/lib/utemplate/compiled.py +++ /dev/null @@ -1,14 +0,0 @@ -class Loader: - - def __init__(self, pkg, dir): - if dir == ".": - dir = "" - else: - dir = dir.replace("/", ".") + "." - if pkg and pkg != "__main__": - dir = pkg + "." + dir - self.p = dir - - def load(self, name): - name = name.replace(".", "_") - return __import__(self.p + name, None, None, (name,)).render \ No newline at end of file diff --git a/lib/utemplate/recompile.py b/lib/utemplate/recompile.py deleted file mode 100644 index b9bae4e..0000000 --- a/lib/utemplate/recompile.py +++ /dev/null @@ -1,21 +0,0 @@ -# (c) 2014-2020 Paul Sokolovsky. MIT license. -try: - from uos import stat, remove -except: - from os import stat, remove -from . import source - - -class Loader(source.Loader): - - def load(self, name): - o_path = self.pkg_path + self.compiled_path(name) - i_path = self.pkg_path + self.dir + "/" + name - try: - o_stat = stat(o_path) - i_stat = stat(i_path) - if i_stat[8] > o_stat[8]: - # input file is newer, remove output to force recompile - remove(o_path) - finally: - return super().load(name) \ No newline at end of file diff --git a/lib/utemplate/source.py b/lib/utemplate/source.py deleted file mode 100644 index 0ff4651..0000000 --- a/lib/utemplate/source.py +++ /dev/null @@ -1,188 +0,0 @@ -# (c) 2014-2019 Paul Sokolovsky. MIT license. -from . import compiled - - -class Compiler: - - START_CHAR = "{" - STMNT = "%" - STMNT_END = "%}" - EXPR = "{" - EXPR_END = "}}" - - def __init__(self, file_in, file_out, indent=0, seq=0, loader=None): - self.file_in = file_in - self.file_out = file_out - self.loader = loader - self.seq = seq - self._indent = indent - self.stack = [] - self.in_literal = False - self.flushed_header = False - self.args = "*a, **d" - - def indent(self, adjust=0): - if not self.flushed_header: - self.flushed_header = True - self.indent() - self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args)) - self.stack.append("def") - self.file_out.write(" " * (len(self.stack) + self._indent + adjust)) - - def literal(self, s): - if not s: - return - if not self.in_literal: - self.indent() - self.file_out.write('yield """') - self.in_literal = True - self.file_out.write(s.replace('"', '\\"')) - - def close_literal(self): - if self.in_literal: - self.file_out.write('"""\n') - self.in_literal = False - - def render_expr(self, e): - self.indent() - self.file_out.write('yield str(' + e + ')\n') - - def parse_statement(self, stmt): - tokens = stmt.split(None, 1) - if tokens[0] == "args": - if len(tokens) > 1: - self.args = tokens[1] - else: - self.args = "" - elif tokens[0] == "set": - self.indent() - self.file_out.write(stmt[3:].strip() + "\n") - elif tokens[0] == "include": - if not self.flushed_header: - # If there was no other output, we still need a header now - self.indent() - tokens = tokens[1].split(None, 1) - args = "" - if len(tokens) > 1: - args = tokens[1] - if tokens[0][0] == "{": - self.indent() - # "1" as fromlist param is uPy hack - self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2]) - self.indent() - self.file_out.write("yield from _.render(%s)\n" % args) - return - - with self.loader.input_open(tokens[0][1:-1]) as inc: - self.seq += 1 - c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq) - inc_id = self.seq - self.seq = c.compile() - self.indent() - self.file_out.write("yield from render%d(%s)\n" % (inc_id, args)) - elif len(tokens) > 1: - if tokens[0] == "elif": - assert self.stack[-1] == "if" - self.indent(-1) - self.file_out.write(stmt + ":\n") - else: - self.indent() - self.file_out.write(stmt + ":\n") - self.stack.append(tokens[0]) - else: - if stmt.startswith("end"): - assert self.stack[-1] == stmt[3:] - self.stack.pop(-1) - elif stmt == "else": - assert self.stack[-1] == "if" - self.indent(-1) - self.file_out.write("else:\n") - else: - assert False - - def parse_line(self, l): - while l: - start = l.find(self.START_CHAR) - if start == -1: - self.literal(l) - return - self.literal(l[:start]) - self.close_literal() - sel = l[start + 1] - #print("*%s=%s=" % (sel, EXPR)) - if sel == self.STMNT: - end = l.find(self.STMNT_END) - assert end > 0 - stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip() - self.parse_statement(stmt) - end += len(self.STMNT_END) - l = l[end:] - if not self.in_literal and l == "\n": - break - elif sel == self.EXPR: - # print("EXPR") - end = l.find(self.EXPR_END) - assert end > 0 - expr = l[start + len(self.START_CHAR + self.EXPR):end].strip() - self.render_expr(expr) - end += len(self.EXPR_END) - l = l[end:] - else: - self.literal(l[start]) - l = l[start + 1:] - - def header(self): - self.file_out.write("# Autogenerated file\n") - - def compile(self): - self.header() - for l in self.file_in: - self.parse_line(l) - self.close_literal() - return self.seq - - -class Loader(compiled.Loader): - - def __init__(self, pkg, dir): - super().__init__(pkg, dir) - self.dir = dir - if pkg == "__main__": - # if pkg isn't really a package, don't bother to use it - # it means we're running from "filesystem directory", not - # from a package. - pkg = None - - self.pkg_path = "" - if pkg: - p = __import__(pkg) - if isinstance(p.__path__, str): - # uPy - self.pkg_path = p.__path__ - else: - # CPy - self.pkg_path = p.__path__[0] - self.pkg_path += "/" - - def input_open(self, template): - path = self.pkg_path + self.dir + "/" + template - return open(path) - - def compiled_path(self, template): - return self.dir + "/" + template.replace(".", "_") + ".py" - - def load(self, name): - try: - return super().load(name) - except (OSError, ImportError): - pass - - compiled_path = self.pkg_path + self.compiled_path(name) - - f_in = self.input_open(name) - f_out = open(compiled_path, "w") - c = Compiler(f_in, f_out, loader=self) - c.compile() - f_in.close() - f_out.close() - return super().load(name) \ No newline at end of file diff --git a/msg.json b/msg.json deleted file mode 100644 index 43dac60..0000000 --- a/msg.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "presets": { - "test": { - "pattern": "on", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - }, - "test2": { - "pattern": "rainbow", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - }, - "test3": { - "pattern": "pulse", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - }, - "test4": { - "pattern": "transition", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - }, - "test5": { - "pattern": "chase", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - }, - "test6": { - "pattern": "circle", - "colors": ["#FF0000", "#00FF00", "#0000FF"], - "delay": 100 - } - }, - - "select": { - "name1": "test", - "name2": "test2", - "name3": "test3" - } -} diff --git a/src/controller_messages.py b/src/controller_messages.py deleted file mode 100644 index 470938c..0000000 --- a/src/controller_messages.py +++ /dev/null @@ -1,219 +0,0 @@ -"""Parse controller JSON (v1) and apply brightness, presets, OTA patterns, etc.""" - -import json -import socket - -from utils import convert_and_reorder_colors - -try: - import uos as os -except ImportError: - import os - - -def process_data(payload, settings, presets, controller_ip=None): - """Read one controller message; json.loads (bytes or str), then apply fields.""" - try: - data = json.loads(payload) - print(payload) - if data.get("v", "") != "1": - return - except (ValueError, TypeError): - return - if "b" in data: - apply_brightness(data, settings, presets) - if "presets" in data: - apply_presets(data, settings, presets) - if "select" in data: - apply_select(data, settings, presets) - if "default" in data: - apply_default(data, settings, presets) - if "manifest" in data: - apply_patterns_ota(data, presets, controller_ip=controller_ip) - if "save" in data and ("presets" in data or "default" in data): - presets.save() - - -def apply_brightness(data, settings, presets): - try: - presets.b = max(0, min(255, int(data["b"]))) - settings["brightness"] = presets.b - except (TypeError, ValueError): - pass - - -def apply_presets(data, settings, presets): - presets_map = data["presets"] - for id, preset_data in presets_map.items(): - if not preset_data: - continue - color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None) - if color_key is not None: - try: - preset_data[color_key] = convert_and_reorder_colors( - preset_data[color_key], settings - ) - except (TypeError, ValueError, KeyError): - continue - presets.edit(id, preset_data) - print(f"Edited preset {id}: {preset_data.get('name', '')}") - - -def apply_select(data, settings, presets): - select_map = data["select"] - device_name = settings["name"] - select_list = select_map.get(device_name, []) - if not select_list: - return - preset_name = select_list[0] - step = select_list[1] if len(select_list) > 1 else None - presets.select(preset_name, step=step) - - -def apply_default(data, settings, presets): - targets = data.get("targets") or [] - default_name = data["default"] - if ( - settings["name"] in targets - and isinstance(default_name, str) - and default_name in presets.presets - ): - settings["default"] = default_name - settings.save() - - -def _parse_http_url(url): - """Parse http://host[:port]/path into (host, port, path).""" - if not isinstance(url, str): - raise ValueError("url must be a string") - if not url.startswith("http://"): - raise ValueError("only http:// URLs are supported") - remainder = url[7:] - slash_idx = remainder.find("/") - if slash_idx == -1: - host_port = remainder - path = "/" - else: - host_port = remainder[:slash_idx] - path = remainder[slash_idx:] - if ":" in host_port: - host, port_s = host_port.rsplit(":", 1) - port = int(port_s) - else: - host = host_port - port = 80 - if not host: - raise ValueError("missing host") - return host, port, path - - -def _http_get_raw(url, timeout_s=10.0): - host, port, path = _parse_http_url(url) - req = ( - "GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host) - ).encode("utf-8") - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.settimeout(timeout_s) - sock.connect((host, int(port))) - sock.send(req) - data = b"" - while True: - chunk = sock.recv(1024) - if not chunk: - break - data += chunk - finally: - try: - sock.close() - except Exception: - pass - sep = b"\r\n\r\n" - if sep not in data: - raise OSError("invalid HTTP response") - head, body = data.split(sep, 1) - status_line = head.split(b"\r\n", 1)[0] - if b" 200 " not in status_line: - raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8")) - return body - - -def _http_get_json(url, timeout_s=10.0): - body = _http_get_raw(url, timeout_s=timeout_s) - return json.loads(body.decode("utf-8")) - - -def _http_get_text(url, timeout_s=10.0, controller_ip=None): - # Support relative URLs from controller messages. - if isinstance(url, str) and url.startswith("/"): - if not controller_ip: - raise OSError("controller IP unavailable for relative URL") - url = "http://%s%s" % (controller_ip, url) - try: - body = _http_get_raw(url, timeout_s=timeout_s) - return body.decode("utf-8") - except Exception: - # Fallback for mDNS/unresolvable host: retry against current controller IP. - if not controller_ip or not isinstance(url, str) or not url.startswith("http://"): - raise - _host, _port, path = _parse_http_url(url) - fallback = "http://%s:%d%s" % (controller_ip, _port, path) - body = _http_get_raw(fallback, timeout_s=timeout_s) - return body.decode("utf-8") - - -def _safe_pattern_filename(name): - if not isinstance(name, str): - return False - if not name.endswith(".py"): - return False - if "/" in name or "\\" in name or ".." in name: - return False - return True - - -def apply_patterns_ota(data, presets, controller_ip=None): - manifest_payload = data.get("manifest") - if not manifest_payload: - return - try: - if isinstance(manifest_payload, dict): - manifest = manifest_payload - elif isinstance(manifest_payload, str): - manifest = _http_get_json(manifest_payload, timeout_s=20.0) - else: - print("patterns_ota: invalid manifest payload type") - return - files = manifest.get("files", []) - if not isinstance(files, list) or not files: - print("patterns_ota: no files in manifest") - return - try: - os.mkdir("patterns") - except OSError: - pass - updated = 0 - for item in files: - if not isinstance(item, dict): - continue - name = item.get("name") - url = item.get("url") - inline_code = item.get("code") - if not _safe_pattern_filename(name): - continue - if isinstance(inline_code, str): - code = inline_code - elif isinstance(url, str): - code = _http_get_text(url, timeout_s=20.0, controller_ip=controller_ip) - else: - continue - with open("patterns/" + name, "w") as f: - f.write(code) - updated += 1 - if updated > 0: - presets.reload_patterns() - print("patterns_ota: updated", updated, "pattern file(s)") - else: - print("patterns_ota: no valid files downloaded") - except Exception as e: - print("patterns_ota failed:", e) diff --git a/src/hello.py b/src/hello.py deleted file mode 100644 index 155cec2..0000000 --- a/src/hello.py +++ /dev/null @@ -1,191 +0,0 @@ -"""LED hello JSON line and UDP broadcast on port 8766. - -Used so led-controller can register the device (name, MAC, IP) when ``wait_reply`` is -false; the controller may then connect to the device's WebSocket. With -``wait_reply`` true, blocks for an echo and returns the controller IP (legacy discovery). - -Wi-Fi must already be connected; this module does not use Settings or call connect(). -""" - -import json -import socket -import ubinascii - -import network - -# Match led-controller/tests/udp_server.py -DISCOVERY_UDP_PORT = 8766 -DEFAULT_RECV_TIMEOUT_S = 3 - - -def pack_hello_dict(sta, device_name=""): - """Same fields as main HTTP/ESP-NOW hello.""" - mac = sta.config("mac") - return { - "v": "1", - "device_name": device_name, - "mac": ubinascii.hexlify(mac).decode().lower(), - "type": "led", - } - - -def pack_hello_bytes(sta, device_name=""): - return json.dumps(pack_hello_dict(sta, device_name)).encode("utf-8") - - -def pack_hello_line(sta, device_name=""): - """JSON hello + newline (HTTP/UDP discovery payloads).""" - return pack_hello_bytes(sta, device_name) + b"\n" - - -def ipv4_broadcast(ip, netmask): - """Directed broadcast (e.g. 192.168.1.0/24 -> 192.168.1.255).""" - ia = [int(x) for x in ip.split(".")] - im = [int(x) for x in netmask.split(".")] - if len(ia) != 4 or len(im) != 4: - return None - # STA often reports 255.255.255.255; "broadcast" would equal the host IP — useless for LAN. - if netmask == "255.255.255.255": - return None - bcast = ".".join(str(ia[i] | (255 - im[i])) for i in range(4)) - if bcast == ip: - return None - return bcast - - -def udp_discovery_targets(ip, mask): - """(directed_broadcast, port) then limited broadcast.""" - out = [("255.255.255.255", DISCOVERY_UDP_PORT)] - b = ipv4_broadcast(ip, mask) - if b: - out.insert(0, (b, DISCOVERY_UDP_PORT)) - return out - - -def _udp_discovery_targets_single(ip, mask): - """One destination: subnet broadcast if known, else limited broadcast.""" - b = ipv4_broadcast(ip, mask) - if b: - return [(b, DISCOVERY_UDP_PORT)] - return [("255.255.255.255", DISCOVERY_UDP_PORT)] - - -def broadcast_hello_udp( - sta, - device_name="", - *, - wait_reply=True, - recv_timeout_s=DEFAULT_RECV_TIMEOUT_S, - wdt=None, - dual_destinations=True, -): - """ - Send pack_hello_line on DISCOVERY_UDP_PORT. - STA must already be connected with a valid IPv4 (caller brings up Wi-Fi). - - If dual_destinations (default), send subnet broadcast then 255.255.255.255 so - discovery works on awkward APs — the controller may receive two packets. - If dual_destinations is False, send only one (subnet broadcast or limited), - e.g. after TCP connect so the Pi does not run duplicate resync handlers. - - If wait_reply, wait for first UDP echo. Returns controller IP string or None. - """ - ip, mask, _gw, _dns = sta.ifconfig() - msg = pack_hello_line(sta, device_name) - print("hello:", msg) - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - except (AttributeError, OSError) as e: - print("SO_BROADCAST not set:", e) - try: - sock.bind((ip, 0)) - except (AttributeError, OSError, TypeError) as e: - try: - sock.bind(("0.0.0.0", 0)) - except (AttributeError, OSError) as e2: - print("bind skipped:", e, e2) - if wait_reply: - try: - sock.settimeout(recv_timeout_s) - except (AttributeError, OSError): - pass - - discovered = None - targets = ( - udp_discovery_targets(ip, mask) - if dual_destinations - else _udp_discovery_targets_single(ip, mask) - ) - for dest_ip, dest_port in targets: - if wdt is not None: - wdt.feed() - label = "%s:%s" % (dest_ip, dest_port) - target = (dest_ip, dest_port) - try: - sock.sendto(msg, target) - print("sent hello ->", target) - except OSError as e: - print("sendto failed:", e) - continue - if not wait_reply: - continue - if wdt is not None: - wdt.feed() - try: - data, addr = sock.recvfrom(2048) - print("reply from", addr, ":", data) - remote_ip = addr[0] - if data != msg: - print("(warning: reply payload differs from hello; still using source IP.)") - discovered = remote_ip - print("Discovered controller at", remote_ip) - break - except OSError as e: - print("recv (no reply):", e, "via", label) - if dest_ip == "255.255.255.255": - print( - "(hint: many APs drop Wi-Fi client broadcast; try wired server or AP without client isolation.)" - ) - - sock.close() - return discovered - - -def discover_controller_udp(device_name="", wdt=None): - """ - Broadcast hello; return controller IP from first UDP echo, or None. - STA must already be connected. - - device_name: logical name in the JSON (caller supplies, e.g. from Settings elsewhere). - wdt: optional WDT to feed during waits. - """ - sta = network.WLAN(network.STA_IF) - if not sta.isconnected(): - print("hello: STA not connected — connect Wi-Fi before discovery.") - raise SystemExit(1) - - ip, mask, _g, _d = sta.ifconfig() - if ip == "0.0.0.0": - print("hello: STA has no IP address.") - raise SystemExit(1) - - print("STA IP:", ip, "mask:", mask) - - discovered = broadcast_hello_udp( - sta, - device_name, - wait_reply=True, - wdt=wdt, - ) - if discovered: - print("discover done; controller =", repr(discovered)) - else: - print("discover done; controller not found") - return discovered - - -if __name__ == "__main__": - if not discover_controller_udp(): - raise SystemExit(1) diff --git a/src/main.py b/src/main.py deleted file mode 100644 index a3dcb21..0000000 --- a/src/main.py +++ /dev/null @@ -1,90 +0,0 @@ -from settings import Settings -from machine import WDT -import network -import utime -import asyncio -from microdot import Microdot -from microdot.websocket import WebSocketError, with_websocket -from presets import Presets -from controller_messages import process_data -from hello import broadcast_hello_udp - -settings = Settings() -print(settings) - -presets = Presets(settings["led_pin"], settings["num_leds"]) -presets.load(settings) -presets.b = settings.get("brightness", 255) -default_preset = settings.get("default", "") -if default_preset and default_preset in presets.presets: - if presets.select(default_preset): - print(f"Selected startup preset: {default_preset}") - else: - print("Startup preset failed (invalid pattern?):", default_preset) - -wdt = WDT(timeout=10000) -wdt.feed() - -sta_if = network.WLAN(network.STA_IF) -sta_if.active(True) -sta_if.config(pm=network.WLAN.PM_NONE) -sta_if.connect(settings["ssid"], settings["password"]) -while not sta_if.isconnected(): - utime.sleep(1) - wdt.feed() - -print(sta_if.ifconfig()) - -app = Microdot() - - -@app.route("/ws") -@with_websocket -async def ws_handler(request, ws): - print("WS client connected") - try: - while True: - data = await ws.receive() - if not data: - print("WS client disconnected (closed)") - break - print(data) - process_data(data, settings, presets) - except WebSocketError as e: - print("WS client disconnected:", e) - except OSError as e: - print("WS client dropped (OSError):", e) - - -async def presets_loop(): - while True: - presets.tick() - wdt.feed() - # tick() does not await; yield so UDP hello and HTTP/WebSocket can run. - await asyncio.sleep(0) - - -async def _udp_hello_after_http_ready(): - """Hello must run after the HTTP server binds, or discovery clients time out on /ws.""" - await asyncio.sleep(1) - print("UDP hello: broadcasting…") - try: - broadcast_hello_udp( - sta_if, - settings.get("name", ""), - wait_reply=False, - wdt=wdt, - dual_destinations=True, - ) - except Exception as ex: - print("UDP hello broadcast failed:", ex) - - -async def main(port=80): - asyncio.create_task(presets_loop()) - asyncio.create_task(_udp_hello_after_http_ready()) - await app.start_server(host="0.0.0.0", port=port) - - -if __name__ == "__main__": - asyncio.run(main(port=80)) diff --git a/src/patterns/__init__.py b/src/patterns/__init__.py deleted file mode 100644 index 83b9dac..0000000 --- a/src/patterns/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .blink import Blink -from .rainbow import Rainbow -from .pulse import Pulse -from .transition import Transition -from .chase import Chase -from .circle import Circle diff --git a/src/patterns/blink.py b/src/patterns/blink.py deleted file mode 100644 index 8a63fe5..0000000 --- a/src/patterns/blink.py +++ /dev/null @@ -1,33 +0,0 @@ -import utime - - -class Blink: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Blink pattern: toggles LEDs on/off using preset delay, cycling through colors.""" - # Use provided colors, or default to white if none - colors = preset.c if preset.c else [(255, 255, 255)] - color_index = 0 - state = True # True = on, False = off - last_update = utime.ticks_ms() - - while True: - current_time = utime.ticks_ms() - # Re-read delay each loop so live updates to preset.d take effect - delay_ms = max(1, int(preset.d)) - if utime.ticks_diff(current_time, last_update) >= delay_ms: - if state: - base_color = colors[color_index % len(colors)] - color = self.driver.apply_brightness(base_color, preset.b) - self.driver.fill(color) - # Advance to next color for the next "on" phase - color_index += 1 - else: - # "Off" phase: turn all LEDs off - self.driver.fill((0, 0, 0)) - state = not state - last_update = current_time - # Yield once per tick so other logic can run - yield diff --git a/src/patterns/chase.py b/src/patterns/chase.py deleted file mode 100644 index 837ac21..0000000 --- a/src/patterns/chase.py +++ /dev/null @@ -1,124 +0,0 @@ -import utime - - -class Chase: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating. - Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)""" - colors = preset.c - if len(colors) < 1: - # Need at least 1 color - return - - # Access colors, delay, and n values from preset - if not colors: - return - # If only one color provided, use it for both colors - if len(colors) < 2: - color0 = colors[0] - color1 = colors[0] - else: - color0 = colors[0] - color1 = colors[1] - - color0 = self.driver.apply_brightness(color0, preset.b) - color1 = self.driver.apply_brightness(color1, preset.b) - - n1 = max(1, int(preset.n1)) # LEDs of color 0 - n2 = max(1, int(preset.n2)) # LEDs of color 1 - n3 = int(preset.n3) # Step movement on even steps (can be negative) - n4 = int(preset.n4) # Step movement on odd steps (can be negative) - - segment_length = n1 + n2 - - # Calculate position from step_count - step_count = self.driver.step - # Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc. - if step_count % 2 == 0: - # Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3 - position = (step_count // 2) * (n3 + n4) + n3 - else: - # Odd steps: ((step_count+1)//2) pairs of (n3+n4) - position = ((step_count + 1) // 2) * (n3 + n4) - - # Wrap position to keep it reasonable - max_pos = self.driver.num_leds + segment_length - position = position % max_pos - if position < 0: - position += max_pos - - # If auto is False, run a single step and then stop - if not preset.a: - # Clear all LEDs - self.driver.n.fill((0, 0, 0)) - - # Draw repeating pattern starting at position - for i in range(self.driver.num_leds): - # Calculate position in the repeating segment - relative_pos = (i - position) % segment_length - if relative_pos < 0: - relative_pos = (relative_pos + segment_length) % segment_length - - # Determine which color based on position in segment - if relative_pos < n1: - self.driver.n[i] = color0 - else: - self.driver.n[i] = color1 - - self.driver.n.write() - - # Increment step for next beat - self.driver.step = step_count + 1 - - # Allow tick() to advance the generator once - yield - return - - # Auto mode: continuous loop - # Use transition_duration for timing and force the first update to happen immediately - transition_duration = max(10, int(preset.d)) - last_update = utime.ticks_ms() - transition_duration - - while True: - current_time = utime.ticks_ms() - if utime.ticks_diff(current_time, last_update) >= transition_duration: - # Calculate current position from step_count - if step_count % 2 == 0: - position = (step_count // 2) * (n3 + n4) + n3 - else: - position = ((step_count + 1) // 2) * (n3 + n4) - - # Wrap position - max_pos = self.driver.num_leds + segment_length - position = position % max_pos - if position < 0: - position += max_pos - - # Clear all LEDs - self.driver.n.fill((0, 0, 0)) - - # Draw repeating pattern starting at position - for i in range(self.driver.num_leds): - # Calculate position in the repeating segment - relative_pos = (i - position) % segment_length - if relative_pos < 0: - relative_pos = (relative_pos + segment_length) % segment_length - - # Determine which color based on position in segment - if relative_pos < n1: - self.driver.n[i] = color0 - else: - self.driver.n[i] = color1 - - self.driver.n.write() - - # Increment step - step_count += 1 - self.driver.step = step_count - last_update = current_time - - # Yield once per tick so other logic can run - yield diff --git a/src/patterns/circle.py b/src/patterns/circle.py deleted file mode 100644 index f063724..0000000 --- a/src/patterns/circle.py +++ /dev/null @@ -1,96 +0,0 @@ -import utime - - -class Circle: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4""" - head = 0 - tail = 0 - - # Calculate timing from preset - head_rate = max(1, int(preset.n1)) # n1 = head moves per second - tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second - max_length = max(1, int(preset.n2)) # n2 = max length - min_length = max(0, int(preset.n4)) # n4 = min length - - head_delay = 1000 // head_rate # ms between head movements - tail_delay = 1000 // tail_rate # ms between tail movements - - last_head_move = utime.ticks_ms() - last_tail_move = utime.ticks_ms() - - phase = "growing" # "growing", "shrinking", or "off" - - # Support up to two colors (like chase). If only one color is provided, - # use black for the second; if none, default to white. - colors = preset.c - if not colors: - base0 = base1 = (255, 255, 255) - elif len(colors) == 1: - base0 = colors[0] - base1 = (0, 0, 0) - else: - base0 = colors[0] - base1 = colors[1] - - color0 = self.driver.apply_brightness(base0, preset.b) - color1 = self.driver.apply_brightness(base1, preset.b) - - while True: - current_time = utime.ticks_ms() - - # Background: use second color during the "off" phase, otherwise clear to black - if phase == "off": - self.driver.n.fill(color1) - else: - self.driver.n.fill((0, 0, 0)) - - # Calculate segment length - segment_length = (head - tail) % self.driver.num_leds - if segment_length == 0 and head != tail: - segment_length = self.driver.num_leds - - # Draw segment from tail to head as a solid color (no per-LED alternation) - current_color = color0 - for i in range(segment_length + 1): - led_pos = (tail + i) % self.driver.num_leds - self.driver.n[led_pos] = current_color - - # Move head continuously at n1 LEDs per second - if utime.ticks_diff(current_time, last_head_move) >= head_delay: - head = (head + 1) % self.driver.num_leds - last_head_move = current_time - - # Tail behavior based on phase - if phase == "growing": - # Growing phase: tail stays at 0 until max length reached - if segment_length >= max_length: - phase = "shrinking" - elif phase == "shrinking": - # Shrinking phase: move tail forward at n3 LEDs per second - if utime.ticks_diff(current_time, last_tail_move) >= tail_delay: - tail = (tail + 1) % self.driver.num_leds - last_tail_move = current_time - - # Check if we've reached min length - current_length = (head - tail) % self.driver.num_leds - if current_length == 0 and head != tail: - current_length = self.driver.num_leds - - # For min_length = 0, we need at least 1 LED (the head) - if min_length == 0 and current_length <= 1: - phase = "off" # All LEDs off for 1 step - elif min_length > 0 and current_length <= min_length: - phase = "growing" # Cycle repeats - else: # phase == "off" - # Off phase: second color fills the ring for 1 step, then restart - tail = head # Reset tail to head position to start fresh - phase = "growing" - - self.driver.n.write() - - # Yield once per tick so other logic can run - yield diff --git a/src/patterns/pulse.py b/src/patterns/pulse.py deleted file mode 100644 index 1faf020..0000000 --- a/src/patterns/pulse.py +++ /dev/null @@ -1,64 +0,0 @@ -import utime - - -class Pulse: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - self.driver.off() - - # Get colors from preset - colors = preset.c - if not colors: - colors = [(255, 255, 255)] - - color_index = 0 - cycle_start = utime.ticks_ms() - - # State machine based pulse using a single generator loop - while True: - # Read current timing parameters from preset - attack_ms = max(0, int(preset.n1)) # Attack time in ms - hold_ms = max(0, int(preset.n2)) # Hold time in ms - decay_ms = max(0, int(preset.n3)) # Decay time in ms - delay_ms = max(0, int(preset.d)) - - total_ms = attack_ms + hold_ms + decay_ms + delay_ms - if total_ms <= 0: - total_ms = 1 - - now = utime.ticks_ms() - elapsed = utime.ticks_diff(now, cycle_start) - - base_color = colors[color_index % len(colors)] - - if elapsed < attack_ms and attack_ms > 0: - # Attack: fade 0 -> 1 - factor = elapsed / attack_ms - color = tuple(int(c * factor) for c in base_color) - self.driver.fill(self.driver.apply_brightness(color, preset.b)) - elif elapsed < attack_ms + hold_ms: - # Hold: full brightness - self.driver.fill(self.driver.apply_brightness(base_color, preset.b)) - elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0: - # Decay: fade 1 -> 0 - dec_elapsed = elapsed - attack_ms - hold_ms - factor = max(0.0, 1.0 - (dec_elapsed / decay_ms)) - color = tuple(int(c * factor) for c in base_color) - self.driver.fill(self.driver.apply_brightness(color, preset.b)) - elif elapsed < total_ms: - # Delay phase: LEDs off between pulses - self.driver.fill((0, 0, 0)) - else: - # End of cycle, move to next color and restart timing - color_index += 1 - cycle_start = now - if not preset.a: - break - # Skip drawing this tick, start next cycle - yield - continue - - # Yield once per tick - yield diff --git a/src/patterns/rainbow.py b/src/patterns/rainbow.py deleted file mode 100644 index 64c54e9..0000000 --- a/src/patterns/rainbow.py +++ /dev/null @@ -1,51 +0,0 @@ -import utime - - -class Rainbow: - def __init__(self, driver): - self.driver = driver - - def _wheel(self, pos): - if pos < 85: - return (pos * 3, 255 - pos * 3, 0) - elif pos < 170: - pos -= 85 - return (255 - pos * 3, 0, pos * 3) - else: - pos -= 170 - return (0, pos * 3, 255 - pos * 3) - - def run(self, preset): - step = self.driver.step % 256 - step_amount = max(1, int(preset.n1)) # n1 controls step increment - - # If auto is False, run a single step and then stop - if not preset.a: - for i in range(self.driver.num_leds): - rc_index = (i * 256 // self.driver.num_leds) + step - self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b) - self.driver.n.write() - # Increment step by n1 for next manual call - self.driver.step = (step + step_amount) % 256 - # Allow tick() to advance the generator once - yield - return - - last_update = utime.ticks_ms() - - while True: - current_time = utime.ticks_ms() - sleep_ms = max(1, int(preset.d)) # Get delay from preset - if utime.ticks_diff(current_time, last_update) >= sleep_ms: - for i in range(self.driver.num_leds): - rc_index = (i * 256 // self.driver.num_leds) + step - self.driver.n[i] = self.driver.apply_brightness( - self._wheel(rc_index & 255), - preset.b, - ) - self.driver.n.write() - step = (step + step_amount) % 256 - self.driver.step = step - last_update = current_time - # Yield once per tick so other logic can run - yield diff --git a/src/patterns/transition.py b/src/patterns/transition.py deleted file mode 100644 index b29545a..0000000 --- a/src/patterns/transition.py +++ /dev/null @@ -1,57 +0,0 @@ -import utime - - -class Transition: - def __init__(self, driver): - self.driver = driver - - def run(self, preset): - """Transition between colors, blending over `delay` ms.""" - colors = preset.c - if not colors: - self.driver.off() - yield - return - - # Only one color: just keep it on - if len(colors) == 1: - while True: - self.driver.fill(self.driver.apply_brightness(colors[0], preset.b)) - yield - return - - color_index = 0 - start_time = utime.ticks_ms() - - while True: - if not colors: - break - - # Get current and next color based on live list - c1 = colors[color_index % len(colors)] - c2 = colors[(color_index + 1) % len(colors)] - - duration = max(10, int(preset.d)) # At least 10ms - now = utime.ticks_ms() - elapsed = utime.ticks_diff(now, start_time) - - if elapsed >= duration: - # End of this transition step - if not preset.a: - # One-shot: transition from first to second color only - self.driver.fill(self.driver.apply_brightness(c2, preset.b)) - break - # Auto: move to next pair - color_index = (color_index + 1) % len(colors) - start_time = now - yield - continue - - # Interpolate between c1 and c2 - factor = elapsed / duration - interpolated = tuple( - int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3) - ) - self.driver.fill(self.driver.apply_brightness(interpolated, preset.b)) - - yield diff --git a/src/preset.py b/src/preset.py deleted file mode 100644 index 6c9f8ab..0000000 --- a/src/preset.py +++ /dev/null @@ -1,116 +0,0 @@ -class Preset: - def __init__(self, data): - # Set default values for all preset attributes - self.p = "off" - self.d = 100 - self.b = 127 - self.c = [(255, 255, 255)] - self.a = True - self.n1 = 0 - self.n2 = 0 - self.n3 = 0 - self.n4 = 0 - self.n5 = 0 - self.n6 = 0 - - # Override defaults with provided data - self.edit(data) - - def edit(self, data=None): - if not data: - return False - aliases = { - "pattern": "p", - "colors": "c", - "delay": "d", - "brightness": "b", - "auto": "a", - } - int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"} - allowed_fields = {"p", "c", "d", "b", "a", "n1", "n2", "n3", "n4", "n5", "n6"} - for key, value in data.items(): - key = aliases.get(key, key) - if key not in allowed_fields: - continue - if key in int_fields: - try: - parsed = int(value) - if key == "b": - parsed = max(0, min(255, parsed)) - elif key in ("d", "n1", "n2", "n3", "n4", "n5", "n6"): - parsed = max(0, parsed) - setattr(self, key, parsed) - except (TypeError, ValueError): - continue - elif key == "a": - if isinstance(value, bool): - self.a = value - elif isinstance(value, int): - self.a = bool(value) - elif isinstance(value, str): - lowered = value.lower() - if lowered in ("true", "1", "yes", "on"): - self.a = True - elif lowered in ("false", "0", "no", "off"): - self.a = False - elif key == "c": - if isinstance(value, (list, tuple)): - self.c = value - else: - setattr(self, key, value) - return True - - @property - def pattern(self): - return self.p - - @pattern.setter - def pattern(self, value): - self.p = value - - @property - def delay(self): - return self.d - - @delay.setter - def delay(self, value): - self.d = value - - @property - def brightness(self): - return self.b - - @brightness.setter - def brightness(self, value): - self.b = value - - @property - def colors(self): - return self.c - - @colors.setter - def colors(self, value): - self.c = value - - @property - def auto(self): - return self.a - - @auto.setter - def auto(self, value): - self.a = value - - def to_dict(self): - return { - "p": self.p, - "d": self.d, - "b": self.b, - "c": self.c, - "a": self.a, - "n1": self.n1, - "n2": self.n2, - "n3": self.n3, - "n4": self.n4, - "n5": self.n5, - "n6": self.n6, - } diff --git a/src/presets.json b/src/presets.json deleted file mode 100644 index 1c4f27f..0000000 --- a/src/presets.json +++ /dev/null @@ -1 +0,0 @@ -{"14": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 102, 0]], "b": 255, "n2": 1000, "n1": 2000, "p": "pulse", "n3": 2000, "d": 800}, "15": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 500}, "5": {"n5": 0, "n4": 1, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 5, "n1": 5, "p": "chase", "n3": 1, "d": 200}, "4": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 500}, "7": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[255, 165, 0], [128, 0, 128]], "b": 255, "n2": 10, "n1": 2, "p": "circle", "n3": 2, "d": 200}, "11": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "12": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "6": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 255, 0]], "b": 255, "n2": 500, "n1": 1000, "p": "pulse", "n3": 1000, "d": 500}, "3": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 2, "p": "rainbow", "n3": 0, "d": 100}, "2": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 0, "n2": 0, "n1": 0, "p": "off", "n3": 0, "d": 100}, "1": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "10": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[230, 242, 255]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "13": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 1, "p": "rainbow", "n3": 0, "d": 150}, "9": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 245, 230]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "8": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 1000}} \ No newline at end of file diff --git a/src/presets.py b/src/presets.py deleted file mode 100644 index e8cbb86..0000000 --- a/src/presets.py +++ /dev/null @@ -1,182 +0,0 @@ -from machine import Pin -from neopixel import NeoPixel -from preset import Preset -from utils import convert_and_reorder_colors -import json -import sys -try: - import uos as os -except ImportError: - import os - - -class Presets: - def __init__(self, pin, num_leds): - self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds) - self.num_leds = num_leds - self.step = 0 - # Global brightness (0–255), controlled via ESPNow {"b": } - self.b = 255 - - self.generator = None - self.presets = {} - self.selected = None - - self.reload_patterns() - - def reload_patterns(self): - # Register built-in methods first, then discovered pattern classes - self.patterns = { - "off": self.off, - "on": self.on, - } - self.patterns.update(self._load_dynamic_patterns()) - - def _load_dynamic_patterns(self): - loaded = {} - try: - files = os.listdir("patterns") - except OSError: - return loaded - - for filename in files: - if not filename.endswith(".py") or filename in ("__init__.py", "main.py"): - continue - module_basename = filename[:-3] - module_name = "patterns." + module_basename - try: - if module_name in sys.modules: - del sys.modules[module_name] - module = __import__(module_name, None, None, ["*"]) - except Exception as e: - print("Pattern import failed:", module_name, e) - continue - - pattern_class = None - for attr_name in dir(module): - attr = getattr(module, attr_name) - # Pick the first class in the module that exposes run() - if isinstance(attr, type) and hasattr(attr, "run"): - pattern_class = attr - break - - if pattern_class is None: - continue - - try: - loaded[module_basename] = pattern_class(self).run - except Exception as e: - print("Pattern init failed:", module_name, e) - - return loaded - - def save(self): - """Save the presets to a file.""" - with open("presets.json", "w") as f: - json.dump({name: preset.to_dict() for name, preset in self.presets.items()}, f) - return True - - def load(self, settings=None): - """Load presets from a file. - - `settings` is used to convert hex strings in `c` to RGB tuples and apply - the device's colour order (same as ESPNow receive). If omitted, RGB order - is assumed. - """ - try: - with open("presets.json", "r") as f: - data = json.load(f) - except OSError: - # Create an empty presets file if missing - self.presets = {} - self.save() - return True - - order = settings if settings is not None else "rgb" - self.presets = {} - for name, preset_data in data.items(): - color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None) - if color_key is not None: - preset_data[color_key] = convert_and_reorder_colors( - preset_data[color_key], order - ) - self.presets[name] = Preset(preset_data) - if self.presets: - print("Loaded presets:") - #for name in sorted(self.presets.keys()): - # print(f" {name}: {self.presets[name].to_dict()}") - return True - - def edit(self, name, data): - """Create or update a preset with the given name.""" - if name in self.presets: - # Update existing preset - self.presets[name].edit(data) - else: - # Create new preset - self.presets[name] = Preset(data) - return True - - def delete(self, name): - if name in self.presets: - del self.presets[name] - return True - return False - - def tick(self): - if self.generator is None: - return - try: - next(self.generator) - except StopIteration: - self.generator = None - except Exception as e: - print(f"Error in tick: {e}") - self.generator = None - - def select(self, preset_name, step=None): - # Auto-create simple built-in presets for common names on first use - if preset_name not in self.presets and preset_name in ("on", "off"): - if preset_name == "on": - self.presets[preset_name] = Preset({"p": "on"}) - else: - self.presets[preset_name] = Preset({"p": "off"}) - - if preset_name in self.presets: - preset = self.presets[preset_name] - if preset.p in self.patterns: - # Set step value if explicitly provided - if step is not None: - self.step = step - elif preset.p == "off" or self.selected != preset_name: - self.step = 0 - self.generator = self.patterns[preset.p](preset) - self.selected = preset_name # Store the preset name, not the object - return True - # If preset doesn't exist or pattern not found, indicate failure - return False - - def update_num_leds(self, pin, num_leds): - self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds) - self.num_leds = num_leds - - def apply_brightness(self, color, brightness_override=None): - # Combine per-preset brightness (override) with global brightness self.b - local = brightness_override if brightness_override is not None else 255 - # Scale preset brightness by global brightness - effective_brightness = int(local * self.b / 255) - return tuple(int(c * effective_brightness / 255) for c in color) - - def fill(self, color=None): - fill_color = color if color is not None else (0, 0, 0) - for i in range(self.num_leds): - self.n[i] = fill_color - self.n.write() - - def off(self, preset=None): - self.fill((0, 0, 0)) - - def on(self, preset): - colors = preset.c - color = colors[0] if colors else (255, 255, 255) - self.fill(self.apply_brightness(color, preset.b)) diff --git a/src/settings.py b/src/settings.py deleted file mode 100644 index 31b4929..0000000 --- a/src/settings.py +++ /dev/null @@ -1,99 +0,0 @@ -import json -import ubinascii -import machine -import network - -class Settings(dict): - SETTINGS_FILE = "/settings.json" - - def __init__(self): - super().__init__() - self.load() # Load settings from file during initialization - self.color_order = self.get_color_order(self["color_order"]) - - def set_defaults(self): - - self["led_pin"] = 10 - self["num_leds"] = 119 - - self["color_order"] = "rgb" - - sta = network.WLAN(network.STA_IF) - sta.active(True) - #use led-mac for name - mac = sta.config("mac") - mac = ubinascii.hexlify(mac).decode().lower() - self["name"] = "led-" + mac - - self["debug"] = False - self["default"] = "on" - self["brightness"] = 32 - self["transport_type"] = "espnow" - self["wifi_channel"] = 1 - # ESP-NOW transport (requires espnow firmware; uses wifi_channel). - self["ssid"] = "" - self["password"] = "" - - def save(self): - try: - j = json.dumps(self) - with open(self.SETTINGS_FILE, 'w') as file: - file.write(j) - print("Settings saved successfully.") - except Exception as e: - print(f"Error saving settings: {e}") - - def load(self): - try: - with open(self.SETTINGS_FILE, 'r') as file: - loaded_settings = json.load(file) - self.update(loaded_settings) - print("Settings loaded successfully.") - except Exception as e: - print(f"Error loading settings") - self.set_defaults() - self.save() - - def get_color_order(self, color_order): - """Convert color order string to tuple of hex string indices.""" - color_orders = { - "rgb": (1, 3, 5), - "rbg": (1, 5, 3), - "grb": (3, 1, 5), - "gbr": (3, 5, 1), - "brg": (5, 1, 3), - "bgr": (5, 3, 1) - } - return color_orders.get(color_order.lower(), (1, 3, 5)) # Default to RGB - - def get_rgb_channel_order(self, color_order=None): - """Convert color order string to RGB channel indices for reordering tuples. - Returns tuple of channel indices: (r_channel, g_channel, b_channel) - Example: 'grb' -> (1, 0, 2) means (G, R, B)""" - if color_order is None: - color_order = self.get("color_order", "rgb") - color_order = color_order.lower() - # Map hex string positions to RGB channel indices - # Position 1 (R in hex) -> channel 0, Position 3 (G) -> channel 1, Position 5 (B) -> channel 2 - hex_to_channel = {1: 0, 3: 1, 5: 2} - hex_indices = self.get_color_order(color_order) - return tuple(hex_to_channel[pos] for pos in hex_indices) - -# Example usage -def main(): - settings = Settings() - print(f"Number of LEDs: {settings['num_leds']}") - settings['num_leds'] = 100 - print(f"Updated number of LEDs: {settings['num_leds']}") - settings.save() - - # Create a new Settings object to test loading - new_settings = Settings() - print(f"Loaded number of LEDs: {new_settings['num_leds']}") - print(settings) - - - -# Run the example -if __name__ == "__main__": - main() diff --git a/src/utils.py b/src/utils.py deleted file mode 100644 index 8343b13..0000000 --- a/src/utils.py +++ /dev/null @@ -1,58 +0,0 @@ -def convert_and_reorder_colors(colors, settings_or_color_order): - """Convert hex color strings to RGB tuples and reorder based on device color order. - - Args: - colors: List of colors, either hex strings like "#FF0000" or RGB tuples like (255, 0, 0) - settings_or_color_order: Either a Settings object or a color_order string (e.g., "rgb", "grb") - - Returns: - List of RGB tuples reordered according to device color order - """ - # Get channel order from settings or color_order string - if hasattr(settings_or_color_order, 'get_rgb_channel_order'): - # It's a Settings object - channel_order = settings_or_color_order.get_rgb_channel_order() - elif isinstance(settings_or_color_order, str): - # It's a color_order string, convert to channel order - color_order = settings_or_color_order.lower() - color_orders = { - "rgb": (1, 3, 5), - "rbg": (1, 5, 3), - "grb": (3, 1, 5), - "gbr": (3, 5, 1), - "brg": (5, 1, 3), - "bgr": (5, 3, 1) - } - hex_indices = color_orders.get(color_order, (1, 3, 5)) - # Map hex string positions to RGB channel indices - hex_to_channel = {1: 0, 3: 1, 5: 2} - channel_order = tuple(hex_to_channel[pos] for pos in hex_indices) - else: - # Assume it's already a channel order tuple - channel_order = settings_or_color_order - - converted_colors = [] - for color in colors: - try: - # Convert "#RRGGBB" to (R, G, B) - if isinstance(color, str) and color.startswith("#") and len(color) == 7: - r = int(color[1:3], 16) - g = int(color[3:5], 16) - b = int(color[5:7], 16) - rgb = (r, g, b) - elif isinstance(color, (list, tuple)) and len(color) == 3: - # Already a tuple/list, just coerce and clamp. - rgb = tuple(max(0, min(255, int(x))) for x in color) - else: - # Unknown format: ignore safely. - continue - - # Reorder based on device color order - reordered = (rgb[channel_order[0]], rgb[channel_order[1]], rgb[channel_order[2]]) - converted_colors.append(reordered) - except (TypeError, ValueError, IndexError): - # Skip malformed color entries to avoid crashing pattern loops. - continue - if not converted_colors: - converted_colors.append((255, 255, 255)) - return converted_colors diff --git a/tests/all.py b/tests/all.py deleted file mode 100644 index a637f3e..0000000 --- a/tests/all.py +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env python3 -"""Self-contained led-driver test runner for MicroPython/mpremote.""" - -import json -import os -import utime -from machine import WDT - -from settings import Settings -from presets import Presets, run_tick -from utils import convert_and_reorder_colors - - -class _TestContext: - def __init__(self): - self.settings = Settings() - self.settings["name"] = self.settings.get("name", "test_device") - self.presets = Presets(self.settings["led_pin"], self.settings["num_leds"]) - self.presets.b = self.settings.get("brightness", 255) - self.wdt = WDT(timeout=10000) - - def tick_for_ms(self, duration_ms, sleep_ms=5): - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms: - self.wdt.feed() - run_tick(self.presets) - utime.sleep_ms(sleep_ms) - - -def _process_message(ctx, payload): - """Small test helper that mirrors the main message handling logic.""" - try: - if isinstance(payload, (bytes, bytearray)): - data = json.loads(payload) - elif isinstance(payload, str): - data = json.loads(payload) - else: - data = payload - except (TypeError, ValueError): - return "invalid_json" - - if not isinstance(data, dict): - return "invalid_shape" - if data.get("v") != "1": - return "wrong_version" - - if "b" in data: - try: - ctx.presets.b = max(0, min(255, int(data["b"]))) - except (TypeError, ValueError): - pass - - if isinstance(data.get("presets"), dict): - for name, preset_data in data["presets"].items(): - if not isinstance(preset_data, dict): - continue - color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None) - if color_key is not None: - try: - preset_data[color_key] = convert_and_reorder_colors( - preset_data[color_key], ctx.settings - ) - except (TypeError, ValueError): - continue - ctx.presets.edit(name, preset_data) - - if isinstance(data.get("select"), dict) and ctx.settings.get("name") in data["select"]: - select_list = data["select"][ctx.settings.get("name")] - if isinstance(select_list, list) and select_list: - preset_name = select_list[0] - step = select_list[1] if len(select_list) > 1 else None - if isinstance(preset_name, str): - ctx.presets.select(preset_name, step=step) - - if "default" in data: - default_name = data["default"] - this_device_name = ctx.settings.get("name") - this_device_name_norm = ( - this_device_name.strip().lower() - if isinstance(this_device_name, str) - else None - ) - should_apply_default = True - if "targets" in data: - should_apply_default = False - targets = data.get("targets") - if isinstance(targets, list) and this_device_name_norm: - normalized_targets = [ - target.strip().lower() - for target in targets - if isinstance(target, str) and target.strip() - ] - should_apply_default = this_device_name_norm in normalized_targets - if ( - should_apply_default - and - isinstance(default_name, str) - and default_name - and default_name in ctx.presets.presets - ): - ctx.settings["default"] = default_name - - if "save" in data: - ctx.presets.save() - - return "ok" - - -def test_invalid_messages_do_not_crash(): - ctx = _TestContext() - cases = [ - b"{not-json", - "[]", - json.dumps({"v": "2"}), - json.dumps({"v": "1", "presets": ["bad"]}), - json.dumps({"v": "1", "select": {"test_device": "not-list"}}), - json.dumps({"v": "1", "presets": {"x": {"c": ["#GG0000"]}}}), - ] - for payload in cases: - _process_message(ctx, payload) - ctx.wdt.feed() - - -def test_preset_edit_sanitization(): - ctx = _TestContext() - ctx.presets.edit( - "sanitize", - { - "pattern": "blink", - "delay": "120", - "brightness": "999", - "auto": "false", - "n1": "-5", - "n2": "7", - "unknown_field": "ignored", - }, - ) - p = ctx.presets.presets["sanitize"] - assert p.p == "blink" - assert p.d == 120 - assert p.b == 255 - assert p.a is False - assert p.n1 == 0 - assert p.n2 == 7 - assert not hasattr(p, "unknown_field") - - -def test_colour_conversion_and_transition(): - ctx = _TestContext() - msg = { - "v": "1", - "presets": { - "fade": { - "p": "transition", - "c": ["#ff0000", "#00ff00"], - "d": 80, - "a": True, - } - }, - "select": {ctx.settings["name"]: ["fade"]}, - } - result = _process_message(ctx, msg) - assert result == "ok" - assert ctx.presets.selected == "fade" - # Smoke-run the generator to ensure math runs without type errors. - ctx.tick_for_ms(250) - - -def test_pattern_smoke(): - ctx = _TestContext() - cases = { - "t_on": {"p": "on", "c": [(16, 8, 4)]}, - "t_off": {"p": "off"}, - "t_blink": {"p": "blink", "c": [(255, 0, 0)], "d": 20}, - "t_rainbow": {"p": "rainbow", "d": 5, "n1": 2}, - "t_pulse": {"p": "pulse", "c": [(255, 0, 0)], "n1": 20, "n2": 10, "n3": 20, "d": 10}, - "t_transition": {"p": "transition", "c": [(255, 0, 0), (0, 0, 255)], "d": 30}, - "t_chase": {"p": "chase", "c": [(255, 0, 0), (0, 0, 255)], "n1": 3, "n2": 2, "n3": 1, "n4": 1, "d": 20}, - "t_circle": {"p": "circle", "c": [(255, 255, 0), (0, 0, 8)], "n1": 5, "n2": 10, "n3": 5, "n4": 2}, - } - for name, data in cases.items(): - ctx.presets.edit(name, data) - assert ctx.presets.select(name), "select failed: %s" % name - ctx.tick_for_ms(120) - - -def test_default_requires_existing_preset(): - ctx = _TestContext() - _process_message(ctx, {"v": "1", "default": "missing"}) - assert ctx.settings.get("default") != "missing" - - ctx.presets.edit("exists", {"p": "on"}) - _process_message(ctx, {"v": "1", "default": "exists"}) - assert ctx.settings.get("default") == "exists" - -def test_default_targets_gate_by_device_name(): - ctx = _TestContext() - ctx.settings["name"] = "a" - ctx.presets.edit("targeted", {"p": "on"}) - ctx.settings["default"] = "baseline" - - _process_message( - ctx, - {"v": "1", "default": "targeted", "targets": ["11"]}, - ) - assert ctx.settings.get("default") == "baseline" - - _process_message( - ctx, - {"v": "1", "default": "targeted", "targets": [" A "]}, - ) - assert ctx.settings.get("default") == "targeted" - - -def test_save_and_load_roundtrip(): - ctx = _TestContext() - ctx.presets.edit( - "persist", - {"p": "blink", "c": [(1, 2, 3), (4, 5, 6)], "d": 77, "b": 123, "a": False}, - ) - assert ctx.presets.save() - - reloaded = Presets(ctx.settings["led_pin"], ctx.settings["num_leds"]) - assert reloaded.load(ctx.settings) - p = reloaded.presets.get("persist") - assert p is not None - assert p.p == "blink" - assert p.d == 77 - assert p.b == 123 - assert p.a is False - assert p.c == [(1, 2, 3), (4, 5, 6)] - - try: - os.remove("presets.json") - except OSError: - pass - - -def run_all(): - tests = [ - test_invalid_messages_do_not_crash, - test_preset_edit_sanitization, - test_colour_conversion_and_transition, - test_pattern_smoke, - test_default_requires_existing_preset, - test_default_targets_gate_by_device_name, - test_save_and_load_roundtrip, - ] - print("=" * 56) - print("led-driver self-contained tests") - print("=" * 56) - for test_func in tests: - print("Running %s ..." % test_func.__name__) - test_func() - print(" PASS") - print("-" * 56) - print("All tests passed") - - -if __name__ == "__main__": - run_all() diff --git a/tests/patterns/auto_manual.py b/tests/patterns/auto_manual.py deleted file mode 100644 index e779b79..0000000 --- a/tests/patterns/auto_manual.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, duration_ms): - """Run pattern for specified duration.""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - print("=" * 50) - print("Testing Auto and Manual Modes") - print("=" * 50) - - # Test 1: Rainbow in AUTO mode (continuous) - print("\nTest 1: Rainbow pattern in AUTO mode (should run continuously)") - p.edit("rainbow_auto", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": True, - }) - p.select("rainbow_auto") - print("Running rainbow_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Pattern ran continuously") - - # Test 2: Rainbow in MANUAL mode (one step per tick) - print("\nTest 2: Rainbow pattern in MANUAL mode (one step per tick)") - p.edit("rainbow_manual", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": False, - }) - p.select("rainbow_manual") - print("Calling tick() 5 times (should advance 5 steps)...") - for i in range(5): - run_tick(p) - utime.sleep_ms(100) # Small delay to see changes - print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}") - - # Check if generator stopped after one cycle - if p.generator is None: - print("✓ Manual mode: Generator stopped after one step (as expected)") - else: - print("⚠ Manual mode: Generator still active (may need multiple ticks)") - - # Test 3: Pulse in AUTO mode (continuous cycles) - print("\nTest 3: Pulse pattern in AUTO mode (should pulse continuously)") - p.edit("pulse_auto", { - "p": "pulse", - "b": 128, - "d": 100, - "n1": 500, # Attack - "n2": 200, # Hold - "n3": 500, # Decay - "c": [(255, 0, 0)], - "a": True, - }) - p.select("pulse_auto") - print("Running pulse_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Pulse ran continuously") - - # Test 4: Pulse in MANUAL mode (one cycle then stop) - print("\nTest 4: Pulse pattern in MANUAL mode (one cycle then stop)") - p.edit("pulse_manual", { - "p": "pulse", - "b": 128, - "d": 100, - "n1": 300, # Attack - "n2": 200, # Hold - "n3": 300, # Decay - "c": [(0, 255, 0)], - "a": False, - }) - p.select("pulse_manual") - print("Running pulse_manual until generator stops...") - tick_count = 0 - max_ticks = 200 # Safety limit - while p.generator is not None and tick_count < max_ticks: - run_tick(p) - tick_count += 1 - utime.sleep_ms(10) - - if p.generator is None: - print(f"✓ Manual mode: Pulse completed one cycle after {tick_count} ticks") - else: - print(f"⚠ Manual mode: Pulse still running after {tick_count} ticks") - - # Test 5: Transition in AUTO mode (continuous transitions) - print("\nTest 5: Transition pattern in AUTO mode (continuous transitions)") - p.edit("transition_auto", { - "p": "transition", - "b": 128, - "d": 500, - "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)], - "a": True, - }) - p.select("transition_auto") - print("Running transition_auto for 3 seconds...") - run_for(p, wdt, 3000) - print("✓ Auto mode: Transition ran continuously") - - # Test 6: Transition in MANUAL mode (one transition then stop) - print("\nTest 6: Transition pattern in MANUAL mode (one transition then stop)") - p.edit("transition_manual", { - "p": "transition", - "b": 128, - "d": 500, - "c": [(255, 0, 0), (0, 255, 0)], - "a": False, - }) - p.select("transition_manual") - print("Running transition_manual until generator stops...") - tick_count = 0 - max_ticks = 200 - while p.generator is not None and tick_count < max_ticks: - run_tick(p) - tick_count += 1 - utime.sleep_ms(10) - - if p.generator is None: - print(f"✓ Manual mode: Transition completed after {tick_count} ticks") - else: - print(f"⚠ Manual mode: Transition still running after {tick_count} ticks") - - # Test 7: Switching between auto and manual modes - print("\nTest 7: Switching between auto and manual modes") - p.edit("switch_test", { - "p": "rainbow", - "b": 128, - "d": 50, - "n1": 2, - "a": True, - }) - p.select("switch_test") - print("Running in auto mode for 1 second...") - run_for(p, wdt, 1000) - - # Switch to manual mode by editing the preset - print("Switching to manual mode...") - p.edit("switch_test", {"a": False}) - p.select("switch_test") # Re-select to apply changes - - print("Calling tick() 3 times in manual mode...") - for i in range(3): - run_tick(p) - utime.sleep_ms(100) - print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}") - - # Switch back to auto mode - print("Switching back to auto mode...") - p.edit("switch_test", {"a": True}) - p.select("switch_test") - print("Running in auto mode for 1 second...") - run_for(p, wdt, 1000) - print("✓ Successfully switched between auto and manual modes") - - # Cleanup - print("\nCleaning up...") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_tick(p) - utime.sleep_ms(100) - - print("\n" + "=" * 50) - print("All tests completed!") - print("=" * 50) - - -if __name__ == "__main__": - main() diff --git a/tests/patterns/blink.py b/tests/patterns/blink.py deleted file mode 100644 index 124de8f..0000000 --- a/tests/patterns/blink.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Create blink preset (use short-key fields: p=pattern, b=brightness, d=delay, c=colors) - p.edit("test_blink", { - "p": "blink", - "b": 64, - "d": 200, - "c": [(255, 0, 0), (0, 0, 255)], - }) - p.select("test_blink") - - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 1500: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -if __name__ == "__main__": - main() - - diff --git a/tests/patterns/chase.py b/tests/patterns/chase.py deleted file mode 100644 index 2e79c49..0000000 --- a/tests/patterns/chase.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1) - print("Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)") - p.edit("chase1", { - "p": "chase", - "b": 255, - "d": 200, - "n1": 5, - "n2": 5, - "n3": 1, - "n4": 1, - "c": [(255, 0, 0), (0, 255, 0)], - }) - p.select("chase1") - run_for(p, wdt, 3000) - - # Test 2: Forward and backward (n3=2, n4=-1) - print("Test 2: Forward and backward (n3=2, n4=-1)") - p.edit("chase2", { - "p": "chase", - "n1": 3, - "n2": 3, - "n3": 2, - "n4": -1, - "d": 150, - "c": [(0, 0, 255), (255, 255, 0)], - }) - p.select("chase2") - run_for(p, wdt, 3000) - - # Test 3: Large segments (n1=10, n2=5) - print("Test 3: Large segments (n1=10, n2=5, n3=3, n4=3)") - p.edit("chase3", { - "p": "chase", - "n1": 10, - "n2": 5, - "n3": 3, - "n4": 3, - "d": 200, - "c": [(255, 128, 0), (128, 0, 255)], - }) - p.select("chase3") - run_for(p, wdt, 3000) - - # Test 4: Fast movement (n3=5, n4=5) - print("Test 4: Fast movement (n3=5, n4=5)") - p.edit("chase4", { - "p": "chase", - "n1": 4, - "n2": 4, - "n3": 5, - "n4": 5, - "d": 100, - "c": [(255, 0, 255), (0, 255, 255)], - }) - p.select("chase4") - run_for(p, wdt, 2000) - - # Test 5: Backward movement (n3=-2, n4=-2) - print("Test 5: Backward movement (n3=-2, n4=-2)") - p.edit("chase5", { - "p": "chase", - "n1": 6, - "n2": 4, - "n3": -2, - "n4": -2, - "d": 200, - "c": [(255, 255, 255), (0, 0, 0)], - }) - p.select("chase5") - run_for(p, wdt, 3000) - - # Test 6: Alternating forward/backward (n3=3, n4=-2) - print("Test 6: Alternating forward/backward (n3=3, n4=-2)") - p.edit("chase6", { - "p": "chase", - "n1": 5, - "n2": 5, - "n3": 3, - "n4": -2, - "d": 250, - "c": [(255, 0, 0), (0, 255, 0)], - }) - p.select("chase6") - run_for(p, wdt, 4000) - - # Test 7: Manual mode - advance one step per beat - print("Test 7: Manual mode chase (auto=False, n3=2, n4=1)") - p.edit("chase_manual", { - "p": "chase", - "n1": 4, - "n2": 4, - "n3": 2, - "n4": 1, - "d": 200, - "c": [(255, 255, 0), (0, 255, 255)], - "a": False, - }) - p.step = 0 # Reset step counter - print(" Advancing pattern with 10 beats (select + tick)...") - for i in range(10): - p.select("chase_manual") # Simulate beat - restarts generator - run_tick(p) # Advance one step - utime.sleep_ms(500) # Pause to see the pattern - wdt.feed() - print(f" Beat {i+1}: step={p.step}") - - # Test 8: Verify step increments correctly in manual mode - print("Test 8: Verify step increments (auto=False)") - p.edit("chase_manual2", { - "p": "chase", - "n1": 3, - "n2": 3, - "n3": 1, - "n4": 1, - "a": False, - }) - p.step = 0 - initial_step = p.step - p.select("chase_manual2") - run_tick(p) - final_step = p.step - print(f" Step updated from {initial_step} to {final_step} (expected: 1)") - if final_step == 1: - print(" ✓ Step increment working correctly") - else: - print(f" ✗ Step increment mismatch! Expected 1, got {final_step}") - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() - diff --git a/tests/patterns/circle.py b/tests/patterns/circle.py deleted file mode 100644 index d84456f..0000000 --- a/tests/patterns/circle.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0) - print("Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)") - p.edit("circle1", { - "p": "circle", - "b": 255, - "n1": 50, # Head moves 50 LEDs/second - "n2": 100, # Max length 100 LEDs - "n3": 200, # Tail moves 200 LEDs/second - "n4": 0, # Min length 0 LEDs - "c": [(255, 0, 0)], # Red - }) - p.select("circle1") - run_for(p, wdt, 5000) - - # Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0) - print("Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)") - p.edit("circle2", { - "p": "circle", - "n1": 20, - "n2": 50, - "n3": 100, - "n4": 0, - "c": [(0, 255, 0)], # Green - }) - p.select("circle2") - run_for(p, wdt, 5000) - - # Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0) - print("Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)") - p.edit("circle3", { - "p": "circle", - "n1": 100, - "n2": 30, - "n3": 20, - "n4": 0, - "c": [(0, 0, 255)], # Blue - }) - p.select("circle3") - run_for(p, wdt, 5000) - - # Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10) - print("Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)") - p.edit("circle4", { - "p": "circle", - "n1": 50, - "n2": 40, - "n3": 100, - "n4": 10, - "c": [(255, 255, 0)], # Yellow - }) - p.select("circle4") - run_for(p, wdt, 5000) - - # Test 5: Very fast (n1=200, n2=20, n3=200, n4=0) - print("Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)") - p.edit("circle5", { - "p": "circle", - "n1": 200, - "n2": 20, - "n3": 200, - "n4": 0, - "c": [(255, 0, 255)], # Magenta - }) - p.select("circle5") - run_for(p, wdt, 3000) - - # Test 6: Very slow (n1=10, n2=25, n3=10, n4=0) - print("Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)") - p.edit("circle6", { - "p": "circle", - "n1": 10, - "n2": 25, - "n3": 10, - "n4": 0, - "c": [(0, 255, 255)], # Cyan - }) - p.select("circle6") - run_for(p, wdt, 5000) - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() - diff --git a/tests/patterns/off.py b/tests/patterns/off.py deleted file mode 100644 index bf7574b..0000000 --- a/tests/patterns/off.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Create an "off" preset (use short-key field `p` for pattern) - p.edit("test_off", {"p": "off"}) - p.select("test_off") - - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 200: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -if __name__ == "__main__": - main() - - diff --git a/tests/patterns/on.py b/tests/patterns/on.py deleted file mode 100644 index 1a3aea1..0000000 --- a/tests/patterns/on.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Create presets for on and off using the short-key fields that Presets expects - # Preset fields: - # p = pattern name, b = brightness, d = delay, c = list of (r,g,b) colors - p.edit("test_on", { - "p": "on", - "b": 64, - "d": 120, - "c": [(255, 0, 0), (0, 0, 255)], - }) - p.edit("test_off", {"p": "off"}) - - # ON phase - p.select("test_on") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 800: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - # OFF phase - p.select("test_off") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 100: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -if __name__ == "__main__": - main() - - diff --git a/tests/patterns/pulse.py b/tests/patterns/pulse.py deleted file mode 100644 index f1793dc..0000000 --- a/tests/patterns/pulse.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Simple single-color pulse - print("Test 1: Single-color pulse (attack=500, hold=500, decay=500, delay=500)") - p.edit("pulse1", { - "p": "pulse", - "b": 255, - "c": [(255, 0, 0)], - "n1": 500, # attack ms - "n2": 500, # hold ms - "n3": 500, # decay ms - "d": 500, # delay ms between pulses - "a": True, - }) - p.select("pulse1") - run_for(p, wdt, 5000) - - # Test 2: Faster pulse - print("Test 2: Fast pulse (attack=100, hold=100, decay=100, delay=100)") - p.edit("pulse2", { - "p": "pulse", - "n1": 100, - "n2": 100, - "n3": 100, - "d": 100, - "c": [(0, 255, 0)], - }) - p.select("pulse2") - run_for(p, wdt, 4000) - - # Test 3: Multi-color pulse cycle - print("Test 3: Multi-color pulse (red -> green -> blue)") - p.edit("pulse3", { - "p": "pulse", - "n1": 300, - "n2": 300, - "n3": 300, - "d": 200, - "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)], - "a": True, - }) - p.select("pulse3") - run_for(p, wdt, 6000) - - # Test 4: One-shot pulse (auto=False) - print("Test 4: Single pulse, auto=False") - p.edit("pulse4", { - "p": "pulse", - "n1": 400, - "n2": 0, - "n3": 400, - "d": 0, - "c": [(255, 255, 255)], - "a": False, - }) - p.select("pulse4") - # Run long enough to allow one full pulse cycle - run_for(p, wdt, 1500) - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 200) - - -if __name__ == "__main__": - main() - - diff --git a/tests/patterns/rainbow.py b/tests/patterns/rainbow.py deleted file mode 100644 index e0d0c8a..0000000 --- a/tests/patterns/rainbow.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Basic rainbow with auto=True (continuous) - print("Test 1: Basic rainbow (auto=True, n1=1)") - p.edit("rainbow1", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 1, - "a": True, - }) - p.select("rainbow1") - run_for(p, wdt, 3000) - - # Test 2: Fast rainbow - print("Test 2: Fast rainbow (low delay, n1=1)") - p.edit("rainbow2", { - "p": "rainbow", - "d": 50, - "n1": 1, - "a": True, - }) - p.select("rainbow2") - run_for(p, wdt, 2000) - - # Test 3: Slow rainbow - print("Test 3: Slow rainbow (high delay, n1=1)") - p.edit("rainbow3", { - "p": "rainbow", - "d": 500, - "n1": 1, - "a": True, - }) - p.select("rainbow3") - run_for(p, wdt, 3000) - - # Test 4: Low brightness rainbow - print("Test 4: Low brightness rainbow (n1=1)") - p.edit("rainbow4", { - "p": "rainbow", - "b": 64, - "d": 100, - "n1": 1, - "a": True, - }) - p.select("rainbow4") - run_for(p, wdt, 2000) - - # Test 5: Single-step rainbow (auto=False) - print("Test 5: Single-step rainbow (auto=False, n1=1)") - p.edit("rainbow5", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 1, - "a": False, - }) - p.step = 0 - for i in range(10): - p.select("rainbow5") - # One tick advances the generator one frame when auto=False - run_tick(p) - utime.sleep_ms(100) - wdt.feed() - - # Test 6: Verify step updates correctly - print("Test 6: Verify step updates (auto=False, n1=1)") - p.edit("rainbow6", { - "p": "rainbow", - "n1": 1, - "a": False, - }) - initial_step = p.step - p.select("rainbow6") - run_tick(p) - final_step = p.step - print(f"Step updated from {initial_step} to {final_step} (expected increment: 1)") - - # Test 7: Fast step increment (n1=5) - print("Test 7: Fast rainbow (n1=5, auto=True)") - p.edit("rainbow7", { - "p": "rainbow", - "b": 255, - "d": 100, - "n1": 5, - "a": True, - }) - p.select("rainbow7") - run_for(p, wdt, 2000) - - # Test 8: Very fast step increment (n1=10) - print("Test 8: Very fast rainbow (n1=10, auto=True)") - p.edit("rainbow8", { - "p": "rainbow", - "n1": 10, - "a": True, - }) - p.select("rainbow8") - run_for(p, wdt, 2000) - - # Test 9: Verify n1 controls step increment (auto=False) - print("Test 9: Verify n1 step increment (auto=False, n1=5)") - p.edit("rainbow9", { - "p": "rainbow", - "n1": 5, - "a": False, - }) - p.step = 0 - initial_step = p.step - p.select("rainbow9") - run_tick(p) - final_step = p.step - expected_step = (initial_step + 5) % 256 - print(f"Step updated from {initial_step} to {final_step} (expected: {expected_step})") - if final_step == expected_step: - print("✓ n1 step increment working correctly") - else: - print(f"✗ Step increment mismatch! Expected {expected_step}, got {final_step}") - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 100) - - -if __name__ == "__main__": - main() - diff --git a/tests/patterns/transition.py b/tests/patterns/transition.py deleted file mode 100644 index 8ff981e..0000000 --- a/tests/patterns/transition.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -import utime -from machine import WDT -from settings import Settings -from presets import Presets, run_tick - - -def run_for(p, wdt, ms): - """Helper: run current pattern for given ms using tick().""" - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < ms: - wdt.feed() - run_tick(p) - utime.sleep_ms(10) - - -def main(): - s = Settings() - pin = s.get("led_pin", 10) - num = s.get("num_leds", 30) - - p = Presets(pin=pin, num_leds=num) - wdt = WDT(timeout=10000) - - # Test 1: Simple two-color transition - print("Test 1: Two-color transition (red <-> blue, delay=1000)") - p.edit("transition1", { - "p": "transition", - "b": 255, - "d": 1000, # transition duration - "c": [(255, 0, 0), (0, 0, 255)], - "a": True, - }) - p.select("transition1") - run_for(p, wdt, 6000) - - # Test 2: Multi-color transition - print("Test 2: Multi-color transition (red -> green -> blue -> white)") - p.edit("transition2", { - "p": "transition", - "d": 800, - "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)], - "a": True, - }) - p.select("transition2") - run_for(p, wdt, 8000) - - # Test 3: One-shot transition (auto=False) - print("Test 3: One-shot transition (auto=False)") - p.edit("transition3", { - "p": "transition", - "d": 1000, - "c": [(255, 0, 0), (0, 255, 0)], - "a": False, - }) - p.select("transition3") - # Run long enough for a single transition step - run_for(p, wdt, 2000) - - # Test 4: Single-color behavior (should just stay on) - print("Test 4: Single-color transition (should hold color)") - p.edit("transition4", { - "p": "transition", - "c": [(0, 0, 255)], - "d": 500, - "a": True, - }) - p.select("transition4") - run_for(p, wdt, 3000) - - # Cleanup - print("Test complete, turning off") - p.edit("cleanup_off", {"p": "off"}) - p.select("cleanup_off") - run_for(p, wdt, 200) - - -if __name__ == "__main__": - main() - - diff --git a/tests/test_espnow_receive.py b/tests/test_espnow_receive.py deleted file mode 100644 index 4c3cb75..0000000 --- a/tests/test_espnow_receive.py +++ /dev/null @@ -1,694 +0,0 @@ -#!/usr/bin/env python3 -"""Test ESPNow receive functionality - runs on MicroPython device.""" -import json -import os -import utime -from settings import Settings -from presets import Presets, run_tick -from utils import convert_and_reorder_colors - - -class MockESPNow: - """Mock ESPNow for testing that can send messages.""" - def __init__(self): - self.messages = [] - self.active_state = False - - def active(self, state): - self.active_state = state - - def any(self): - """Return True if there are messages.""" - return len(self.messages) > 0 - - def recv(self): - """Receive a message (removes it from queue).""" - if self.messages: - return self.messages.pop(0) - return None, None - - def send_message(self, host, msg_data): - """Send a message by adding it to the queue (testing helper).""" - if isinstance(msg_data, dict): - msg = json.dumps(msg_data) - else: - msg = msg_data - self.messages.append((host, msg)) - - def clear(self): - """Clear all messages (testing helper).""" - self.messages = [] - - -from machine import WDT - -def get_wdt(): - """Get a real WDT instance for tests.""" - return WDT(timeout=10000) # 10 second timeout for tests - - -def run_main_loop_iterations(espnow, patterns, settings, wdt, max_iterations=10): - """Run main loop iterations until no messages or max reached.""" - iterations = 0 - results = [] - - while iterations < max_iterations: - wdt.feed() - run_tick(patterns) - - if espnow.any(): - host, msg = espnow.recv() - data = json.loads(msg) - - if data.get("v") != "1": - results.append(("version_rejected", data)) - continue - - if "presets" in data: - for name, preset_data in data["presets"].items(): - # Convert hex color strings to RGB tuples and reorder based on device color order - if "colors" in preset_data: - preset_data["colors"] = convert_and_reorder_colors(preset_data["colors"], settings) - patterns.edit(name, preset_data) - results.append(("presets_processed", list(data["presets"].keys()))) - - if settings.get("name") in data.get("select", {}): - select_list = data["select"][settings.get("name")] - # Select value is always a list: ["preset_name"] or ["preset_name", step] - if select_list: - preset_name = select_list[0] - step = select_list[1] if len(select_list) > 1 else None - if patterns.select(preset_name, step=step): - results.append(("selected", preset_name)) - - iterations += 1 - - # Stop if no more messages - if not espnow.any(): - break - - return results - - -def test_version_check(): - """Test that messages with wrong version are rejected.""" - print("Test 1: Version check") - settings = Settings() - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Send message with wrong version - mock_espnow.send_message(b"\xaa\xaa\xaa\xaa\xaa\xaa", {"v": "2", "presets": {"test": {"pattern": "on"}}}) - results = run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - assert len([r for r in results if r[0] == "version_rejected"]) > 0, "Should reject wrong version" - assert "test" not in patterns.presets, "Preset should not be created" - print(" ✓ Version check passed") - - # Send message with correct version - mock_espnow.clear() - mock_espnow.send_message(b"\xaa\xaa\xaa\xaa\xaa\xaa", {"v": "1", "presets": {"test": {"pattern": "on"}}}) - results = run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - assert len([r for r in results if r[0] == "presets_processed"]) > 0, "Should process correct version" - assert "test" in patterns.presets, "Preset should be created" - print(" ✓ Correct version accepted") - - -def test_preset_creation(): - """Test preset creation from ESPNow messages.""" - print("\nTest 2: Preset creation") - settings = Settings() - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - msg = { - "v": "1", - "presets": { - "test_blink": { - "pattern": "blink", - "colors": ["#FF0000", "#00FF00"], - "delay": 200, - "brightness": 128 - }, - "test_rainbow": { - "pattern": "rainbow", - "delay": 100, - "n1": 2 - } - } - } - - mock_espnow.send_message(b"\xbb\xbb\xbb\xbb\xbb\xbb", msg) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - assert "test_blink" in patterns.presets, "test_blink preset should exist" - assert "test_rainbow" in patterns.presets, "test_rainbow preset should exist" - - # Check preset values - blink_preset = patterns.presets["test_blink"] - assert blink_preset.pattern == "blink", "Pattern should be blink" - assert blink_preset.delay == 200, "Delay should be 200" - assert blink_preset.brightness == 128, "Brightness should be 128" - - rainbow_preset = patterns.presets["test_rainbow"] - assert rainbow_preset.pattern == "rainbow", "Pattern should be rainbow" - assert rainbow_preset.n1 == 2, "n1 should be 2" - - print(" ✓ Presets created correctly") - - -def test_color_conversion(): - """Test hex color string conversion and reordering.""" - print("\nTest 3: Color conversion") - settings = Settings() - settings["color_order"] = "rgb" # Default RGB order - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - msg = { - "v": "1", - "presets": { - "test_colors": { - "pattern": "on", - "colors": ["#FF0000", "#00FF00", "#0000FF"] # Red, Green, Blue - } - } - } - - mock_espnow.send_message(b"\xcc\xcc\xcc\xcc\xcc\xcc", msg) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - preset = patterns.presets["test_colors"] - assert len(preset.colors) == 3, "Should have 3 colors" - assert preset.colors[0] == (255, 0, 0), "First color should be red (255,0,0)" - assert preset.colors[1] == (0, 255, 0), "Second color should be green (0,255,0)" - assert preset.colors[2] == (0, 0, 255), "Third color should be blue (0,0,255)" - print(" ✓ Colors converted correctly (RGB order)") - - # Test GRB order - settings["color_order"] = "grb" - patterns2 = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow2 = MockESPNow() - msg2 = { - "v": "1", - "presets": { - "test_grb": { - "pattern": "on", - "colors": ["#FF0000"] # Red in RGB, should become (0, 255, 0) in GRB - } - } - } - mock_espnow2.send_message(b"\xdd\xdd\xdd\xdd\xdd\xdd", msg2) - wdt2 = get_wdt() - run_main_loop_iterations(mock_espnow2, patterns2, settings, wdt2) - preset2 = patterns2.presets["test_grb"] - assert preset2.colors[0] == (0, 255, 0), "GRB: Red should become green (0,255,0)" - print(" ✓ Colors reordered correctly (GRB order)") - - -def test_preset_update(): - """Test that editing an existing preset updates it.""" - print("\nTest 4: Preset update") - settings = Settings() - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Create initial preset - msg1 = { - "v": "1", - "presets": { - "test_update": { - "pattern": "blink", - "delay": 100, - "brightness": 64 - } - } - } - mock_espnow.send_message(b"\xee\xee\xee\xee\xee\xee", msg1) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.presets["test_update"].delay == 100, "Initial delay should be 100" - - # Update preset - mock_espnow.clear() - msg2 = { - "v": "1", - "presets": { - "test_update": { - "pattern": "blink", - "delay": 200, - "brightness": 128 - } - } - } - mock_espnow.send_message(b"\xff\xff\xff\xff\xff\xff", msg2) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.presets["test_update"].delay == 200, "Updated delay should be 200" - assert patterns.presets["test_update"].brightness == 128, "Updated brightness should be 128" - print(" ✓ Preset updated correctly") - - -def test_select(): - """Test preset selection.""" - print("\nTest 5: Preset selection") - settings = Settings() - settings["name"] = "device1" - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Create presets - msg1 = { - "v": "1", - "presets": { - "preset1": {"pattern": "on", "colors": [(255, 0, 0)]}, - "preset2": {"pattern": "rainbow", "delay": 50} - } - } - mock_espnow.send_message(b"\x11\x11\x11\x11\x11\x11", msg1) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - # Select preset - mock_espnow.clear() - msg2 = { - "v": "1", - "select": { - "device1": ["preset1"], - "device2": ["preset2"] - } - } - mock_espnow.send_message(b"\x22\x22\x22\x22\x22\x22", msg2) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "preset1", "Should select preset1" - print(" ✓ Preset selected correctly") - - -def test_full_message(): - """Test a full message with presets and select.""" - print("\nTest 6: Full message (presets + select)") - settings = Settings() - settings["name"] = "test_device" - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - msg = { - "v": "1", - "presets": { - "my_preset": { - "pattern": "pulse", - "colors": ["#FF0000", "#00FF00"], - "delay": 150, - "n1": 500, - "n2": 200, - "n3": 500 - } - }, - "select": { - "test_device": ["my_preset"], - "other_device": ["other_preset"] - } - } - - mock_espnow.send_message(b"\x44\x44\x44\x44\x44\x44", msg) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - assert "my_preset" in patterns.presets, "Preset should be created" - assert patterns.selected == "my_preset", "Preset should be selected" - - preset = patterns.presets["my_preset"] - assert preset.pattern == "pulse", "Pattern should be pulse" - assert preset.delay == 150, "Delay should be 150" - assert preset.n1 == 500, "n1 should be 500" - print(" ✓ Full message processed correctly") - - -def test_switch_presets(): - """Test switching between different presets.""" - print("\nTest 7: Switch between presets") - settings = Settings() - settings["name"] = "switch_device" - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Create multiple presets - msg1 = { - "v": "1", - "presets": { - "preset_blink": {"pattern": "blink", "delay": 200, "colors": [(255, 0, 0)]}, - "preset_rainbow": {"pattern": "rainbow", "delay": 100, "n1": 2}, - "preset_pulse": {"pattern": "pulse", "delay": 150, "n1": 500, "n2": 200, "n3": 500} - } - } - mock_espnow.send_message(b"\x55\x55\x55\x55\x55\x55", msg1) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - # Select and run first preset for 2 seconds - mock_espnow.clear() - msg2 = { - "v": "1", - "select": { - "switch_device": ["preset_blink"] - } - } - mock_espnow.send_message(b"\x66\x66\x66\x66\x66\x66", msg2) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "preset_blink", "Should select preset_blink" - print(" ✓ Selected preset_blink, running for 2 seconds...") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - wdt.feed() - run_tick(patterns) - utime.sleep_ms(10) - - # Switch to second preset and run for 2 seconds - mock_espnow.clear() - msg3 = { - "v": "1", - "select": { - "switch_device": ["preset_rainbow"] - } - } - mock_espnow.send_message(b"\x77\x77\x77\x77\x77\x77", msg3) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "preset_rainbow", "Should switch to preset_rainbow" - print(" ✓ Switched to preset_rainbow, running for 2 seconds...") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - wdt.feed() - run_tick(patterns) - utime.sleep_ms(10) - - # Switch to third preset and run for 2 seconds - mock_espnow.clear() - msg4 = { - "v": "1", - "select": { - "switch_device": ["preset_pulse"] - } - } - mock_espnow.send_message(b"\x88\x88\x88\x88\x88\x88", msg4) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "preset_pulse", "Should switch to preset_pulse" - print(" ✓ Switched to preset_pulse, running for 2 seconds...") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - wdt.feed() - run_tick(patterns) - utime.sleep_ms(10) - - # Switch back to first preset and run for 2 seconds - mock_espnow.clear() - msg5 = { - "v": "1", - "select": { - "switch_device": ["preset_blink"] - } - } - mock_espnow.send_message(b"\x99\x99\x99\x99\x99\x99", msg5) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "preset_blink", "Should switch back to preset_blink" - print(" ✓ Switched back to preset_blink, running for 2 seconds...") - start = utime.ticks_ms() - while utime.ticks_diff(utime.ticks_ms(), start) < 2000: - wdt.feed() - run_tick(patterns) - utime.sleep_ms(10) - - print(" ✓ Preset switching works correctly") - - -def test_beat_functionality(): - """Test beat functionality - calling select() again with same preset restarts pattern.""" - print("\nTest 8: Beat functionality") - settings = Settings() - settings["name"] = "beat_device" - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Create presets with manual mode - msg1 = { - "v": "1", - "presets": { - "beat_rainbow": {"pattern": "rainbow", "delay": 100, "n1": 1, "auto": False}, - "beat_chase": {"pattern": "chase", "delay": 200, "n1": 4, "n2": 4, "n3": 2, "n4": 1, "auto": False}, - "beat_pulse": {"pattern": "pulse", "delay": 150, "n1": 300, "n2": 100, "n3": 300, "auto": False} - } - } - mock_espnow.send_message(b"\xaa\xaa\xaa\xaa\xaa\xaa", msg1) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - # Test 1: Beat with rainbow (manual mode) - should advance one step per beat - print(" Test 8.1: Beat with rainbow (manual mode)") - patterns.step = 0 - mock_espnow.clear() - msg2 = { - "v": "1", - "select": { - "beat_device": ["beat_rainbow"] - } - } - mock_espnow.send_message(b"\xbb\xbb\xbb\xbb\xbb\xbb", msg2) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "beat_rainbow", "Should select beat_rainbow" - initial_step = patterns.step - - # First beat - advance one step - mock_espnow.clear() - mock_espnow.send_message(b"\xcc\xcc\xcc\xcc\xcc\xcc", msg2) # Same select message = beat - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=5) - # tick() is already called in run_main_loop_iterations, so step should be incremented - assert patterns.step == (initial_step + 1) % 256, f"Step should increment from {initial_step} to {(initial_step + 1) % 256}, got {patterns.step}" - - # Second beat - advance another step - mock_espnow.clear() - mock_espnow.send_message(b"\xdd\xdd\xdd\xdd\xdd\xdd", msg2) # Beat again - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=5) - assert patterns.step == (initial_step + 2) % 256, f"Step should increment to {(initial_step + 2) % 256}, got {patterns.step}" - print(" ✓ Rainbow beat advances one step per beat") - - # Test 2: Beat with chase (manual mode) - should advance one step per beat - print(" Test 8.2: Beat with chase (manual mode)") - patterns.step = 0 - mock_espnow.clear() - msg3 = { - "v": "1", - "select": { - "beat_device": ["beat_chase"] - } - } - mock_espnow.send_message(b"\xee\xee\xee\xee\xee\xee", msg3) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "beat_chase", "Should select beat_chase" - initial_step = patterns.step - - # First beat - mock_espnow.clear() - mock_espnow.send_message(b"\xff\xff\xff\xff\xff\xff", msg3) # Beat - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=5) - # tick() is already called in run_main_loop_iterations - assert patterns.step == initial_step + 1, f"Chase step should increment from {initial_step} to {initial_step + 1}, got {patterns.step}" - - # Second beat - mock_espnow.clear() - mock_espnow.send_message(b"\x11\x11\x11\x11\x11\x11", msg3) # Beat again - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=5) - assert patterns.step == initial_step + 2, f"Chase step should increment to {initial_step + 2}, got {patterns.step}" - print(" ✓ Chase beat advances one step per beat") - - # Test 3: Beat with pulse (manual mode) - should restart full cycle - print(" Test 8.3: Beat with pulse (manual mode)") - mock_espnow.clear() - msg4 = { - "v": "1", - "select": { - "beat_device": ["beat_pulse"] - } - } - mock_espnow.send_message(b"\x22\x22\x22\x22\x22\x22", msg4) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.selected == "beat_pulse", "Should select beat_pulse" - assert patterns.generator is not None, "Generator should be active" - - # First beat - should restart generator - initial_generator = patterns.generator - mock_espnow.clear() - mock_espnow.send_message(b"\x33\x33\x33\x33\x33\x33", msg4) # Beat - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - assert patterns.generator is not None, "Generator should still be active after beat" - assert patterns.generator != initial_generator, "Generator should be restarted (new instance)" - print(" ✓ Pulse beat restarts generator for full cycle") - - # Test 4: Multiple beats in sequence - print(" Test 8.4: Multiple beats in sequence") - patterns.step = 0 - mock_espnow.clear() - mock_espnow.send_message(b"\x44\x44\x44\x44\x44\x44", msg2) # Select rainbow - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - # Send 5 beats - for i in range(5): - mock_espnow.clear() - mock_espnow.send_message(b"\x55\x55\x55\x55\x55\x55", msg2) # Beat - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=5) - # tick() is already called in run_main_loop_iterations - wdt.feed() - utime.sleep_ms(50) - - assert patterns.step == 5, f"After 5 beats, step should be 5, got {patterns.step}" - print(" ✓ Multiple beats work correctly") - - print(" ✓ Beat functionality works correctly") - - -def test_select_with_step(): - """Test selecting a preset with an explicit step value.""" - print("\nTest 9: Select with step value") - settings = Settings() - settings["name"] = "step_device" - patterns = Presets(settings["led_pin"], settings["num_leds"]) - mock_espnow = MockESPNow() - wdt = get_wdt() - - # Create preset - msg1 = { - "v": "1", - "presets": { - "step_preset": {"pattern": "rainbow", "delay": 100, "n1": 1, "auto": False} - } - } - mock_espnow.send_message(b"\xaa\xaa\xaa\xaa\xaa\xaa", msg1) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt) - - # Select with explicit step value - mock_espnow.clear() - msg2 = { - "v": "1", - "select": { - "step_device": ["step_preset", 10] - } - } - mock_espnow.send_message(b"\xbb\xbb\xbb\xbb\xbb\xbb", msg2) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=2) - # Ensure tick() is called after select() to advance the step - run_tick(patterns) - - assert patterns.selected == "step_preset", "Should select step_preset" - # Step is set to 10, then tick() advances it, so it should be 11 - assert patterns.step == 11, f"Step should be set to 10 then advanced to 11 by tick(), got {patterns.step}" - print(" ✓ Step value set correctly") - - # Select without step (should use default behavior) - mock_espnow.clear() - msg3 = { - "v": "1", - "select": { - "step_device": ["step_preset"] - } - } - mock_espnow.send_message(b"\xcc\xcc\xcc\xcc\xcc\xcc", msg3) - initial_step = patterns.step # Should be 11 - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=2) - # Ensure tick() is called after select() to advance the step - run_tick(patterns) - # Since it's the same preset, step should not be reset, but tick() will advance it - # So step should be initial_step + 1 (one tick call) - assert patterns.step == initial_step + 1, f"Step should advance from {initial_step} to {initial_step + 1} (not reset), got {patterns.step}" - print(" ✓ Step preserved when selecting same preset without step (tick advances it)") - - # Select different preset with step - patterns.edit("other_preset", {"p": "rainbow", "a": False}) - mock_espnow.clear() - msg4 = { - "v": "1", - "select": { - "step_device": ["other_preset", 5] - } - } - mock_espnow.send_message(b"\xdd\xdd\xdd\xdd\xdd\xdd", msg4) - run_main_loop_iterations(mock_espnow, patterns, settings, wdt, max_iterations=2) - # Ensure tick() is called after select() to advance the step - run_tick(patterns) - - assert patterns.selected == "other_preset", "Should select other_preset" - # Step is set to 5, then tick() advances it, so it should be 6 - assert patterns.step == 6, f"Step should be set to 5 then advanced to 6 by tick(), got {patterns.step}" - print(" ✓ Step set correctly when switching presets") - - -def test_preset_save_load(): - """Test saving and loading presets to/from JSON.""" - print("\nTest 10: Preset save/load") - settings = Settings() - patterns = Presets(settings["led_pin"], settings["num_leds"]) - - patterns.edit("saved_preset", { - "p": "blink", - "d": 150, - "b": 200, - "c": [(1, 2, 3), (4, 5, 6)], - "a": False, - "n1": 1, - "n2": 2, - "n3": 3, - "n4": 4, - "n5": 5, - "n6": 6, - }) - assert patterns.save(), "Save should return True" - - reloaded = Presets(settings["led_pin"], settings["num_leds"]) - assert reloaded.load(settings), "Load should return True" - - preset = reloaded.presets.get("saved_preset") - assert preset is not None, "Preset should be loaded" - assert preset.p == "blink", "Pattern should be blink" - assert preset.d == 150, "Delay should be 150" - assert preset.b == 200, "Brightness should be 200" - assert preset.c == [(1, 2, 3), (4, 5, 6)], "Colors should be restored as tuples" - assert preset.a is False, "Auto should be False" - assert (preset.n1, preset.n2, preset.n3, preset.n4, preset.n5, preset.n6) == (1, 2, 3, 4, 5, 6), "n1-n6 should match" - try: - os.remove("presets.json") - except OSError: - pass - print(" ✓ Preset save/load works correctly") - - -def main(): - """Run all tests.""" - print("=" * 60) - print("ESPNow Receive Functionality Tests") - print("=" * 60) - - try: - test_version_check() - test_preset_creation() - test_color_conversion() - test_preset_update() - test_select() - test_full_message() - test_switch_presets() - test_beat_functionality() - test_select_with_step() - test_preset_save_load() - - print("\n" + "=" * 60) - print("All tests passed! ✓") - print("=" * 60) - except AssertionError as e: - print("\n✗ Test failed:", e) - raise - except Exception as e: - print("\n✗ Unexpected error:", e) - raise - - -if __name__ == "__main__": - main() diff --git a/tests/test_mdns.py b/tests/test_mdns.py deleted file mode 100644 index 1083d7c..0000000 --- a/tests/test_mdns.py +++ /dev/null @@ -1,239 +0,0 @@ -#!/usr/bin/env python3 -"""mDNS smoke test — runs on the MicroPython device (ESP32). - -Loads Wi-Fi credentials from /settings.json via Settings (same as main firmware). -Sets the network hostname before connect so the chip can advertise as .local -on builds where mDNS is enabled (see network.hostname() in MicroPython docs). - -By default the script stays connected until you stop it (reset or mpremote Ctrl+C) so you -can ping the mDNS name from another machine (e.g. name "a" -> leda.local; hyphens are omitted -in the hostname because ESP32 mDNS often breaks on '-'). - -After flashing, do a full hardware reset once so the first DHCP sees the new hostname. - -Deploy src to the device (including utils.py with mdns_hostname), then from the host: - - mpremote connect PORT run tests/test_mdns.py - -Copy ``utils.py`` from ``src/`` onto the device if imports fail. - -Or with cwd led-driver: - - mpremote connect /dev/ttyUSB0 run tests/test_mdns.py -""" - -import time -import network -import socket - -import utime -from machine import WDT - -from settings import Settings -from utils import mdns_hostname - -CONNECT_TIMEOUT_S = 45 -# ESP32 MicroPython WDT timeout is capped (typically 10000 ms). Longer blocking work -# (PHY calibration) runs with no WDT; WDT is only used in HOLD_S. -WDT_TIMEOUT_MS = 10000 -# socket.getaddrinfo(".local", …) often hangs a long time or indefinitely on ESP32; off by default. -SELF_LOCAL_GETADDRINFO = False -# After checks: 0 = exit immediately; >0 = stay up that many seconds; -1 = until reset/Ctrl+C (for remote ping). -HOLD_S = -1 -# Set False to silence [mdns-test] timing lines (phase labels + elapsed ms since test start). -DEBUG = True - - -def _dbg(t0, msg): - if not DEBUG: - return - ms = utime.ticks_diff(utime.ticks_ms(), t0) - print("[mdns-test +%dms] %s" % (ms, msg)) - - -def _set_hostname(h, sta): - """Apply hostname on this STA object before active(True) / connect (DHCP + mDNS).""" - try: - network.hostname(h) - how = "network.hostname" - except (AttributeError, ValueError, OSError) as e: - how = None - last = e - try: - sta.config(hostname=h) - how = how or "WLAN.config(hostname=)" - except (AttributeError, ValueError, OSError) as e: - if how is None: - last = e - if how: - return how - print("Warning: could not set hostname (%s); mDNS name may be default." % last) - return None - - -def _sta_ip(sta): - try: - pair = sta.ipconfig("addr4") - if isinstance(pair, tuple) and pair: - return pair[0].split("/")[0] if isinstance(pair[0], str) else str(pair[0]) - except (AttributeError, OSError, TypeError, ValueError): - pass - return sta.ifconfig()[0] - - -def _wait_wifi(sta, timeout_s, wdt, t0): - """Wait for connection. If wdt is set, feed each iteration (keep gap < WDT_TIMEOUT_MS).""" - deadline = utime.ticks_add(utime.ticks_ms(), int(timeout_s * 1000)) - n = 0 - while not sta.isconnected(): - if utime.ticks_diff(deadline, utime.ticks_ms()) <= 0: - _dbg(t0, "WiFi wait TIMEOUT after %d iterations, status=%s" % (n, sta.status())) - return False - st = sta.status() - n += 1 - if DEBUG: - _dbg(t0, "WiFi wait iter #%d status=%s" % (n, st)) - else: - print("WiFi status:", st, "(waiting)") - if wdt is not None: - wdt.feed() - time.sleep(1) - if wdt is not None: - wdt.feed() - _dbg(t0, "WiFi connected after %d wait iterations" % n) - return True - - -def _try_resolve_local(hostname, t0): - """Best-effort: resolve our own *.local via getaddrinfo (often blocks a very long time on ESP32).""" - fqdn = hostname + ".local" - _dbg(t0, "getaddrinfo(%r) starting (may block a long time)" % fqdn) - t_gai = utime.ticks_ms() - try: - ai = socket.getaddrinfo(fqdn, 80) - dt = utime.ticks_diff(utime.ticks_ms(), t_gai) - print("getaddrinfo(%r) -> %s" % (fqdn, ai)) - _dbg(t0, "getaddrinfo OK (call took %dms)" % dt) - return True - except OSError as e: - dt = utime.ticks_diff(utime.ticks_ms(), t_gai) - print("getaddrinfo(%r) failed: %s" % (fqdn, e)) - _dbg(t0, "getaddrinfo OSError after %dms" % dt) - return False - - -def main(): - t0 = utime.ticks_ms() - _dbg(t0, "start") - - settings = Settings() - _dbg(t0, "Settings() loaded") - ssid = settings.get("ssid") or "" - password = settings.get("password") or "" - if not ssid: - print("mDNS test skipped: ssid empty in settings.json (configure Wi-Fi to run this test).") - raise SystemExit(0) - - hostname = mdns_hostname(settings) - _dbg(t0, "mdns_hostname -> %r" % hostname) - sta = network.WLAN(network.STA_IF) - how = _set_hostname(hostname, sta) - if how: - print("Hostname set via %s: %r" % (how, hostname)) - _dbg(t0, "set_hostname done (%s)" % (how or "failed")) - - _dbg(t0, "before sta.active(True) (often slow: RF calibration)") - print("WiFi active(True) (can take a while for calibration)...") - sta.active(True) - _dbg(t0, "after sta.active(True)") - try: - sta.config(pm=network.WLAN.PM_NONE) - _dbg(t0, "sta.config(pm=PM_NONE) OK") - except (AttributeError, ValueError, TypeError) as e: - _dbg(t0, "sta.config(pm=PM_NONE) skipped: %s" % e) - - print("Connecting to SSID %r ..." % ssid) - _dbg(t0, "before sta.connect()") - sta.connect(ssid, password) - _dbg(t0, "after sta.connect() (returned; association may still be in progress)") - # No WDT during calibration/wait/getaddrinfo — they can block longer than WDT_TIMEOUT_MS. - if not _wait_wifi(sta, CONNECT_TIMEOUT_S, None, t0): - print("Timeout: not connected. status=", sta.status()) - raise SystemExit(1) - - ip = _sta_ip(sta) - print("WiFi OK, IP:", ip) - try: - stack_host = network.hostname() - except (AttributeError, ValueError, TypeError, OSError): - stack_host = None - if stack_host: - print( - "mDNS: use what the stack reports — ping %s.local (avahi-resolve -n %s.local)" - % (stack_host, stack_host) - ) - if str(stack_host) != str(hostname): - print( - "(We asked for %r but stack reports %r — ping the stack name; cold boot may help.)" - % (hostname, stack_host) - ) - else: - print("From another machine: ping %s.local" % hostname) - print("(or: avahi-resolve -n %s.local)" % hostname) - - if SELF_LOCAL_GETADDRINFO: - _try_resolve_local(hostname, t0) - else: - _dbg( - t0, - "skip getaddrinfo(%s.local): SELF_LOCAL_GETADDRINFO=False (on-device self-.local lookup often hangs)" - % hostname, - ) - print( - "Skipped on-device getaddrinfo(*.local); verify mDNS from a PC (ping above). " - "Set SELF_LOCAL_GETADDRINFO = True to attempt (may hang)." - ) - - if HOLD_S != 0: - forever = HOLD_S < 0 - _dbg( - t0, - "starting WDT(%dms) + hold %s" - % (WDT_TIMEOUT_MS, "forever" if forever else ("%ds" % HOLD_S)), - ) - wdt = WDT(timeout=WDT_TIMEOUT_MS) - wdt.feed() - if forever: - ping_target = stack_host or hostname - print( - "Staying online until you stop (reset device or mpremote Ctrl+C). " - "From another host: ping %s.local" % ping_target - ) - else: - print("Keeping connection up for %d s (Ctrl+C or reset to stop) ..." % HOLD_S) - end = None if forever else utime.ticks_add(utime.ticks_ms(), HOLD_S * 1000) - hold_i = 0 - while True: - wdt.feed() - time.sleep(2) - hold_i += 1 - if not sta.isconnected(): - print("lost WiFi connection") - break - if forever: - if DEBUG and hold_i % 15 == 0: - _dbg(t0, "hold alive #%d IP %s" % (hold_i, _sta_ip(sta))) - else: - _dbg(t0, "hold tick #%d" % hold_i) - print("still connected, IP", _sta_ip(sta)) - if utime.ticks_diff(end, utime.ticks_ms()) <= 0: - break - - _dbg(t0, "hold loop finished") - - _dbg(t0, "Done.") - print("Done.") - - -if __name__ == "__main__": - main() diff --git a/tests/test_wifi.py b/tests/test_wifi.py deleted file mode 100644 index 4e742f0..0000000 --- a/tests/test_wifi.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python3 -"""Wi-Fi connection smoke test for MicroPython on ESP32. - -Runs on-device via mpremote and uses /settings.json credentials. - -Usage: - mpremote connect /dev/ttyACM0 run tests/test_wifi.py -""" - -import time -import utime -import network -from machine import WDT - -from settings import Settings - -CONNECT_TIMEOUT_S = 30 -RETRY_DELAY_S = 2 -WDT_TIMEOUT_MS = 10000 - - -def _wifi_status_label(code): - names = { - getattr(network, "STAT_IDLE", 0): "idle", - getattr(network, "STAT_CONNECTING", 1): "connecting", - getattr(network, "STAT_WRONG_PASSWORD", -3): "wrong_password", - getattr(network, "STAT_NO_AP_FOUND", -2): "no_ap_found", - getattr(network, "STAT_CONNECT_FAIL", -1): "connect_fail", - getattr(network, "STAT_GOT_IP", 3): "got_ip", - } - return names.get(code, str(code)) - - -def connect_wifi_with_wdt(sta, ssid, password, wdt): - attempt = 0 - while not sta.isconnected(): - attempt += 1 - print("[wifi-test] attempt", attempt, "ssid=", repr(ssid)) - try: - sta.disconnect() - except Exception: - pass - sta.connect(ssid, password) - - start = utime.time() - last_status = None - while not sta.isconnected(): - status = sta.status() - if status != last_status: - print("[wifi-test] status:", status, _wifi_status_label(status)) - last_status = status - if status in ( - getattr(network, "STAT_WRONG_PASSWORD", -3), - getattr(network, "STAT_NO_AP_FOUND", -2), - getattr(network, "STAT_CONNECT_FAIL", -1), - ): - break - if utime.time() - start >= CONNECT_TIMEOUT_S: - print("[wifi-test] timeout after", CONNECT_TIMEOUT_S, "seconds") - break - time.sleep(1) - wdt.feed() - - if sta.isconnected(): - return True - - print("[wifi-test] retry in", RETRY_DELAY_S, "seconds") - for _ in range(RETRY_DELAY_S): - time.sleep(1) - wdt.feed() - return True - - -def main(): - settings = Settings() - ssid = settings.get("ssid") or "" - password = settings.get("password") or "" - - if not ssid: - print("[wifi-test] skipped: settings.ssid is empty") - raise SystemExit(0) - - wdt = WDT(timeout=WDT_TIMEOUT_MS) - wdt.feed() - - sta = network.WLAN(network.STA_IF) - sta.active(True) - try: - sta.config(pm=network.WLAN.PM_NONE) - except (AttributeError, ValueError, TypeError): - pass - - ok = connect_wifi_with_wdt(sta, ssid, password, wdt) - if not ok or not sta.isconnected(): - print("[wifi-test] FAILED: not connected") - raise SystemExit(1) - - print("[wifi-test] OK:", sta.ifconfig()) - - -if __name__ == "__main__": - main() diff --git a/tests/udp_client.py b/tests/udp_client.py deleted file mode 100644 index 61a1052..0000000 --- a/tests/udp_client.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""UDP discovery test — runs on MicroPython (ESP32). - -Brings up Wi-Fi from settings (test harness only), then **`hello.discover_controller_udp(device_name, wdt)`**. -`hello` does not use Settings or connect Wi‑Fi. - -In firmware, **`main.py`** discovers the controller IP in RAM for HTTP; it is not written to settings. - -Deploy `src` (including `hello.py`), then from host with cwd `led-driver`: - - mpremote connect PORT run tests/udp_client.py -""" - -import time - -import network -import utime -from machine import WDT - -from hello import discover_controller_udp -from settings import Settings - -CONNECT_WAIT_S = 45 -WDT_MS = 10000 - - -def _wait_wifi(sta, timeout_s, wdt): - deadline = utime.ticks_add(utime.ticks_ms(), int(timeout_s * 1000)) - while not sta.isconnected(): - wdt.feed() - if utime.ticks_diff(deadline, utime.ticks_ms()) <= 0: - return False - print("WiFi status:", sta.status()) - wdt.feed() - time.sleep(1) - wdt.feed() - return True - - -def main(): - settings = Settings() - ssid = settings.get("ssid") or "" - password = settings.get("password") or "" - - if not ssid: - print("udp_client: set ssid/password in settings.json (test harness Wi-Fi).") - raise SystemExit(1) - - sta = network.WLAN(network.STA_IF) - sta.active(True) - try: - sta.config(pm=network.WLAN.PM_NONE) - except (AttributeError, ValueError, TypeError): - pass - - wdt = WDT(timeout=WDT_MS) - wdt.feed() - print("udp_client: connecting to", repr(ssid)) - sta.connect(ssid, password) - wdt.feed() - if not _wait_wifi(sta, CONNECT_WAIT_S, wdt): - print("WiFi timeout, status=", sta.status()) - raise SystemExit(1) - - ip = discover_controller_udp(settings.get("name", ""), wdt=wdt) - if not ip: - raise SystemExit(1) - - -if __name__ == "__main__": - main()