diff --git a/.gitignore b/.gitignore
index 21c6461..041b41d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,12 @@ __pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
-firmware/panel/build/
-firmware/_deps/
+.mypy_cache/
+.ruff_cache/
+dist/
+build/
+*.egg
+.DS_Store
+Thumbs.db
+firmware/pico/build/
+*.uf2
diff --git a/Makefile b/Makefile
index ec004fd..6b2115f 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
# make deploy Build and flash panel firmware over USB
# make build Build only
-.PHONY: build deploy flash upload monitor clean help
+.PHONY: build deploy flash upload reset monitor clean help
_PANEL_MAKE_FLAGS :=
ifdef PANEL_ID
@@ -12,15 +12,15 @@ endif
ifdef NO_DHCP
_PANEL_MAKE_FLAGS += NO_DHCP=$(NO_DHCP)
endif
-ifdef NUM_LEDS
-_PANEL_MAKE_FLAGS += NUM_LEDS=$(NUM_LEDS)
-endif
ifdef WS2812_PIN
_PANEL_MAKE_FLAGS += WS2812_PIN=$(WS2812_PIN)
endif
+ifdef WS2812_PIN1
+_PANEL_MAKE_FLAGS += WS2812_PIN1=$(WS2812_PIN1)
+endif
help:
- @$(MAKE) -C firmware/panel help
+ @$(MAKE) -C firmware/pico help
-build deploy flash upload monitor clean:
- @$(MAKE) -C firmware/panel $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)
+build deploy flash upload reset monitor clean:
+ @$(MAKE) -C firmware/pico $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)
diff --git a/Pipfile b/Pipfile
index 5554374..6f65334 100644
--- a/Pipfile
+++ b/Pipfile
@@ -5,6 +5,10 @@ name = "pypi"
[packages]
portal = {editable = true, path = "."}
+"uvicorn[standard]" = "*"
+fastapi = "*"
+uvicorn = {extras = ["standard"], version = "*"}
+pillow = "*"
[dev-packages]
diff --git a/Pipfile.lock b/Pipfile.lock
index 437e46c..d3310e5 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "2d6705b77e526b8f5f55e85b57b98d5e5249097e4d02415229aabbd0744e0fa1"
+ "sha256": "0295b09710d67de544fc79d41d9aa8cc8f53ba2fd3309736bd7866608ca9979d"
},
"pipfile-spec": 6,
"requires": {
@@ -16,10 +16,438 @@
]
},
"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:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72",
+ "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==4.14.1"
+ },
+ "click": {
+ "hashes": [
+ "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6",
+ "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==8.4.2"
+ },
+ "fastapi": {
+ "hashes": [
+ "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8",
+ "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.10'",
+ "version": "==0.138.2"
+ },
+ "h11": {
+ "hashes": [
+ "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
+ "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==0.16.0"
+ },
+ "httptools": {
+ "hashes": [
+ "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683",
+ "sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb",
+ "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b",
+ "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527",
+ "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124",
+ "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca",
+ "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081",
+ "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c",
+ "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77",
+ "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09",
+ "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f",
+ "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085",
+ "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376",
+ "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5",
+ "sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62",
+ "sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5",
+ "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8",
+ "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681",
+ "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999",
+ "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1",
+ "sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005",
+ "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d",
+ "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d",
+ "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d",
+ "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d",
+ "sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba",
+ "sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247",
+ "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745",
+ "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07",
+ "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b",
+ "sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4",
+ "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2",
+ "sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557",
+ "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d",
+ "sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826",
+ "sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b",
+ "sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813",
+ "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0",
+ "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150",
+ "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e",
+ "sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77",
+ "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568",
+ "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6",
+ "sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8",
+ "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b",
+ "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7",
+ "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168",
+ "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a",
+ "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0",
+ "sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==0.8.0"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
+ "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==3.18"
+ },
+ "pillow": {
+ "hashes": [
+ "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756",
+ "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a",
+ "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59",
+ "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45",
+ "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3",
+ "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df",
+ "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139",
+ "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b",
+ "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39",
+ "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e",
+ "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8",
+ "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1",
+ "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8",
+ "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89",
+ "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5",
+ "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130",
+ "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd",
+ "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d",
+ "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b",
+ "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed",
+ "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace",
+ "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb",
+ "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931",
+ "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510",
+ "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6",
+ "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1",
+ "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce",
+ "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385",
+ "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e",
+ "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c",
+ "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7",
+ "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace",
+ "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c",
+ "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f",
+ "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64",
+ "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f",
+ "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a",
+ "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827",
+ "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17",
+ "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4",
+ "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a",
+ "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701",
+ "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e",
+ "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91",
+ "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66",
+ "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468",
+ "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217",
+ "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658",
+ "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418",
+ "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a",
+ "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c",
+ "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330",
+ "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402",
+ "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09",
+ "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930",
+ "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f",
+ "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec",
+ "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a",
+ "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94",
+ "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468",
+ "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b",
+ "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965",
+ "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8",
+ "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd",
+ "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7",
+ "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c",
+ "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777",
+ "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35",
+ "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9",
+ "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f",
+ "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f",
+ "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0",
+ "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c",
+ "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71",
+ "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3",
+ "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838",
+ "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf",
+ "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321",
+ "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26",
+ "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec",
+ "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9",
+ "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65",
+ "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5",
+ "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e",
+ "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d",
+ "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198",
+ "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.10'",
+ "version": "==12.3.0"
+ },
"portal": {
"editable": true,
"path": "."
},
+ "pydantic": {
+ "hashes": [
+ "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
+ "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==2.13.4"
+ },
+ "pydantic-core": {
+ "hashes": [
+ "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0",
+ "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262",
+ "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda",
+ "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0",
+ "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e",
+ "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b",
+ "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594",
+ "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29",
+ "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2",
+ "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c",
+ "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d",
+ "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398",
+ "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d",
+ "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3",
+ "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f",
+ "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb",
+ "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7",
+ "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5",
+ "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9",
+ "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462",
+ "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4",
+ "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b",
+ "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d",
+ "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df",
+ "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2",
+ "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0",
+ "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519",
+ "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd",
+ "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7",
+ "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac",
+ "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6",
+ "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565",
+ "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898",
+ "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb",
+ "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928",
+ "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6",
+ "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3",
+ "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a",
+ "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596",
+ "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987",
+ "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e",
+ "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d",
+ "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712",
+ "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008",
+ "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd",
+ "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1",
+ "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be",
+ "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea",
+ "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292",
+ "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33",
+ "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3",
+ "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4",
+ "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b",
+ "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826",
+ "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac",
+ "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7",
+ "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d",
+ "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf",
+ "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4",
+ "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc",
+ "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15",
+ "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3",
+ "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b",
+ "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914",
+ "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04",
+ "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c",
+ "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b",
+ "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9",
+ "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce",
+ "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4",
+ "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a",
+ "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f",
+ "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424",
+ "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894",
+ "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9",
+ "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76",
+ "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201",
+ "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb",
+ "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109",
+ "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4",
+ "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848",
+ "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526",
+ "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0",
+ "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01",
+ "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458",
+ "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e",
+ "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba",
+ "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a",
+ "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39",
+ "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c",
+ "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000",
+ "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b",
+ "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf",
+ "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4",
+ "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd",
+ "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28",
+ "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9",
+ "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30",
+ "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983",
+ "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1",
+ "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76",
+ "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5",
+ "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4",
+ "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7",
+ "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c",
+ "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066",
+ "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3",
+ "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02",
+ "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89",
+ "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50",
+ "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76",
+ "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49",
+ "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b",
+ "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d",
+ "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7",
+ "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4",
+ "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c",
+ "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e",
+ "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff",
+ "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==2.46.4"
+ },
+ "python-dotenv": {
+ "hashes": [
+ "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
+ "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==1.2.2"
+ },
+ "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"
+ },
"rpi-ws281x": {
"hashes": [
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
@@ -33,6 +461,276 @@
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
],
"version": "==3.8"
+ },
+ "starlette": {
+ "hashes": [
+ "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0",
+ "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==1.3.1"
+ },
+ "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": {
+ "extras": [
+ "standard"
+ ],
+ "hashes": [
+ "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f",
+ "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==0.49.0"
+ },
+ "uvloop": {
+ "hashes": [
+ "sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772",
+ "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e",
+ "sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743",
+ "sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54",
+ "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec",
+ "sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659",
+ "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8",
+ "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad",
+ "sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7",
+ "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35",
+ "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289",
+ "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142",
+ "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77",
+ "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733",
+ "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd",
+ "sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193",
+ "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74",
+ "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0",
+ "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6",
+ "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473",
+ "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21",
+ "sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242",
+ "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705",
+ "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702",
+ "sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6",
+ "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f",
+ "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e",
+ "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d",
+ "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370",
+ "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4",
+ "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792",
+ "sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa",
+ "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079",
+ "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2",
+ "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86",
+ "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6",
+ "sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4",
+ "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3",
+ "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21",
+ "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c",
+ "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e",
+ "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25",
+ "sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820",
+ "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9",
+ "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88",
+ "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2",
+ "sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c",
+ "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c",
+ "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"
+ ],
+ "markers": "python_full_version >= '3.8.1'",
+ "version": "==0.22.1"
+ },
+ "watchfiles": {
+ "hashes": [
+ "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
+ "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98",
+ "sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551",
+ "sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d",
+ "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7",
+ "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db",
+ "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69",
+ "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242",
+ "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925",
+ "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f",
+ "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5",
+ "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5",
+ "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427",
+ "sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19",
+ "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4",
+ "sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e",
+ "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa",
+ "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba",
+ "sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df",
+ "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c",
+ "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906",
+ "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65",
+ "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c",
+ "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c",
+ "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30",
+ "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077",
+ "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374",
+ "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01",
+ "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33",
+ "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831",
+ "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9",
+ "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2",
+ "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b",
+ "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f",
+ "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658",
+ "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579",
+ "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5",
+ "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0",
+ "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7",
+ "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666",
+ "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5",
+ "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201",
+ "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103",
+ "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6",
+ "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8",
+ "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1",
+ "sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631",
+ "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898",
+ "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d",
+ "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44",
+ "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2",
+ "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5",
+ "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a",
+ "sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1",
+ "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b",
+ "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc",
+ "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5",
+ "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377",
+ "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8",
+ "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add",
+ "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281",
+ "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9",
+ "sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994",
+ "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0",
+ "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e",
+ "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0",
+ "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28",
+ "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7",
+ "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55",
+ "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb",
+ "sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07",
+ "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb",
+ "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4",
+ "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0",
+ "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e",
+ "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4",
+ "sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9",
+ "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06",
+ "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26",
+ "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7",
+ "sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4",
+ "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3",
+ "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3",
+ "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838",
+ "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71",
+ "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488",
+ "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717",
+ "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d",
+ "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44",
+ "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2",
+ "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b",
+ "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2",
+ "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22",
+ "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6",
+ "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e",
+ "sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310",
+ "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165",
+ "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5",
+ "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799",
+ "sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8",
+ "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7",
+ "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379",
+ "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925",
+ "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72",
+ "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4",
+ "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08",
+ "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==1.2.0"
+ },
+ "websockets": {
+ "hashes": [
+ "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c",
+ "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a",
+ "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe",
+ "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e",
+ "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec",
+ "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1",
+ "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64",
+ "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3",
+ "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8",
+ "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206",
+ "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3",
+ "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156",
+ "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d",
+ "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9",
+ "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad",
+ "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2",
+ "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03",
+ "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8",
+ "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230",
+ "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8",
+ "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea",
+ "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641",
+ "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957",
+ "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6",
+ "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6",
+ "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5",
+ "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f",
+ "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00",
+ "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e",
+ "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b",
+ "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72",
+ "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39",
+ "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9",
+ "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79",
+ "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0",
+ "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac",
+ "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35",
+ "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0",
+ "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5",
+ "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c",
+ "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8",
+ "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1",
+ "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244",
+ "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3",
+ "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767",
+ "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a",
+ "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d",
+ "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd",
+ "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e",
+ "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944",
+ "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82",
+ "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d",
+ "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4",
+ "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5",
+ "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904",
+ "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde",
+ "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f",
+ "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c",
+ "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89",
+ "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da",
+ "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
+ ],
+ "markers": "python_version >= '3.10'",
+ "version": "==16.0"
}
},
"develop": {}
diff --git a/README.md b/README.md
index 0864d62..b69bd80 100644
--- a/README.md
+++ b/README.md
@@ -1,38 +1,86 @@
# portal
-WS2812 LED control for Raspberry Pi 5 and Pico panel adapters.
+Hexagonal LED portal: five WS2812 matrix panels on Pico UDP adapters, plus a bare floor.
+
+Reference: [photo of the physical portal](https://technical.kiwi/images/portal/IMG_20241029_220222.jpg) — upright flat-top hex arch, dark frame, red LED grids on each face.
+
+```
+ ___[2]___
+ / \
+ [1] [3]
+ | |
+ [0] [4]
+ \___________/
+ floor
+```
+
+WS2812 control for Raspberry Pi 5 (orchestrator) and Pico panel adapters.
## Panel IPs
-Five panels on `10.1.1.10`–`10.1.1.14` (panel 0 → `.10`, panel 4 → `.14`). Examples drive **all 5** by default.
+Five Pico panels on `10.1.1.10`–`10.1.1.14`. Examples drive **all 5** by default.
## Panel sizes
Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` in `leds/array_config.py`:
-| Panel | IP | Size | LEDs | Flash |
-|-------|-----|------|------|-------|
-| 0 | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0 NUM_LEDS=351` |
-| 1 | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1 NUM_LEDS=405` |
-| 2 | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2 NUM_LEDS=405` |
-| 3 | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3 NUM_LEDS=405` |
-| 4 | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4 NUM_LEDS=351` |
+| Panel | Face | IP | Size | LEDs | Firmware |
+|-------|------|-----|------|------|----------|
+| 0 | bottom-left | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0` |
+| 1 | top-left | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1` |
+| 2 | **top** | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2` |
+| 3 | top-right | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3` |
+| 4 | bottom-right | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4` |
+
+Panels **0→1→2→3→4** run clockwise from bottom-left (`PORTAL_PANEL_ORDER_CLOCKWISE` in `leds/array_config.py`).
Panel 4 may be **38 or 39** wide — find the exact width:
```bash
-make deploy PANEL_ID=4 NUM_LEDS=387 # 43 cols max for probe
+make deploy PANEL_ID=4
pipenv run python examples/panel_sync_test.py --test width --panel-index 4
```
-Last column that lights → set `PANEL_WIDTH_BY_INDEX[4]` in `leds/array_config.py` to **column + 1**, then re-flash with `NUM_LEDS=width×9` (342 for 38, 351 for 39).
+Last column that lights → set `PANEL_WIDTH_BY_INDEX[4]` in `leds/array_config.py` to **column + 1** (342 LEDs for 38, 351 for 39). Firmware takes pixel count from each UDP frame — no re-flash for width changes.
Animations render at each panel's own width automatically.
## Examples
+### Portal web simulator
+
+Preview all five panels in the browser (3D interior view + flat layout map). Uses the same Python animations as the hardware.
+
+```bash
+pipenv install fastapi "uvicorn[standard]"
+pipenv run python examples/portal_simulator.py
+# open http://localhost:8765/
+```
+
+Dev mode (default): **uvicorn reload** for Python changes, **browser auto-reload** when `web/` files change. Disable with `--no-reload` or `--no-browser-reload`.
+
+Options: `--host 0.0.0.0 --port 8765` to view from another device on the network.
+
+Open via the simulator URL (`http://…:8765/`) — do not open `index.html` directly from the filesystem (`file://`), or module imports will fail.
+
+Frontend uses **Web Components** and ES modules under `web/js/`:
+
+| Module | Role |
+|--------|------|
+| `` | Root layout, frame streaming |
+| `` | Sidebar settings |
+| `` | Three.js 3D scene |
+| `` | Flat layout map |
+| `` | Single LED matrix canvas |
+
### Pico panel (UDP)
+WS2812 defaults: **GP28** (strip 0) and **GP27** (strip 1). Override over UDP:
+
+```bash
+pipenv run python examples/panel_color_test.py --panel-index 0 --pins 28:405,27:351
+```
+
| Command | What it does |
|---------|----------------|
| `pipenv run python examples/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
@@ -50,17 +98,40 @@ Single panel only:
pipenv run python examples/panel_animations.py --panel-index 1 # 9×45 @ 10.1.1.11
```
-### Pi direct (SPI on GPIO 10)
+### Pi SPI (bench / single matrix)
+
+Optional local SPI tests via [`rpi5-ws2812`](https://github.com/niklasr22/rpi5-ws2812) on GPIO 10 — not used for the five portal panels.
+
+405 LEDs need a ~10 KiB SPI frame. Default `spidev` bufsiz (4096) only updates ~169 LEDs — run once:
+
+```bash
+pipenv run python examples/setup_spi_bufsiz.py --install # sudo
+```
| Command | What it does |
|---------|----------------|
-| `pipenv run python examples/animations.py` | All animations on matrix |
+| `pipenv run python examples/spi_rgb_test.py` | Red / green / blue over SPI |
+| `pipenv run python examples/spi_panel_test.py` | Corners, rows, chase — verify all LEDs |
+| `pipenv run python examples/animations.py` | All animations on local matrix |
| `pipenv run python examples/matrix_demo.py` | Rainbow only |
| `pipenv run python examples/led_demo.py` | Dual-strip chase + rainbow |
-### Firmware
+Colors use logical **RGB** everywhere (`(255,0,0)` = red). Pico firmware remaps to **GRB** on the wire; Pi SPI uses the same swap when needed. The web simulator stays logical RGB.
+
+```python
+# MicroPython on Pico — this is the reference:
+np.fill((255, 0, 0)); np.write() # red
+```
```bash
-make deploy PANEL_ID=0 NUM_LEDS=351
-make monitor
+pipenv run python examples/panel_rgb_cycle.py # same colors over UDP
+```
+
+### Firmware
+
+Pico SDK sources live under `firmware/pico/` (see that README for the UDP protocol).
+
+```bash
+make deploy PANEL_ID=0
+make reset && make monitor
```
diff --git a/adapter/code.py b/adapter/code.py
index 97d7ef2..b8b2866 100644
--- a/adapter/code.py
+++ b/adapter/code.py
@@ -12,7 +12,7 @@ SPI1_RX = board.GP12
SPI1_CSn = board.GP9
W5500_RSTn = board.GP13
-pixel_pin = board.GP27
+pixel_pin = board.GP28
num_pixels = 2
print("Wiznet5k SimpleServer Test (DHCP)")
diff --git a/examples/animations.py b/examples/animations.py
index 8343069..95c9611 100755
--- a/examples/animations.py
+++ b/examples/animations.py
@@ -23,6 +23,7 @@ from leds.panel_udp import (
animation_playlist,
format_panel_targets,
panel_targets_from_args,
+ parse_pins_arg,
run_panel_animation_loop,
run_panel_playlist,
)
@@ -102,6 +103,9 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
+ sync_send=args.sync_send,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
else:
run_panel_playlist(
@@ -113,6 +117,9 @@ def main() -> int:
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
+ sync_send=args.sync_send,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
@@ -138,7 +145,7 @@ def main() -> int:
if args.linear:
with LedStrip(
count,
- backend="spi",
+ backend=strip_cfg.backend,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
diff --git a/examples/matrix_demo.py b/examples/matrix_demo.py
index 729dc97..fc62534 100755
--- a/examples/matrix_demo.py
+++ b/examples/matrix_demo.py
@@ -15,6 +15,7 @@ from leds.panel_udp import (
add_panel_network_args,
format_panel_targets,
panel_targets_from_args,
+ parse_pins_arg,
run_panel_animation_loop,
)
@@ -36,12 +37,16 @@ def main() -> int:
if args.panel:
targets = panel_targets_from_args(args)
+ print(f"Panel -> {format_panel_targets(targets, args.port)}")
try:
run_panel_animation_loop(
targets,
args.port,
"rainbow",
brightness=args.brightness,
+ panel_id=args.panel_id,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
diff --git a/examples/panel_animations.py b/examples/panel_animations.py
index 19674d9..30eb770 100755
--- a/examples/panel_animations.py
+++ b/examples/panel_animations.py
@@ -11,6 +11,7 @@ from leds.panel_udp import (
animation_playlist,
format_panel_targets,
panel_targets_from_args,
+ parse_pins_arg,
run_panel_animation_loop,
run_panel_playlist,
)
@@ -47,6 +48,9 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
+ sync_send=args.sync_send,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
else:
run_panel_playlist(
@@ -58,6 +62,9 @@ def main() -> int:
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
+ sync_send=args.sync_send,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
diff --git a/examples/panel_color_test.py b/examples/panel_color_test.py
index 16c5ce8..3416fe6 100755
--- a/examples/panel_color_test.py
+++ b/examples/panel_color_test.py
@@ -8,19 +8,21 @@ import socket
import sys
import time
+from leds.colors import BLUE, GREEN, OFF, RED, WHITE
from leds.panel_udp import (
add_panel_args,
+ apply_pin_arg,
format_panel_targets,
panel_targets_from_args,
send_solid_to_targets,
)
COLORS = (
- ("RED", (255, 0, 0)),
- ("GREEN", (0, 255, 0)),
- ("BLUE", (0, 0, 255)),
- ("WHITE", (255, 255, 255)),
- ("OFF", (0, 0, 0)),
+ ("RED", RED),
+ ("GREEN", GREEN),
+ ("BLUE", BLUE),
+ ("WHITE", WHITE),
+ ("OFF", OFF),
)
@@ -31,7 +33,9 @@ def main() -> int:
args = parser.parse_args()
targets = panel_targets_from_args(args)
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ has_udp = any(not t.local for t in targets)
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
+ targets = apply_pin_arg(args, sock, targets)
print(f"Color test -> {format_panel_targets(targets, args.port)}\n")
try:
@@ -46,7 +50,8 @@ def main() -> int:
return 1
finally:
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
- sock.close()
+ if sock is not None:
+ sock.close()
return 0
diff --git a/examples/panel_rgb_cycle.py b/examples/panel_rgb_cycle.py
index f9aa56e..979c130 100755
--- a/examples/panel_rgb_cycle.py
+++ b/examples/panel_rgb_cycle.py
@@ -7,14 +7,9 @@ import argparse
import sys
import time
+from leds.colors import RGB_CYCLE
from leds.panel_udp import PanelClient, add_panel_args, format_panel_targets, panel_targets_from_args
-COLORS = (
- ("RED", (255, 0, 0)),
- ("GREEN", (0, 255, 0)),
- ("BLUE", (0, 0, 255)),
-)
-
def main() -> int:
parser = argparse.ArgumentParser(description="RGB cycle on Pico panel(s)")
@@ -28,7 +23,7 @@ def main() -> int:
try:
with PanelClient.from_args(args) as client:
while True:
- for label, color in COLORS:
+ for label, color in RGB_CYCLE:
print(f" {label}")
client.fill(color)
time.sleep(args.pause)
diff --git a/examples/panel_spi_bridge.py b/examples/panel_spi_bridge.py
new file mode 100644
index 0000000..3be6ca9
--- /dev/null
+++ b/examples/panel_spi_bridge.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python3
+"""Run a UDP-to-SPI bridge so a Pi-connected panel looks like a Pico adapter."""
+
+from __future__ import annotations
+
+from leds.panel_bridge import main
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/panel_sync_test.py b/examples/panel_sync_test.py
index a572349..2738dd9 100644
--- a/examples/panel_sync_test.py
+++ b/examples/panel_sync_test.py
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
-"""Positioning and sync test for multiple Pico panels.
+"""Positioning and sync test for the hexagonal portal panels.
Runs a sequence of patterns on all panels (or one via --panel-index):
- identify - panel index digit on each board (check physical mapping)
+ identify - panel index digit on each face (check physical mapping)
corners - red/green/blue/yellow corners (check orientation)
crosshair - center row + column
columns - sweep the same column index on every panel together
@@ -23,12 +23,15 @@ import time
from leds.panel_udp import (
HeadlessMatrix,
+ PanelMatrixBundle,
PanelTarget,
add_panel_args,
+ apply_pin_arg,
format_panel_targets,
panel_targets_from_args,
- send_frame,
+ push_panel_frames,
)
+from leds.array_config import panel_label
from leds.text import draw_text_centered
PANEL_COLORS = (
@@ -47,37 +50,35 @@ CORNER_COLORS = {
}
-def _matrices(targets: list[PanelTarget], brightness: float) -> list[tuple[PanelTarget, HeadlessMatrix]]:
- return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
+def _matrices(targets: list[PanelTarget], brightness: float) -> PanelMatrixBundle:
+ return PanelMatrixBundle(targets, brightness)
def _push(
- sock: socket.socket,
- targets: list[PanelTarget],
- matrices: list[tuple[PanelTarget, HeadlessMatrix]],
+ sock: socket.socket | None,
+ bundle: PanelMatrixBundle,
port: int,
panel_id: int | None,
) -> None:
"""Send all panel frames in a tight burst for best sync."""
- for target, matrix in matrices:
- send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
+ push_panel_frames(sock, bundle.matrices, port, panel_id)
def _clear_all(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
) -> None:
- matrices = _matrices(targets, brightness)
- for _, matrix in matrices:
- matrix.clear()
- _push(sock, targets, matrices, port, panel_id)
+ with PanelMatrixBundle(targets, brightness) as bundle:
+ for _, matrix in bundle.matrices:
+ matrix.clear()
+ _push(sock, bundle, port, panel_id)
def test_identify(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -85,18 +86,18 @@ def test_identify(
pause: float,
) -> None:
print("Test: identify — each panel shows its index (0–4)")
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- matrix.clear()
- color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
- draw_text_centered(matrix, str(target.index), color)
- print(f" panel {target.index} -> {target.host} ({target.width}×{target.height})")
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
+ draw_text_centered(matrix, str(target.index), color)
+ print(f" {panel_label(target.index)} -> {target.label()}")
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
def test_corners(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -104,24 +105,24 @@ def test_corners(
pause: float,
) -> None:
print("Test: corners — TL=red TR=green BL=blue BR=yellow (all panels)")
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- w, h = target.width, target.height
- matrix.clear()
- points = {
- "top-left": (0, 0),
- "top-right": (w - 1, 0),
- "bottom-left": (0, h - 1),
- "bottom-right": (w - 1, h - 1),
- }
- for name, (x, y) in points.items():
- matrix[x, y] = CORNER_COLORS[name]
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for target, matrix in bundle.matrices:
+ w, h = target.width, target.height
+ matrix.clear()
+ points = {
+ "top-left": (0, 0),
+ "top-right": (w - 1, 0),
+ "bottom-left": (0, h - 1),
+ "bottom-right": (w - 1, h - 1),
+ }
+ for name, (x, y) in points.items():
+ matrix[x, y] = CORNER_COLORS[name]
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
def test_crosshair(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -129,22 +130,22 @@ def test_crosshair(
pause: float,
) -> None:
print("Test: crosshair — center row + column (white)")
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- w, h = target.width, target.height
- matrix.clear()
- mid_y = h // 2
- mid_x = w // 2
- for x in range(w):
- matrix[x, mid_y] = (255, 255, 255)
- for y in range(h):
- matrix[mid_x, y] = (200, 200, 200)
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for target, matrix in bundle.matrices:
+ w, h = target.width, target.height
+ matrix.clear()
+ mid_y = h // 2
+ mid_x = w // 2
+ for x in range(w):
+ matrix[x, mid_y] = (255, 255, 255)
+ for y in range(h):
+ matrix[mid_x, y] = (200, 200, 200)
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
def test_columns(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -153,22 +154,22 @@ def test_columns(
) -> None:
max_width = max(t.width for t in targets)
print(f"Test: column sweep — x=0..{max_width - 1} on all panels together")
- for x in range(max_width):
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- matrix.clear()
- if x >= target.width:
- continue
- hue = int(255 * x / max(max_width - 1, 1))
- for y in range(target.height):
- matrix[x, y] = (hue, 80, 255 - hue)
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for x in range(max_width):
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ if x >= target.width:
+ continue
+ hue = int(255 * x / max(max_width - 1, 1))
+ for y in range(target.height):
+ matrix[x, y] = (hue, 80, 255 - hue)
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_rows(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -177,19 +178,19 @@ def test_rows(
) -> None:
height = targets[0].height if targets else 9
print(f"Test: row sweep — y=0..{height - 1} on all panels together")
- for y in range(height):
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- matrix.clear()
- for x in range(target.width):
- matrix[x, y] = (255, 255, 255)
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for y in range(height):
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ for x in range(target.width):
+ matrix[x, y] = (255, 255, 255)
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_flash(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -199,18 +200,18 @@ def test_flash(
) -> None:
print(f"Test: sync flash — {count} white flashes on all panels")
for n in range(count):
- matrices = _matrices(targets, brightness)
- for _, matrix in matrices:
- matrix.fill((255, 255, 255))
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for _, matrix in bundle.matrices:
+ matrix.fill((255, 255, 255))
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
time.sleep(pause / 2)
print(f" flash {n + 1}/{count}")
def test_pulse(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -220,20 +221,20 @@ def test_pulse(
) -> None:
print(f"Test: sync pulse — {seconds:.0f}s solid red fade (all panels)")
frames = max(int(seconds * fps), 1)
- for frame in range(frames):
- t = frame / frames
- level = 0.15 + 0.85 * (0.5 - 0.5 * math.cos(t * 6.28318))
- color = (int(255 * level), 0, 0)
- matrices = _matrices(targets, min(brightness * level, 1.0))
- for _, matrix in matrices:
- matrix.fill(color)
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(1.0 / fps)
+ with _matrices(targets, brightness) as bundle:
+ for frame in range(frames):
+ t = frame / frames
+ level = 0.15 + 0.85 * (0.5 - 0.5 * math.cos(t * 6.28318))
+ color = (int(255 * level), 0, 0)
+ for _, matrix in bundle.matrices:
+ matrix.fill(color)
+ _push(sock, bundle, port, panel_id)
+ time.sleep(1.0 / fps)
_clear_all(sock, targets, port, brightness, panel_id)
def test_chain_ends(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -241,21 +242,21 @@ def test_chain_ends(
pause: float,
) -> None:
print("Test: chain ends — LED 0=red, last LED=green (per panel)")
- matrices = _matrices(targets, brightness)
- for target, matrix in matrices:
- matrix.clear()
- matrix[0, 0] = (255, 0, 0)
- last = target.pixels - 1
- x = last % target.width
- y = last // target.width
- matrix[x, y] = (0, 255, 0)
- print(f" panel {target.index}: index 0 and {last} ({x},{y})")
- _push(sock, targets, matrices, port, panel_id)
- time.sleep(pause)
+ with _matrices(targets, brightness) as bundle:
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ matrix[0, 0] = (255, 0, 0)
+ last = target.pixels - 1
+ x = last % target.width
+ y = last // target.width
+ matrix[x, y] = (0, 255, 0)
+ print(f" panel {target.index}: index 0 and {last} ({x},{y})")
+ _push(sock, bundle, port, panel_id)
+ time.sleep(pause)
def test_width(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -264,25 +265,26 @@ def test_width(
col_start: int,
col_end: int,
) -> None:
+ udp_targets = [t for t in targets if not t.local]
+ if not udp_targets:
+ print("Test: width — skipped (no UDP Pico panels in target list)")
+ return
probe_w = col_end + 1
- probe_leds = probe_w * targets[0].height if targets else probe_w * 9
- print("Test: width — one full column at a time (right-edge probe)")
+ probe_leds = probe_w * udp_targets[0].height
+ print("Test: width — one full column at a time (right-edge probe, UDP panels only)")
print(f" Probing columns {col_start}–{col_end} (sending {probe_w}×9 = {probe_leds} LEDs)")
- print(" Flash firmware with at least NUM_LEDS={} first, e.g.:".format(probe_leds))
- print(f" make deploy PANEL_ID= NUM_LEDS={probe_leds}")
print(" The last column that lights a real LED → width = column + 1")
print(" (column 37 lit → width 38; column 38 lit → width 39)\n")
for x in range(col_start, col_end + 1):
burst: list[tuple[PanelTarget, HeadlessMatrix]] = []
- for target in targets:
+ for target in udp_targets:
matrix = HeadlessMatrix(probe_w, target.height, brightness=brightness)
matrix.clear()
for y in range(target.height):
matrix[x, y] = (255, 255, 255)
probe_target = PanelTarget(target.index, target.host, probe_w, target.height)
burst.append((probe_target, matrix))
- for probe_target, matrix in burst:
- send_frame(sock, probe_target.host, port, matrix.rgb_bytes(), panel_id)
+ push_panel_frames(sock, burst, port, panel_id)
print(f" column {x} → width {x + 1} if this is the last real column")
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
@@ -323,7 +325,9 @@ def main() -> int:
args = parser.parse_args()
targets = panel_targets_from_args(args)
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ has_udp = any(not t.local for t in targets)
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
+ targets = apply_pin_arg(args, sock, targets)
print(f"Panel sync test -> {format_panel_targets(targets, args.port)}")
print("Ctrl+C to stop\n")
@@ -353,7 +357,8 @@ def main() -> int:
return 1
finally:
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
- sock.close()
+ if sock is not None:
+ sock.close()
return 0
diff --git a/examples/panel_text.py b/examples/panel_text.py
index 4ad74e7..4eb4a43 100755
--- a/examples/panel_text.py
+++ b/examples/panel_text.py
@@ -10,13 +10,21 @@ import sys
import time
from leds.panel_udp import (
- HeadlessMatrix,
+ PanelMatrixBundle,
add_panel_args,
+ apply_pin_arg,
format_panel_targets,
panel_targets_from_args,
- send_frame,
+ push_panel_frames,
+)
+from leds.text import (
+ blit_mask,
+ blit_mask_gradient,
+ draw_font_text_centered,
+ draw_text,
+ draw_text_centered,
+ render_font_mask,
)
-from leds.text import draw_text, draw_text_centered
DEFAULT_NAME = "JIMMY"
@@ -44,9 +52,40 @@ def main() -> int:
parser.add_argument("--color", default="255,200,80", metavar="R,G,B")
parser.add_argument("--hold", type=float, default=0.0, help="0 = show until Ctrl+C")
parser.add_argument("--scroll", action="store_true")
+ parser.add_argument(
+ "--cursive",
+ action="store_true",
+ help="Italic script style (keeps letter case; auto-scrolls if too wide)",
+ )
+ parser.add_argument(
+ "--font-size",
+ type=int,
+ default=None,
+ help="Font point size (default: panel height + 3)",
+ )
+ parser.add_argument(
+ "--letter-spacing",
+ type=int,
+ default=0,
+ help="Pixels between cursive letters (default: 0)",
+ )
+ parser.add_argument(
+ "--speed",
+ type=float,
+ default=None,
+ help="Scroll delay in seconds (default: 0.15 cursive, 0.08 bitmap)",
+ )
+ parser.add_argument(
+ "--rainbow",
+ action="store_true",
+ help="Rainbow gradient across cursive text",
+ )
args = parser.parse_args()
- text = (args.text or _default_name()).upper()
+ if args.cursive:
+ text = args.text or "Allyssia"
+ else:
+ text = (args.text or _default_name()).upper()
parts = [int(p.strip()) for p in args.color.split(",")]
if len(parts) != 3:
print("Error: --color must be R,G,B", file=sys.stderr)
@@ -54,46 +93,114 @@ def main() -> int:
color = (parts[0], parts[1], parts[2])
targets = panel_targets_from_args(args)
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- print(f"Displaying '{text}' on {format_panel_targets(targets, args.port)}")
+ has_udp = any(not t.local for t in targets)
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
+ targets = apply_pin_arg(args, sock, targets)
+ style = "cursive" if args.cursive else "bitmap"
+
+ cursive_masks: dict[int, tuple[int, list[tuple[int, int]]]] = {}
+ if args.cursive:
+ try:
+ for target in targets:
+ width, _, ons = render_font_mask(
+ text,
+ height=target.height,
+ size=args.font_size,
+ letter_spacing=args.letter_spacing,
+ )
+ cursive_masks[target.index] = (width, ons)
+ except (ImportError, FileNotFoundError, OSError) as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+
+ # Auto-scroll when the rendered text is wider than any target panel.
+ do_scroll = args.scroll
+ if args.cursive and not do_scroll:
+ if any(
+ cursive_masks[t.index][0] > t.width for t in targets if t.index in cursive_masks
+ ):
+ do_scroll = True
+
+ scroll_delay = args.speed
+ if scroll_delay is None:
+ scroll_delay = 0.15 if args.cursive else 0.08
+
+ print(
+ f"Displaying '{text}' ({style}"
+ f"{', scrolling' if do_scroll else ''})"
+ f" on {format_panel_targets(targets, args.port)}"
+ )
try:
- matrices = [
- (target, HeadlessMatrix(target.width, target.height, brightness=args.brightness))
- for target in targets
- ]
- if args.scroll:
- start = time.monotonic()
- offsets = {target.index: matrix.width for target, matrix in matrices}
- while True:
- for target, matrix in matrices:
- matrix.clear()
- draw_text(matrix, text, offsets[target.index], 1, color)
- send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
- offsets[target.index] -= 1
- if offsets[target.index] < -len(text) * 6:
- offsets[target.index] = matrix.width
- time.sleep(0.08)
- if args.hold > 0 and time.monotonic() - start >= args.hold:
- break
- else:
- for target, matrix in matrices:
- matrix.clear()
- draw_text_centered(matrix, text, color)
- send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
- if args.hold <= 0:
- print("Showing (Ctrl+C to clear)")
+ with PanelMatrixBundle(targets, args.brightness) as bundle:
+ if do_scroll:
+ start = time.monotonic()
+ offsets = {
+ target.index: matrix.width for target, matrix in bundle.matrices
+ }
while True:
- time.sleep(1)
+ phase = (time.monotonic() - start) * 0.55
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ if args.cursive:
+ tw, ons = cursive_masks[target.index]
+ if args.rainbow:
+ blit_mask_gradient(
+ matrix,
+ ons,
+ offsets[target.index],
+ 0,
+ width=tw,
+ phase=phase,
+ cycles=2.5,
+ )
+ else:
+ blit_mask(matrix, ons, offsets[target.index], 0, color)
+ wrap = tw
+ else:
+ draw_text(matrix, text, offsets[target.index], 1, color)
+ wrap = len(text) * 6
+ offsets[target.index] -= 1
+ if offsets[target.index] < -wrap:
+ offsets[target.index] = matrix.width
+ push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
+ time.sleep(scroll_delay)
+ if args.hold > 0 and time.monotonic() - start >= args.hold:
+ break
else:
- time.sleep(args.hold)
+ for target, matrix in bundle.matrices:
+ matrix.clear()
+ if args.cursive:
+ if args.rainbow:
+ tw, ons = cursive_masks[target.index]
+ x = max(0, (matrix.width - tw) // 2)
+ blit_mask_gradient(matrix, ons, x, 0, width=tw, phase=0.0)
+ else:
+ draw_font_text_centered(
+ matrix,
+ text,
+ color,
+ size=args.font_size,
+ letter_spacing=args.letter_spacing,
+ )
+ else:
+ draw_text_centered(matrix, text, color)
+ push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
+ if args.hold <= 0:
+ print("Showing (Ctrl+C to clear)")
+ while True:
+ time.sleep(1)
+ else:
+ time.sleep(args.hold)
except KeyboardInterrupt:
print("\nClearing.")
finally:
- for target, matrix in matrices:
- matrix.clear()
- send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
- sock.close()
+ with PanelMatrixBundle(targets, args.brightness) as bundle:
+ for _, matrix in bundle.matrices:
+ matrix.clear()
+ push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
+ if sock is not None:
+ sock.close()
return 0
diff --git a/examples/panel_udp_send.py b/examples/panel_udp_send.py
index 73aff50..7ac49f1 100755
--- a/examples/panel_udp_send.py
+++ b/examples/panel_udp_send.py
@@ -11,6 +11,7 @@ from leds.panel_udp import (
add_panel_args,
format_panel_targets,
panel_targets_from_args,
+ parse_pins_arg,
run_panel_animation_loop,
)
@@ -35,6 +36,8 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
+ pin=args.pin,
+ pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
diff --git a/examples/portal_simulator.py b/examples/portal_simulator.py
new file mode 100644
index 0000000..288fc4b
--- /dev/null
+++ b/examples/portal_simulator.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Web portal simulator — FastAPI + live reload."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+from pathlib import Path
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Portal web simulator (FastAPI)")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ parser.add_argument(
+ "--reload",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="reload Python on code changes (default: on)",
+ )
+ parser.add_argument(
+ "--browser-reload",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="reload browser when web/ files change (default: on)",
+ )
+ args = parser.parse_args()
+
+ web_dir = Path(__file__).resolve().parent.parent / "web"
+ if not web_dir.is_dir():
+ print(f"Missing web directory: {web_dir}", file=sys.stderr)
+ return 1
+
+ try:
+ import uvicorn
+ except ImportError:
+ print(
+ "FastAPI simulator requires: pipenv install fastapi 'uvicorn[standard]'",
+ file=sys.stderr,
+ )
+ return 1
+
+ os.environ["PORTAL_DEV_RELOAD"] = "1" if args.browser_reload else "0"
+
+ leds_dir = Path(__file__).resolve().parent.parent / "leds"
+ reload_dirs = [str(web_dir), str(leds_dir)] if args.reload else None
+
+ print(f"Portal simulator → http://{args.host}:{args.port}/")
+ if args.reload:
+ print("Python reload: on")
+ if args.browser_reload:
+ print("Browser reload: on (watches web/)")
+ print("Ctrl+C to stop")
+
+ uvicorn.run(
+ "leds.portal_web:app",
+ host=args.host,
+ port=args.port,
+ reload=args.reload,
+ reload_dirs=reload_dirs,
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/setup_spi_bufsiz.py b/examples/setup_spi_bufsiz.py
new file mode 100644
index 0000000..41c96d2
--- /dev/null
+++ b/examples/setup_spi_bufsiz.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Raise spidev bufsiz so full WS2812 SPI frames fit (405 LEDs ≈ 10 KiB)."""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+from pathlib import Path
+
+from leds.backends.spi import _DEFAULT_BUFSIZ, spidev_bufsize
+
+MODPROBE_D = Path("/etc/modprobe.d/spidev-bufsiz.conf")
+MARKER = "# portal spidev bufsiz"
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="Configure spidev bufsiz for long WS2812 SPI strips"
+ )
+ parser.add_argument(
+ "--bufsiz",
+ type=int,
+ default=_DEFAULT_BUFSIZ,
+ help=f"Kernel SPI buffer size (default {_DEFAULT_BUFSIZ})",
+ )
+ parser.add_argument(
+ "--install",
+ action="store_true",
+ help="Write /etc/modprobe.d/spidev-bufsiz.conf and reload spidev (sudo)",
+ )
+ args = parser.parse_args()
+
+ current = spidev_bufsize()
+ print(f"spidev bufsiz: {current}")
+ print(f"405 LEDs need ~9762 bytes per frame.\n")
+
+ line = f"options spidev bufsiz={args.bufsiz}"
+ print("Add to /etc/modprobe.d/spidev-bufsiz.conf:\n")
+ print(f"{MARKER}")
+ print(line)
+ print("\nThen reload:\n sudo modprobe -r spidev && sudo modprobe spidev")
+
+ if not args.install:
+ return 0
+
+ try:
+ existing = MODPROBE_D.read_text() if MODPROBE_D.exists() else ""
+ if MARKER in existing:
+ print(f"\n{MODPROBE_D} already configured.")
+ else:
+ MODPROBE_D.write_text(f"{MARKER}\n{line}\n")
+ print(f"\nWrote {MODPROBE_D}.")
+
+ subprocess.run(["modprobe", "-r", "spidev"], check=False)
+ subprocess.run(["modprobe", "spidev", f"bufsiz={args.bufsiz}"], check=True)
+ print(f"Reloaded spidev (bufsiz={spidev_bufsize()}).")
+ except OSError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ print("Re-run with sudo.", file=sys.stderr)
+ return 1
+ except subprocess.CalledProcessError as exc:
+ print(f"modprobe failed: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/spi_panel_test.py b/examples/spi_panel_test.py
new file mode 100644
index 0000000..a764088
--- /dev/null
+++ b/examples/spi_panel_test.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Step-by-step drive test for a Pi-connected panel over SPI."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+import time
+
+from leds.array_config import (
+ MATRIX_BRIGHTNESS,
+ MATRIX_SPI_BUS,
+ MATRIX_SPI_DEVICE,
+ matrix_pixel_index,
+ panel_layout,
+ panel_pixel_count,
+)
+from leds.backends.spi import spidev_bufsize_hint
+from leds.config import panel_strip_config
+from leds.matrix import LedMatrix
+
+
+def _flash(matrix: LedMatrix, pause: float) -> None:
+ print("Test: RGB flash")
+ for name, color in (("red", (255, 0, 0)), ("green", (0, 255, 0)), ("blue", (0, 0, 255))):
+ print(f" {name}")
+ matrix.fill(color)
+ matrix.show()
+ time.sleep(pause)
+ matrix.clear()
+ matrix.show()
+
+
+def _first_last(matrix: LedMatrix, pixels: int, pause: float) -> None:
+ print(f"Test: LED 0 red, LED {pixels - 1} green")
+ width = matrix.width
+ matrix.fill((0, 0, 0))
+ matrix.set_pixel(0, 0, (255, 0, 0))
+ last = pixels - 1
+ matrix.set_pixel(last % width, last // width, (0, 255, 0))
+ matrix.show()
+ time.sleep(pause)
+ matrix.clear()
+ matrix.show()
+
+
+def _rows(matrix: LedMatrix, width: int, height: int, pause: float) -> None:
+ print("Test: one row at a time (white)")
+ for y in range(height):
+ matrix.fill((0, 0, 0))
+ for x in range(width):
+ matrix[x, y] = (255, 255, 255)
+ print(f" row {y}")
+ matrix.show()
+ time.sleep(pause)
+ matrix.clear()
+ matrix.show()
+
+
+def _corners(matrix: LedMatrix, width: int, height: int, pause: float) -> None:
+ print("Test: corners")
+ corners = (
+ ("top-left", 0, 0, (255, 0, 0)),
+ ("top-right", width - 1, 0, (0, 255, 0)),
+ ("bottom-left", 0, height - 1, (0, 0, 255)),
+ ("bottom-right", width - 1, height - 1, (255, 255, 0)),
+ )
+ for label, x, y, color in corners:
+ matrix.fill((0, 0, 0))
+ matrix[x, y] = color
+ idx = matrix_pixel_index(x, y, width, height)
+ print(f" {label} ({x},{y}) -> LED {idx}")
+ matrix.show()
+ time.sleep(pause)
+ matrix.clear()
+ matrix.show()
+
+
+def _chase(matrix: LedMatrix, pixels: int, pause: float, steps: int) -> None:
+ print(f"Test: chase ({steps} steps)")
+ width = matrix.width
+ for i in range(steps):
+ matrix.fill((0, 0, 0))
+ idx = i % pixels
+ matrix[idx % width, idx // width] = (0, 255, 255)
+ matrix.show()
+ time.sleep(pause)
+ matrix.clear()
+ matrix.show()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="SPI drive test for Pi-connected panel")
+ parser.add_argument("--panel-index", type=int, default=2, choices=range(5), metavar="N")
+ parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
+ parser.add_argument(
+ "--spi-bus",
+ type=int,
+ default=int(os.environ.get("PORTAL_SPI_BUS", MATRIX_SPI_BUS)),
+ )
+ parser.add_argument("--spi-device", type=int, default=MATRIX_SPI_DEVICE)
+ parser.add_argument("--spi-mhz", type=float, default=4.2)
+ parser.add_argument("--wire-order", default=None)
+ parser.add_argument("--pause", type=float, default=0.8)
+ parser.add_argument(
+ "--test",
+ choices=("all", "flash", "first-last", "rows", "corners", "chase"),
+ default="all",
+ )
+ parser.add_argument("--chase-steps", type=int, default=None)
+ args = parser.parse_args()
+
+ width, height = panel_layout(args.panel_index)
+ pixels = panel_pixel_count(args.panel_index)
+ hint = spidev_bufsize_hint(pixels)
+ if hint:
+ print(f"Warning: {hint}\n", file=sys.stderr)
+
+ cfg = panel_strip_config(
+ args.panel_index,
+ pixels,
+ spi_max_speed_hz=max(1, int(args.spi_mhz * 1_000_000)),
+ wire_order=args.wire_order,
+ brightness=args.brightness,
+ )
+ chase_steps = args.chase_steps if args.chase_steps is not None else pixels
+
+ print(f"SPI panel test: panel {args.panel_index}, {width}×{height} ({pixels} LEDs)")
+
+ try:
+ with LedMatrix(width, height, config0=cfg) as matrix:
+ if args.test in ("all", "flash"):
+ _flash(matrix, args.pause)
+ if args.test in ("all", "first-last"):
+ _first_last(matrix, pixels, args.pause)
+ if args.test in ("all", "corners"):
+ _corners(matrix, width, height, args.pause)
+ if args.test in ("all", "rows"):
+ _rows(matrix, width, height, max(args.pause * 0.6, 0.15))
+ if args.test in ("all", "chase"):
+ _chase(matrix, pixels, max(args.pause * 0.25, 0.05), chase_steps)
+ except KeyboardInterrupt:
+ print("\nStopped.")
+ except OSError as exc:
+ print(f"SPI error: {exc}", file=sys.stderr)
+ return 1
+ print("Done.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/examples/spi_rgb_test.py b/examples/spi_rgb_test.py
new file mode 100644
index 0000000..1afc9d2
--- /dev/null
+++ b/examples/spi_rgb_test.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Full-panel red / green / blue over SPI (GPIO 10 on Pi 5)."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+import time
+
+from leds.array_config import (
+ MATRIX_BRIGHTNESS,
+ MATRIX_SPI_BUS,
+ MATRIX_SPI_DEVICE,
+ panel_layout,
+ panel_pixel_count,
+)
+from leds.colors import BLUE, GREEN, OFF, RED, WHITE
+from leds.config import panel_strip_config, spi_strip_config
+from leds.matrix import LedMatrix
+
+COLORS = (
+ ("RED", RED),
+ ("GREEN", GREEN),
+ ("BLUE", BLUE),
+ ("WHITE", WHITE),
+ ("OFF", OFF),
+)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description="R/G/B color test on a WS2812 strip via SPI"
+ )
+ parser.add_argument(
+ "--panel-index",
+ type=int,
+ default=2,
+ choices=range(5),
+ metavar="N",
+ help="Panel layout for width×height (default: 2 = top / Pi)",
+ )
+ parser.add_argument(
+ "--count",
+ type=int,
+ default=None,
+ help="LED count override (default: panel width × 9)",
+ )
+ parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
+ parser.add_argument(
+ "--spi-bus",
+ type=int,
+ default=int(os.environ.get("PORTAL_SPI_BUS", MATRIX_SPI_BUS)),
+ )
+ parser.add_argument("--spi-device", type=int, default=MATRIX_SPI_DEVICE)
+ parser.add_argument(
+ "--spi-mhz",
+ type=float,
+ default=4.2,
+ help="SPI clock in MHz (default: 4.2)",
+ )
+ parser.add_argument("--pause", type=float, default=1.5)
+ args = parser.parse_args()
+
+ width, height = panel_layout(args.panel_index)
+ pixels = args.count if args.count is not None else panel_pixel_count(args.panel_index)
+ spi_hz = max(1, int(args.spi_mhz * 1_000_000))
+
+ if args.count is not None:
+ if pixels % width != 0:
+ parser.error(f"--count {pixels} is not a multiple of width {width}")
+ height = pixels // width
+ cfg = spi_strip_config(
+ pixels,
+ spi_bus=args.spi_bus,
+ spi_device=args.spi_device,
+ spi_max_speed_hz=spi_hz,
+ brightness=args.brightness,
+ passthrough=True,
+ swap_rg=True,
+ )
+ else:
+ cfg = panel_strip_config(
+ args.panel_index,
+ pixels,
+ spi_max_speed_hz=spi_hz,
+ brightness=args.brightness,
+ passthrough=True,
+ swap_rg=True,
+ )
+
+ print(
+ f"SPI RGB test: {width}×{height} ({pixels} LEDs), "
+ f"bus {args.spi_bus}, {args.spi_mhz:g} MHz, passthrough RGB"
+ )
+
+ try:
+ with LedMatrix(width, height, config0=cfg) as matrix:
+ for label, color in COLORS:
+ print(f" {label}")
+ matrix.fill(color)
+ matrix.show()
+ time.sleep(args.pause)
+ except KeyboardInterrupt:
+ print("\nStopped.")
+ except OSError as exc:
+ print(f"SPI error: {exc}", file=sys.stderr)
+ print("Enable SPI and wire DIN → GPIO 10 (/dev/spidev0.0).", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/firmware/panel/README.md b/firmware/panel/README.md
deleted file mode 100644
index f2a2ea9..0000000
--- a/firmware/panel/README.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Portal panel firmware (Pico SDK)
-
-C firmware for a **Pico + W5500 + WS2812** panel adapter. Replaces the slow CircuitPython `adapter/code.py` path with native UDP receive and PIO WS2812 output.
-
-## Hardware
-
-Matches `adapter/code.py`:
-
-| Signal | GPIO |
-|--------|------|
-| W5500 CS | GP9 |
-| SPI1 SCK | GP10 |
-| SPI1 MOSI | GP11 |
-| SPI1 MISO | GP12 |
-| W5500 RST | GP13 |
-| Status LED | GP25 |
-| WS2812 data | GP27 |
-
-Default: **405 LEDs** (45×9 matrix chain), one data pin per panel.
-
-WS2812 output uses [ForsakenNGS/Pico_WS2812](https://github.com/ForsakenNGS/Pico_WS2812) (FORMAT_GRB on GP27).
-
-## Build
-
-Requires [pico-sdk](https://github.com/raspberrypi/pico-sdk) and the ARM GCC toolchain (`arm-none-eabi-gcc`).
-
-```bash
-# From repo root
-make deploy # build + flash over USB
-make build # build only
-
-# Or from this directory
-make deploy
-```
-
-Manual cmake (one-time pico-sdk setup):
-
-```bash
-git clone https://github.com/raspberrypi/pico-sdk.git ~/pico/pico-sdk
-cd ~/pico/pico-sdk && git submodule update --init
-export PICO_SDK_PATH=~/pico/pico-sdk
-
-mkdir -p build && cd build
-cmake ..
-make -j$(nproc)
-```
-
-UF2 output: `build/panel.uf2` — hold BOOTSEL and copy to the Pico.
-
-### Options
-
-```bash
-# Panel 0 of 5 → 10.1.1.10, unique MAC, UDP panel_id filter
-make deploy PANEL_ID=0 # 10.1.1.10
-make deploy PANEL_ID=1 # 10.1.1.11
-
-# DHCP instead of static 10.1.1.10–14
-make deploy PANEL_ID=0 NO_DHCP=0
-
-# Fewer LEDs for bench test
-make deploy NUM_LEDS=2
-```
-
-Static IP and MAC are derived from `PANEL_ID`:
-
-| Panel | IP | MAC |
-|-------|-----|-----|
-| 0 | 10.1.1.10 | 02:50:52:54:4C:00 |
-| 1 | 10.1.1.11 | 02:50:52:54:4C:01 |
-| … | … | … |
-| 4 | 10.1.1.14 | 02:50:52:54:4C:04 |
-| 255 (bench) | 10.1.1.19 | 02:50:52:54:4C:FF |
-
-Edit `board_config.h` for gateway, subnet, and pin changes.
-
-## UDP protocol (port 50007)
-
-| Payload | Action |
-|---------|--------|
-| **1215 bytes** | Raw RGB (`405 × 3`), show immediately |
-| **1216 bytes** | `panel_id` (byte 0) + RGB; `255` = accept on any panel |
-| **4 bytes `SHOW`** | Push buffered pixels to the strip (sync helper) |
-
-Pi sends **RGB** order; firmware converts to WS2812 **GRB**.
-
-Target throughput: ~30–50 fps per panel (vs ~10–20 fps on CircuitPython).
-
-## Pi test sender
-
-```bash
-pipenv run python examples/panel_udp_send.py --host 192.168.2.111 --animation rainbow
-```
-
-## Flashing
-
-Requires [picotool](https://github.com/raspberrypi/picotool) with libusb support:
-
-```bash
-make deploy
-```
-
-Or `./scripts/panel_deploy.sh` (same thing).
-
-Or manually:
-
-```bash
-picotool load -x -f build/panel.uf2
-```
-
-`-f` forces a USB reboot into BOOTSEL when the panel is already running.
-
-Legacy UF2 drag-and-drop also works: hold BOOTSEL, plug USB, copy `panel.uf2` to `RPI-RP2`.
-
-Serial debug:
-
-```bash
-make monitor
-# or: picocom /dev/ttyACM0
-```
-
-Ctrl+A then Ctrl+X to exit picocom.
-
-## Multi-panel (5 Picos)
-
-Flash each Pico with `PANEL_ID` 0–4 (`make deploy PANEL_ID=N`). Each gets `10.1.1.1N` (10–14), a unique MAC, and UDP filtering. Send frames from the Pi to each panel’s IP, or use a leading `panel_id` byte with `255` broadcast filtering.
diff --git a/firmware/panel/main.c b/firmware/panel/main.c
deleted file mode 100644
index f0f5caf..0000000
--- a/firmware/panel/main.c
+++ /dev/null
@@ -1,219 +0,0 @@
-/**
- * Portal panel firmware — W5500 UDP + WS2812 (Pico SDK).
- *
- * UDP protocol (port 50007):
- * - N×3 bytes: raw RGB (N = NUM_LEDS), show immediately
- * - N×3+1 bytes: panel_id (byte 0) + RGB; panel_id 255 = any panel
- * - 4 bytes "SHOW": refresh buffered pixels (for split frame/show sync)
- */
-
-#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[LED_RGB_BYTES];
-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(void) {
- printf("LED boot test on GP%d (%d pixels)\n", PIN_WS2812, NUM_LEDS);
- ws2812_fill(255, 0, 0);
- ws2812_show();
- sleep_ms(500);
- ws2812_fill(0, 255, 0);
- ws2812_show();
- sleep_ms(500);
- ws2812_fill(0, 0, 0);
- ws2812_show();
-}
-
-static int handle_frame(const uint8_t *rgb, uint16_t len) {
- if (len != LED_RGB_BYTES) {
- printf("frame reject len=%u want=%u\n", (unsigned)len, (unsigned)LED_RGB_BYTES);
- return -1;
- }
- ws2812_set_rgb(rgb, NUM_LEDS);
- ws2812_show();
- return 0;
-}
-
-static void handle_packet(const uint8_t *data, int16_t len) {
- if (len == 4 && memcmp(data, "SHOW", 4) == 0) {
- ws2812_set_rgb(g_pixel_buf, NUM_LEDS);
- ws2812_show();
- return;
- }
-
- if (len == LED_RGB_BYTES) {
- memcpy(g_pixel_buf, data, LED_RGB_BYTES);
- handle_frame(data, (uint16_t)len);
- return;
- }
-
- if (len == LED_RGB_BYTES + 1) {
- uint8_t panel = data[0];
- if (panel != 255 && panel != (uint8_t)PANEL_ID) {
- return;
- }
- memcpy(g_pixel_buf, data + 1, LED_RGB_BYTES);
- handle_frame(data + 1, LED_RGB_BYTES);
- 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);
- }
-}
-
-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("LEDs=%d panel_id=%d mac=02:50:52:54:4C:%02X ip=10.1.1.%u\n",
- NUM_LEDS, PANEL_ID, (unsigned)PANEL_ID, (unsigned)STATIC_IP_OCT4);
-
- wizchip_spi_initialize();
- wizchip_cris_initialize();
- wizchip_reset();
- wizchip_initialize();
- wizchip_check();
-
- ws2812_init(PIN_WS2812, NUM_LEDS);
- memset(g_pixel_buf, 0, sizeof(g_pixel_buf));
- led_boot_test();
-
- 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();
- if (++heartbeat >= 500) {
- gpio_xor_mask(1u << PIN_LED_STATUS);
- heartbeat = 0;
- }
- sleep_ms(1);
- }
-}
diff --git a/firmware/panel/ws2812_led.cpp b/firmware/panel/ws2812_led.cpp
deleted file mode 100644
index 2bd4251..0000000
--- a/firmware/panel/ws2812_led.cpp
+++ /dev/null
@@ -1,39 +0,0 @@
-/* C API wrapper around ForsakenNGS/Pico_WS2812 */
-
-#include "ws2812_led.h"
-
-#include "WS2812.hpp"
-
-#include "hardware/pio.h"
-#include "pico/stdlib.h"
-
-static WS2812 *s_strip;
-
-extern "C" void ws2812_init(unsigned int pin, unsigned int num_leds) {
- uint sm = pio_claim_unused_sm(pio0, true);
- s_strip = new WS2812(pin, num_leds, pio0, sm, WS2812::FORMAT_GRB);
-}
-
-extern "C" void ws2812_set_rgb(const uint8_t *rgb, unsigned int count) {
- if (s_strip == nullptr || rgb == nullptr) {
- return;
- }
- for (unsigned int i = 0; i < count; i++) {
- s_strip->setPixelColor(i, rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]);
- }
-}
-
-extern "C" void ws2812_fill(uint8_t r, uint8_t g, uint8_t b) {
- if (s_strip == nullptr) {
- return;
- }
- s_strip->fill(WS2812::RGB(r, g, b));
-}
-
-extern "C" void ws2812_show(void) {
- if (s_strip == nullptr) {
- return;
- }
- s_strip->show();
- sleep_us(300);
-}
diff --git a/firmware/panel/ws2812_led.h b/firmware/panel/ws2812_led.h
deleted file mode 100644
index 3bc2db5..0000000
--- a/firmware/panel/ws2812_led.h
+++ /dev/null
@@ -1,19 +0,0 @@
-#ifndef WS2812_LED_H
-#define WS2812_LED_H
-
-#include
-
-#ifdef __cplusplus
-extern "C" {
-#endif
-
-void ws2812_init(unsigned int pin, unsigned int num_leds);
-void ws2812_set_rgb(const uint8_t *rgb, unsigned int count);
-void ws2812_fill(uint8_t r, uint8_t g, uint8_t b);
-void ws2812_show(void);
-
-#ifdef __cplusplus
-}
-#endif
-
-#endif
diff --git a/firmware/panel/CMakeLists.txt b/firmware/pico/CMakeLists.txt
similarity index 93%
rename from firmware/panel/CMakeLists.txt
rename to firmware/pico/CMakeLists.txt
index 53039e2..0e892b4 100644
--- a/firmware/panel/CMakeLists.txt
+++ b/firmware/pico/CMakeLists.txt
@@ -47,14 +47,14 @@ if (DEFINED PANEL_ID_BUILD)
target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD})
endif ()
-if (DEFINED NUM_LEDS_BUILD)
- target_compile_definitions(panel PRIVATE NUM_LEDS=${NUM_LEDS_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 ()
+
option(PORTAL_USE_DHCP "Use DHCP for W5500" OFF)
if (PORTAL_USE_DHCP)
target_compile_definitions(panel PRIVATE USE_DHCP=1)
diff --git a/firmware/panel/Makefile b/firmware/pico/Makefile
similarity index 71%
rename from firmware/panel/Makefile
rename to firmware/pico/Makefile
index 9cbb131..e6058fe 100644
--- a/firmware/panel/Makefile
+++ b/firmware/pico/Makefile
@@ -8,7 +8,7 @@
# Options (passed to cmake on configure/reconfigure):
# make deploy PANEL_ID=0
# make deploy NO_DHCP=1
-# make deploy WS2812_PIN=28 # if data wire is on GP28 not GP27
+# make deploy WS2812_PIN=26 WS2812_PIN1=27 # override defaults GP28 / GP27
#
# Environment:
# PICO_SDK_PATH default: ~/pico/pico-sdk
@@ -28,24 +28,13 @@ SERIAL_BAUD ?= 115200
CMAKE_ARGS :=
ifdef PANEL_ID
CMAKE_ARGS += -DPANEL_ID_BUILD=$(PANEL_ID)
-# Panels 0 and 4 are 9×39 (351); others are 9×45 (405). Always pass NUM_LEDS so
-# cmake cache cannot keep a stale count from a previous PANEL_ID flash.
-ifeq ($(PANEL_ID),0)
-NUM_LEDS ?= 351
-else ifeq ($(PANEL_ID),4)
-NUM_LEDS ?= 351
-else
-NUM_LEDS ?= 405
-endif
-CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
-else
-ifdef NUM_LEDS
-CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
-endif
endif
ifdef WS2812_PIN
CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN)
endif
+ifdef WS2812_PIN1
+CMAKE_ARGS += -DWS2812_PIN1_BUILD=$(WS2812_PIN1)
+endif
ifeq ($(NO_DHCP),0)
CMAKE_ARGS += -DPORTAL_USE_DHCP=ON
else
@@ -56,7 +45,7 @@ CMAKE_STAMP_BODY := $(strip $(CMAKE_ARGS))
SOURCES := $(wildcard *.c *.cpp *.h) WS2812.pio CMakeLists.txt pico_sdk_import.cmake
-.PHONY: all build configure reconfigure deploy flash upload monitor clean help
+.PHONY: all build configure reconfigure deploy flash upload reset monitor clean help
all: build
@@ -65,9 +54,12 @@ help:
@echo " make build Build panel.uf2"
@echo " make deploy Build and flash over USB"
@echo " make flash Alias for deploy"
+ @echo " make reset USB reboot Pico (picotool) — run before monitor for boot log"
@echo " make monitor USB serial console (picocom, Ctrl+A Ctrl+X to quit)"
@echo " make clean Remove build directory"
@echo " make reconfigure Re-run cmake (e.g. after changing PANEL_ID)"
+ @echo ""
+ @echo "Typical: make reset && make monitor"
build: configure
@echo "==> Building panel firmware"
@@ -103,11 +95,32 @@ deploy: build
flash: deploy
upload: deploy
+reset:
+ @command -v picotool >/dev/null || (echo "Error: picotool not found (https://github.com/raspberrypi/picotool)" && exit 1)
+ @echo "==> Resetting Pico over USB"
+ @picotool reboot -f
+ @echo "==> Waiting for USB serial..."
+ @i=0; \
+ while [ $$i -lt 40 ]; do \
+ if ls /dev/ttyACM* >/dev/null 2>&1; then \
+ echo "==> Ready: $$(ls /dev/ttyACM* | head -1)"; \
+ exit 0; \
+ fi; \
+ i=$$((i + 1)); \
+ sleep 0.25; \
+ done; \
+ echo "Warning: no /dev/ttyACM* yet — try make monitor anyway"; \
+ exit 0
+
monitor:
@command -v picocom >/dev/null || (echo "Error: picocom not found (sudo apt install picocom)" && exit 1)
- @test -e "$(SERIAL_PORT)" || (echo "Error: $(SERIAL_PORT) not found (plug in Pico USB, or set SERIAL_PORT=...)" && exit 1)
- @echo "==> Serial monitor on $(SERIAL_PORT) ($(SERIAL_BAUD) baud, Ctrl+A Ctrl+X to quit)"
- picocom --baud $(SERIAL_BAUD) --flow n --echo $(SERIAL_PORT)
+ @SERIAL="$(SERIAL_PORT)"; \
+ if [ ! -e "$$SERIAL" ]; then \
+ SERIAL=$$(ls /dev/ttyACM* 2>/dev/null | head -1); \
+ fi; \
+ test -n "$$SERIAL" && test -e "$$SERIAL" || (echo "Error: no /dev/ttyACM* (plug in Pico USB, or set SERIAL_PORT=...)" && exit 1); \
+ echo "==> Serial monitor on $$SERIAL ($(SERIAL_BAUD) baud, Ctrl+A Ctrl+X to quit)"; \
+ picocom --baud $(SERIAL_BAUD) --flow n --echo "$$SERIAL"
clean:
rm -rf $(BUILD_DIR)
diff --git a/firmware/pico/README.md b/firmware/pico/README.md
new file mode 100644
index 0000000..973eeab
--- /dev/null
+++ b/firmware/pico/README.md
@@ -0,0 +1,66 @@
+# Portal Pico firmware (Pico SDK)
+
+C firmware for a **Pico + W5500 + WS2812** panel adapter under `firmware/pico/`.
+Replaces the CircuitPython `adapter/code.py` path with native UDP + PIO WS2812.
+
+## Hardware
+
+| Signal | GPIO |
+|--------|------|
+| W5500 CS | GP9 |
+| SPI1 SCK | GP10 |
+| SPI1 MOSI | GP11 |
+| SPI1 MISO | GP12 |
+| W5500 RST | GP13 |
+| Status LED | GP25 |
+| WS2812 strip 0 | GP28 |
+| WS2812 strip 1 | GP27 |
+
+**Two strips** per Pico. Pixel count comes from each UDP frame.
+`MAX_LEDS` (512) is capacity only — edit `board_config.h` to raise it.
+
+## Build
+
+```bash
+# From repo root
+make deploy PANEL_ID=0
+make reset && make monitor
+make build
+```
+
+Requires [pico-sdk](https://github.com/raspberrypi/pico-sdk) and `arm-none-eabi-gcc`.
+UF2: `firmware/pico/build/panel.uf2`.
+
+## Options
+
+```bash
+make deploy PANEL_ID=0 # 10.1.1.10
+make deploy PANEL_ID=1 # 10.1.1.11
+make deploy PANEL_ID=0 NO_DHCP=0
+make deploy PANEL_ID=0 WS2812_PIN=26 WS2812_PIN1=28
+```
+
+| Panel | IP | MAC |
+|-------|-----|-----|
+| 0 | 10.1.1.10 | 02:50:52:54:4C:00 |
+| 1 | 10.1.1.11 | 02:50:52:54:4C:01 |
+| … | … | … |
+| 4 | 10.1.1.14 | 02:50:52:54:4C:04 |
+| 255 (bench) | 10.1.1.19 | 02:50:52:54:4C:FF |
+
+## UDP protocol (port 50007)
+
+| Payload | Action |
+|---------|--------|
+| **N×3 bytes** | Raw RGB → strip 0 |
+| **1 + N×3 bytes** | `strip_id` + RGB (`0`/`1`, or `255` = all) |
+| **5 bytes `PIN\\0` + gpio** | Bind strip 0 |
+| **8 bytes `PIN\\0` + strip + gpio + len_lo + len_hi** | Bind strip (optional LED hint) |
+| **4 bytes `SHOW`** | Reserved |
+
+```bash
+# Both strips on one Pico (strip0=GP28, strip1=GP27)
+pipenv run python examples/panel_color_test.py --panel-index 0 --pins 28:405,27:351
+```
+
+Pi sends logical **RGB**. Firmware converts to **GRB** on the wire (`FORMAT_GRB`).
diff --git a/firmware/panel/WS2812.cpp b/firmware/pico/WS2812.cpp
similarity index 59%
rename from firmware/panel/WS2812.cpp
rename to firmware/pico/WS2812.cpp
index 5d64845..c684ddf 100644
--- a/firmware/panel/WS2812.cpp
+++ b/firmware/pico/WS2812.cpp
@@ -4,6 +4,9 @@
#include "WS2812.pio.h"
+#include "hardware/clocks.h"
+#include "hardware/gpio.h"
+
#include
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm) {
@@ -39,9 +42,45 @@ void WS2812::initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, Da
this->bytes[1] = b2;
this->bytes[2] = b3;
this->bytes[3] = b4;
- uint offset = pio_add_program(pio, &ws2812_program);
- uint bits = (b1 == NONE ? 24 : 32);
- ws2812_program_init(pio, sm, offset, pin, 800000, bits);
+ this->bits = (b1 == NONE ? 24 : 32);
+ /* 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;
+ }
+ 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) {
@@ -86,7 +125,14 @@ void WS2812::fill(uint32_t color) {
}
void WS2812::show() {
- for (uint i = 0; i < length; i++) {
+ show(length);
+}
+
+void WS2812::show(uint count) {
+ if (count > length) {
+ count = length;
+ }
+ for (uint i = 0; i < count; i++) {
pio_sm_put_blocking(pio, sm, data[i]);
}
}
diff --git a/firmware/panel/WS2812.hpp b/firmware/pico/WS2812.hpp
similarity index 87%
rename from firmware/panel/WS2812.hpp
rename to firmware/pico/WS2812.hpp
index 0f18b28..78ba571 100644
--- a/firmware/panel/WS2812.hpp
+++ b/firmware/pico/WS2812.hpp
@@ -21,18 +21,24 @@ class WS2812 {
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;
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();
};
#endif
diff --git a/firmware/panel/WS2812.pio b/firmware/pico/WS2812.pio
similarity index 100%
rename from firmware/panel/WS2812.pio
rename to firmware/pico/WS2812.pio
diff --git a/firmware/panel/board_config.h b/firmware/pico/board_config.h
similarity index 67%
rename from firmware/panel/board_config.h
rename to firmware/pico/board_config.h
index 724f133..781e86c 100644
--- a/firmware/panel/board_config.h
+++ b/firmware/pico/board_config.h
@@ -1,7 +1,7 @@
#ifndef BOARD_CONFIG_H
#define BOARD_CONFIG_H
-/* Matches adapter/code.py — W5500 on SPI1, WS2812 on GP27 */
+/* W5500 on SPI1; two WS2812 strips on GP27 + GP28 */
#define PIN_LED_STATUS 25
@@ -14,15 +14,25 @@
#define PIN_MISO 12
#define PIN_RST 13
+/* Strip 0 / strip 1 data pins (override with -DPIN_WS2812=… / -DPIN_WS2812_1=…). */
#ifndef PIN_WS2812
-#define PIN_WS2812 27
+#define PIN_WS2812 28
+#endif
+#ifndef PIN_WS2812_1
+#define PIN_WS2812_1 27
#endif
-#ifndef NUM_LEDS
-#define NUM_LEDS 405
+/* Strip / buffer capacity. Pixel count comes from each UDP frame length. */
+#ifndef MAX_LEDS
+#define MAX_LEDS 512
#endif
-#define LED_RGB_BYTES (NUM_LEDS * 3)
+#define LED_RGB_BYTES (MAX_LEDS * 3)
+
+/* Two WS2812 outputs on one Pico (separate GPIOs / PIO SMs). */
+#ifndef MAX_STRIPS
+#define MAX_STRIPS 2
+#endif
/* W5500 socket assignments */
#define SOCKET_DHCP 0
diff --git a/firmware/pico/main.c b/firmware/pico/main.c
new file mode 100644
index 0000000..9e2242d
--- /dev/null
+++ b/firmware/pico/main.c
@@ -0,0 +1,362 @@
+/**
+ * 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=10.1.1.%u\n",
+ MAX_LEDS, MAX_STRIPS, PANEL_ID, (unsigned)PANEL_ID, (unsigned)STATIC_IP_OCT4);
+
+ 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] = {PIN_WS2812, PIN_WS2812_1};
+ 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/panel/pico_sdk_import.cmake b/firmware/pico/pico_sdk_import.cmake
similarity index 100%
rename from firmware/panel/pico_sdk_import.cmake
rename to firmware/pico/pico_sdk_import.cmake
diff --git a/firmware/panel/timer.c b/firmware/pico/timer.c
similarity index 100%
rename from firmware/panel/timer.c
rename to firmware/pico/timer.c
diff --git a/firmware/panel/timer.h b/firmware/pico/timer.h
similarity index 100%
rename from firmware/panel/timer.h
rename to firmware/pico/timer.h
diff --git a/firmware/panel/wizchip_spi.c b/firmware/pico/wizchip_spi.c
similarity index 100%
rename from firmware/panel/wizchip_spi.c
rename to firmware/pico/wizchip_spi.c
diff --git a/firmware/panel/wizchip_spi.h b/firmware/pico/wizchip_spi.h
similarity index 100%
rename from firmware/panel/wizchip_spi.h
rename to firmware/pico/wizchip_spi.h
diff --git a/firmware/pico/ws2812_led.cpp b/firmware/pico/ws2812_led.cpp
new file mode 100644
index 0000000..245d09a
--- /dev/null
+++ b/firmware/pico/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/pico/ws2812_led.h b/firmware/pico/ws2812_led.h
new file mode 100644
index 0000000..d880093
--- /dev/null
+++ b/firmware/pico/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 4
+#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/leds/array_config.py b/leds/array_config.py
index 81515e5..daeb427 100644
--- a/leds/array_config.py
+++ b/leds/array_config.py
@@ -3,11 +3,45 @@
from __future__ import annotations
import os
+from typing import Literal
MATRIX_WIDTH = 45
MATRIX_HEIGHT = 9
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
+# --- Hexagonal portal -------------------------------------------------------
+# Bottom face = floor (no LEDs). Panels 0–4 numbered clockwise from bottom-left.
+# Panel 2 is the top face. All five panels are Pico UDP adapters.
+#
+# Viewed from outside:
+#
+# [2] TOP
+# [1] [3]
+# top-left top-right
+#
+# [0] [4]
+# bottom-left bottom-right
+#
+# Floor (no LEDs) is below.
+TOP_PANEL_INDEX = 2
+PORTAL_PANEL_ORDER_CLOCKWISE: tuple[int, ...] = (0, 1, 2, 3, 4)
+SIDE_PANEL_ORDER: tuple[int, ...] = (0, 1, 3, 4)
+PANEL_POSITION_BY_INDEX: dict[int, str] = {
+ 0: "bottom-left",
+ 1: "top-left",
+ 2: "top",
+ 3: "top-right",
+ 4: "bottom-right",
+}
+PortalFace = Literal["top", "side", "floor"]
+PANEL_FACE_BY_INDEX: dict[int, PortalFace] = {
+ 2: "top",
+ 0: "side",
+ 1: "side",
+ 3: "side",
+ 4: "side",
+}
+
# Per-panel width (height is always 9 rows). Edit to match your hardware.
# Panel 4 may still be 38 or 39 — run: panel_sync_test.py --test width --panel-index 4
PANEL_HEIGHT = 9
@@ -21,6 +55,24 @@ PANEL_WIDTH_BY_INDEX: dict[int, int] = {
DEFAULT_PANEL_WIDTH = 45
PANEL_COUNT = 5
+# Panels driven in-process by the animation script (SPI/PIO on this Pi).
+LOCAL_PANEL_INDICES: frozenset[int] = frozenset()
+
+# Optional: Pi UDP→SPI bridge panels (examples/panel_spi_bridge.py). Empty = all Pico.
+PI_BRIDGE_PANEL_INDICES: frozenset[int] = frozenset()
+
+# Optional: two panels on one Pico (GP27 + GP28).
+# Example — panels 0 and 1 on 10.1.1.10, strips 0 and 1:
+# PANEL_HOST_BY_INDEX = {0: "10.1.1.10", 1: "10.1.1.10"}
+# PANEL_STRIP_BY_INDEX = {0: 0, 1: 1}
+# PANEL_WS2812_PIN_BY_INDEX = {0: 28, 1: 27}
+PANEL_HOST_BY_INDEX: dict[int, str] = {}
+PANEL_STRIP_BY_INDEX: dict[int, int] = {}
+PANEL_WS2812_PIN_BY_INDEX: dict[int, int] = {}
+
+# Optional SPI bus override per panel index (Pi 5: 0 = GPIO 10, 1 = GPIO 20).
+PANEL_SPI_BUS_BY_INDEX: dict[int, int] = {}
+
# SPI data line: bus 0 = GPIO 10 (MOSI), bus 1 = GPIO 20 (SPI1 MOSI)
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
@@ -34,8 +86,9 @@ MATRIX_FLIP_Y = False
MATRIX_BRIGHTNESS = 0.25
-# LED wire order for direct SPI on Pi (not used for Pico UDP — firmware packs GRB).
-WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "grb").lower()
+# Optional wire reorder for direct SPI tests only (set PORTAL_WIRE_ORDER env).
+# UDP/bridge/firmware pass logical RGB through unchanged (default rgb = no swap).
+WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "rgb").lower()
def panel_width(panel_index: int) -> int:
@@ -51,6 +104,22 @@ def panel_pixel_count(panel_index: int) -> int:
return w * h
+def panel_face(panel_index: int) -> PortalFace | None:
+ return PANEL_FACE_BY_INDEX.get(panel_index)
+
+
+def panel_label(panel_index: int) -> str:
+ face = panel_face(panel_index)
+ pos = PANEL_POSITION_BY_INDEX.get(panel_index)
+ if face == "top":
+ return f"panel {panel_index} (top / Pi)"
+ if pos:
+ return f"panel {panel_index} ({pos})"
+ if face == "side":
+ return f"panel {panel_index} (side wall)"
+ return f"panel {panel_index}"
+
+
def matrix_pixel_index(
x: int,
y: int,
diff --git a/leds/backends/pio.py b/leds/backends/pio.py
index 87c06b3..09a4c20 100644
--- a/leds/backends/pio.py
+++ b/leds/backends/pio.py
@@ -34,7 +34,7 @@ class PioBackend:
frame = bytearray(self._led_count * 4)
for i in range(self._led_count):
base = i * 3
- g, r, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
+ r, g, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
offset = i * 4
frame[offset] = r
frame[offset + 1] = g
diff --git a/leds/backends/rpi5_ws2812.py b/leds/backends/rpi5_ws2812.py
new file mode 100644
index 0000000..9346ac1
--- /dev/null
+++ b/leds/backends/rpi5_ws2812.py
@@ -0,0 +1,54 @@
+"""WS2812 over SPI via the rpi5-ws2812 package (Pi 5, GPIO 10)."""
+
+from __future__ import annotations
+
+import numpy as np
+from rpi5_ws2812.ws2812 import WS2812SpiDriver
+
+from leds.backends.spi import spidev_bufsize_hint
+
+
+class Rpi5Ws2812Backend:
+ def __init__(
+ self,
+ led_count: int,
+ *,
+ spi_bus: int = 0,
+ spi_device: int = 0,
+ max_speed_hz: int = 4_200_000,
+ ) -> None:
+ hint = spidev_bufsize_hint(led_count)
+ if hint:
+ raise OSError(hint)
+
+ self._led_count = led_count
+ self._driver = WS2812SpiDriver(spi_bus, spi_device, led_count)
+ self._driver._device.max_speed_hz = max_speed_hz
+
+ @property
+ def led_count(self) -> int:
+ return self._led_count
+
+ def write_grb(self, rgb_bytes: bytes) -> None:
+ """RGB bytes passed through to SPI unchanged."""
+ if len(rgb_bytes) != self._led_count * 3:
+ raise ValueError(
+ f"expected {self._led_count * 3} bytes, got {len(rgb_bytes)}"
+ )
+ buf = np.frombuffer(rgb_bytes, dtype=np.uint8).reshape(self._led_count, 3)
+ self._driver.write(buf)
+
+ def clear(self) -> None:
+ self._driver.clear()
+
+ def close(self) -> None:
+ try:
+ self.clear()
+ finally:
+ self._driver._device.close()
+
+ def __enter__(self) -> Rpi5Ws2812Backend:
+ return self
+
+ def __exit__(self, *_exc) -> None:
+ self.close()
diff --git a/leds/backends/spi.py b/leds/backends/spi.py
index cc69982..d024113 100644
--- a/leds/backends/spi.py
+++ b/leds/backends/spi.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from array import array
+from pathlib import Path
try:
from spidev import SpiDev
@@ -16,6 +17,48 @@ else:
_LED_ZERO = 0b1100_0000
_LED_ONE = 0b1111_1100
_PREAMBLE = 42
+_DEFAULT_BUFSIZ = 65536
+
+
+def spidev_bufsize() -> int:
+ try:
+ return int(Path("/sys/module/spidev/parameters/bufsiz").read_text().strip())
+ except (OSError, ValueError):
+ return 4096
+
+
+def spi_frame_bytes(led_count: int) -> int:
+ return _PREAMBLE + led_count * 24
+
+
+def leds_from_truncated_frame(bufsize: int) -> int:
+ payload = max(0, bufsize - _PREAMBLE)
+ return payload // 24
+
+
+def spidev_bufsize_hint(led_count: int) -> str:
+ required = spi_frame_bytes(led_count)
+ bufsize = spidev_bufsize()
+ if bufsize >= required:
+ return ""
+ suggested = max(_DEFAULT_BUFSIZ, required + 1024)
+ truncated = leds_from_truncated_frame(bufsize)
+ return (
+ f"spidev bufsiz is {bufsize} but {led_count} LEDs need a {required}-byte SPI frame "
+ f"(only ~{truncated} LEDs update at the current limit).\n"
+ f"Fix now:\n"
+ f" sudo modprobe -r spidev\n"
+ f" sudo modprobe spidev bufsiz={suggested}\n"
+ f"Permanent:\n"
+ f" pipenv run python examples/setup_spi_bufsiz.py --install"
+ )
+
+
+def _write_spi_frame(device: SpiDev, frame: array) -> None:
+ required = len(frame)
+ if required > spidev_bufsize():
+ raise OSError(spidev_bufsize_hint((required - _PREAMBLE) // 24))
+ device.writebytes2(frame)
class SpiBackend:
@@ -39,12 +82,16 @@ class SpiBackend:
self._device.mode = 0b00
self._device.lsbfirst = False
- frame_len = _PREAMBLE + led_count * 24
+ frame_len = spi_frame_bytes(led_count)
self._frame = array("B", [0] * frame_len)
self._clear_frame = array("B", [0] * frame_len)
for i in range(_PREAMBLE, frame_len):
self._clear_frame[i] = _LED_ZERO
+ hint = spidev_bufsize_hint(led_count)
+ if hint:
+ raise OSError(hint)
+
@property
def led_count(self) -> int:
return self._led_count
@@ -62,11 +109,10 @@ class SpiBackend:
out[pos] = _LED_ONE if value & bit else _LED_ZERO
pos += 1
- # writebytes2 handles large frames (400+ LEDs); writebytes is capped at 4 KiB.
- self._device.writebytes2(out)
+ _write_spi_frame(self._device, out)
def clear(self) -> None:
- self._device.writebytes2(self._clear_frame)
+ _write_spi_frame(self._device, self._clear_frame)
def close(self) -> None:
try:
diff --git a/leds/colors.py b/leds/colors.py
index aa19efe..7d41e6e 100644
--- a/leds/colors.py
+++ b/leds/colors.py
@@ -7,6 +7,20 @@ from typing import Tuple
RGB = Tuple[int, int, int]
+# Logical RGB (R, G, B) — same as neopixel: RED=(255,0,0), GREEN=(0,255,0), etc.
+# Firmware and Pi SPI convert to GRB on the wire (NeoPixel default pixel_order).
+RED: RGB = (255, 0, 0)
+GREEN: RGB = (0, 255, 0)
+BLUE: RGB = (0, 0, 255)
+WHITE: RGB = (255, 255, 255)
+OFF: RGB = (0, 0, 0)
+
+RGB_CYCLE: tuple[tuple[str, RGB], ...] = (
+ ("RED", RED),
+ ("GREEN", GREEN),
+ ("BLUE", BLUE),
+)
+
def wheel(pos: int) -> RGB:
pos = pos % 256
diff --git a/leds/config.py b/leds/config.py
index ae336c9..e4c2aa2 100644
--- a/leds/config.py
+++ b/leds/config.py
@@ -13,7 +13,7 @@ except ImportError:
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
-BackendName = Literal["auto", "pio", "spi", "ws281x"]
+BackendName = Literal["auto", "pio", "spi", "rpi5_ws2812", "ws281x"]
@dataclass
@@ -25,7 +25,41 @@ class StripConfig:
device: str = "/dev/leds0"
spi_bus: int = 0
spi_device: int = 0
+ spi_max_speed_hz: int | None = None
+ wire_order: str | None = None
brightness: float = 1.0
+ passthrough: bool = False
+ swap_rg: bool = True
+
+
+def panel_strip_config(
+ panel_index: int,
+ count: int,
+ *,
+ spi_max_speed_hz: int | None = None,
+ wire_order: str | None = None,
+ brightness: float = 1.0,
+ passthrough: bool = True,
+ swap_rg: bool = True,
+) -> StripConfig:
+ from leds.array_config import LOCAL_PANEL_INDICES, PANEL_SPI_BUS_BY_INDEX
+
+ if panel_index in PANEL_SPI_BUS_BY_INDEX:
+ spi_bus = PANEL_SPI_BUS_BY_INDEX[panel_index]
+ elif panel_index in LOCAL_PANEL_INDICES:
+ spi_bus = pi5_second_spi_bus()
+ else:
+ spi_bus = MATRIX_SPI_BUS
+ return spi_strip_config(
+ count,
+ spi_bus=spi_bus,
+ spi_device=MATRIX_SPI_DEVICE,
+ spi_max_speed_hz=spi_max_speed_hz,
+ wire_order=wire_order,
+ brightness=brightness,
+ passthrough=passthrough,
+ swap_rg=swap_rg,
+ )
def spi_strip_config(
@@ -33,14 +67,25 @@ def spi_strip_config(
*,
spi_bus: int = MATRIX_SPI_BUS,
spi_device: int = MATRIX_SPI_DEVICE,
+ spi_max_speed_hz: int | None = None,
+ wire_order: str | None = None,
brightness: float = 1.0,
+ backend: BackendName | None = None,
+ passthrough: bool = False,
+ swap_rg: bool = True,
) -> StripConfig:
+ if backend is None:
+ backend = "rpi5_ws2812" if board_family() == "pi5" else "spi"
return StripConfig(
count,
- backend="spi",
+ backend=backend,
spi_bus=spi_bus,
spi_device=spi_device,
+ spi_max_speed_hz=spi_max_speed_hz,
+ wire_order=wire_order,
brightness=brightness,
+ passthrough=passthrough,
+ swap_rg=swap_rg,
)
diff --git a/leds/matrix.py b/leds/matrix.py
index 93505cf..3b4256a 100644
--- a/leds/matrix.py
+++ b/leds/matrix.py
@@ -61,6 +61,10 @@ class LedMatrix:
brightness=config0.brightness,
spi_bus=config0.spi_bus,
spi_device=config0.spi_device,
+ spi_max_speed_hz=config0.spi_max_speed_hz,
+ wire_order=config0.wire_order,
+ passthrough=config0.passthrough,
+ swap_rg=config0.swap_rg,
)
else:
total_rows = sum(rows_per_strip)
@@ -148,6 +152,12 @@ class LedMatrix:
assert self._strips is not None
self._strips.show()
+ def show_rgb_bytes(self, rgb: bytes) -> None:
+ """Push a linear RGB frame (row-major) straight to the strip."""
+ if self._strip is None:
+ raise RuntimeError("show_rgb_bytes requires a single-strip matrix")
+ self._strip.show_rgb_bytes(rgb)
+
def clear(self) -> None:
if self._strip is not None:
self._strip.clear()
diff --git a/leds/panel_bridge.py b/leds/panel_bridge.py
new file mode 100644
index 0000000..4885590
--- /dev/null
+++ b/leds/panel_bridge.py
@@ -0,0 +1,214 @@
+"""UDP-to-SPI bridge — makes a Pi-connected panel look like Pico firmware."""
+
+from __future__ import annotations
+
+import argparse
+import queue
+import socket
+import sys
+import threading
+from typing import Sequence
+
+from leds.array_config import MATRIX_BRIGHTNESS, panel_layout, panel_pixel_count
+from leds.config import panel_strip_config
+from leds.matrix import LedMatrix
+from leds.panel_udp import DEFAULT_PORT
+
+# Larger kernel RX buffer so SPI show() does not drop bursts from the animator.
+_SOCKET_RCVBUF = 1 << 20
+_DEFAULT_SPI_MHZ = 4.2
+
+
+def decode_panel_frame(
+ data: bytes,
+ pixel_count: int,
+ panel_id: int,
+) -> bytes | None:
+ """Match Pico firmware: raw RGB, or panel_id byte + RGB."""
+ rgb_len = pixel_count * 3
+ if len(data) == rgb_len:
+ return data
+ if len(data) == rgb_len + 1:
+ prefix = data[0]
+ if prefix != 255 and prefix != (panel_id & 0xFF):
+ return None
+ return data[1:]
+ return None
+
+
+def _recv_latest_frame(
+ sock: socket.socket,
+ bufsize: int,
+ pixel_count: int,
+ panel_id: int,
+) -> bytes | None:
+ """Block for one datagram, then drain the queue and return the newest frame."""
+ rgb: bytes | None = None
+ while rgb is None:
+ try:
+ data, _addr = sock.recvfrom(bufsize)
+ except InterruptedError:
+ continue
+ rgb = decode_panel_frame(data, pixel_count, panel_id)
+ sock.setblocking(False)
+ try:
+ while True:
+ try:
+ data, _addr = sock.recvfrom(bufsize)
+ except BlockingIOError:
+ break
+ newer = decode_panel_frame(data, pixel_count, panel_id)
+ if newer is not None:
+ rgb = newer
+ finally:
+ sock.setblocking(True)
+ return rgb
+
+
+def _receiver_loop(
+ sock: socket.socket,
+ bufsize: int,
+ pixel_count: int,
+ panel_id: int,
+ frames: queue.Queue[bytes],
+ stop: threading.Event,
+) -> None:
+ while not stop.is_set():
+ try:
+ rgb = _recv_latest_frame(sock, bufsize, pixel_count, panel_id)
+ except OSError:
+ if stop.is_set():
+ return
+ raise
+ if rgb is None:
+ continue
+ try:
+ frames.put_nowait(rgb)
+ except queue.Full:
+ try:
+ frames.get_nowait()
+ except queue.Empty:
+ pass
+ frames.put_nowait(rgb)
+
+
+def add_bridge_args(parser: argparse.ArgumentParser) -> None:
+ parser.add_argument(
+ "--panel-index",
+ type=int,
+ required=True,
+ choices=range(5),
+ metavar="N",
+ help="Panel index (sets LED count / SPI layout)",
+ )
+ parser.add_argument(
+ "--bind",
+ default="0.0.0.0",
+ help="Bind address (default: 0.0.0.0; animators send to 127.0.0.1)",
+ )
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
+ parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
+ parser.add_argument(
+ "--spi-mhz",
+ type=float,
+ default=_DEFAULT_SPI_MHZ,
+ help=f"SPI clock in MHz (default {_DEFAULT_SPI_MHZ})",
+ )
+ parser.add_argument(
+ "--wire-order",
+ default=None,
+ help="ignored (passthrough RGB); kept for compatibility",
+ )
+
+
+def run_bridge(
+ panel_index: int,
+ *,
+ bind: str | None = None,
+ port: int = DEFAULT_PORT,
+ brightness: float = MATRIX_BRIGHTNESS,
+ spi_mhz: float = _DEFAULT_SPI_MHZ,
+ wire_order: str | None = None, # unused; kept for compatibility
+) -> None:
+ width, height = panel_layout(panel_index)
+ pixels = panel_pixel_count(panel_index)
+ bind_addr = bind if bind is not None else "0.0.0.0"
+ spi_hz = max(1, int(spi_mhz * 1_000_000))
+ # UDP frames are already dimmed by the animator; avoid scaling twice on SPI.
+ strip_cfg = panel_strip_config(
+ panel_index,
+ pixels,
+ brightness=1.0,
+ spi_max_speed_hz=spi_hz,
+ passthrough=True,
+ swap_rg=False,
+ )
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, _SOCKET_RCVBUF)
+ try:
+ sock.bind((bind_addr, port))
+ except OSError as exc:
+ raise OSError(f"Cannot bind {bind_addr}:{port}: {exc}") from exc
+
+ print(
+ f"Panel {panel_index} bridge on {bind_addr}:{port} "
+ f"({width}×{height}, {pixels} LEDs, SPI bus {strip_cfg.spi_bus}, "
+ f"{spi_mhz:g} MHz, rpi5-ws2812, passthrough)"
+ )
+ print("Ctrl+C to stop")
+
+ bufsize = pixels * 3 + 1
+ frames: queue.Queue[bytes] = queue.Queue(maxsize=1)
+ stop = threading.Event()
+ receiver = threading.Thread(
+ target=_receiver_loop,
+ args=(sock, bufsize, pixels, panel_index, frames, stop),
+ name="panel-bridge-recv",
+ daemon=True,
+ )
+ receiver.start()
+
+ try:
+ with LedMatrix(width, height, config0=strip_cfg) as matrix:
+ while True:
+ rgb = frames.get()
+ while True:
+ try:
+ rgb = frames.get_nowait()
+ except queue.Empty:
+ break
+ matrix.show_rgb_bytes(rgb)
+ finally:
+ stop.set()
+ sock.close()
+ receiver.join(timeout=0.5)
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="UDP-to-SPI bridge (Pi panel emulates Pico firmware)"
+ )
+ add_bridge_args(parser)
+ args = parser.parse_args(argv)
+ try:
+ run_bridge(
+ args.panel_index,
+ bind=args.bind,
+ port=args.port,
+ brightness=args.brightness,
+ spi_mhz=args.spi_mhz,
+ wire_order=args.wire_order,
+ )
+ except KeyboardInterrupt:
+ print("\nStopped.")
+ return 0
+ except OSError as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/leds/panel_udp.py b/leds/panel_udp.py
index 0f29980..bed4423 100644
--- a/leds/panel_udp.py
+++ b/leds/panel_udp.py
@@ -1,31 +1,39 @@
-"""UDP helpers for Pico panel firmware."""
+"""UDP helpers for Pico panel firmware and Pi-direct panels."""
from __future__ import annotations
import argparse
+import asyncio
import os
import socket
+import subprocess
import time
from dataclasses import dataclass
-from typing import Iterable, Sequence, Tuple
+from typing import Any, Iterable, Sequence, Tuple
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
+ LOCAL_PANEL_INDICES,
MATRIX_HEIGHT,
MATRIX_PIXELS,
MATRIX_WIDTH,
PANEL_COUNT,
+ PANEL_HOST_BY_INDEX,
+ PANEL_STRIP_BY_INDEX,
+ PANEL_WS2812_PIN_BY_INDEX,
+ PI_BRIDGE_PANEL_INDICES,
matrix_pixel_index,
panel_layout,
panel_pixel_count,
)
from leds.colors import dim
+RGB = Tuple[int, int, int]
+
PANEL_IP_NET = "10.1.1"
PANEL_IP_BASE = 10
-PANEL_MAX_FPS = 25.0
-
-RGB = Tuple[int, int, int]
+PANEL_MAX_FPS = 15.0
+PANEL_SEND_TIMEOUT_S = 0.05
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
@@ -44,15 +52,37 @@ class PanelTarget:
host: str
width: int
height: int
+ local: bool = False
+ strip: int = 0
+ pin: int | None = None
@property
def pixels(self) -> int:
return self.width * self.height
def label(self) -> str:
+ if self.local:
+ return f"panel {self.index} Pi-direct ({self.width}×{self.height})"
+ strip = f" strip {self.strip}" if self.strip else ""
+ pin = f" GP{self.pin}" if self.pin is not None else ""
if self.index >= 0:
- return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
- return f"{self.host} ({self.width}×{self.height})"
+ return f"{self.host} ({self.width}×{self.height}, panel {self.index}{strip}{pin})"
+ return f"{self.host} ({self.width}×{self.height}{strip}{pin})"
+
+
+def _make_panel_target(index: int, width: int, height: int) -> PanelTarget:
+ local = index in LOCAL_PANEL_INDICES
+ if local:
+ host = "local"
+ elif index in PANEL_HOST_BY_INDEX:
+ host = PANEL_HOST_BY_INDEX[index]
+ elif index in PI_BRIDGE_PANEL_INDICES:
+ host = "127.0.0.1" # panel_spi_bridge on this Pi — no 10.1.1.12 on lo
+ else:
+ host = panel_ip(index)
+ strip = PANEL_STRIP_BY_INDEX.get(index, 0)
+ pin = PANEL_WS2812_PIN_BY_INDEX.get(index)
+ return PanelTarget(index, host, width, height, local=local, strip=strip, pin=pin)
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
@@ -73,22 +103,81 @@ def resolve_panel_targets(
w = width
if height is not None:
h = height
- return [PanelTarget(panel_index, panel_ip(panel_index), w, h)]
+ return [_make_panel_target(panel_index, w, h)]
if host:
w = width if width is not None else MATRIX_WIDTH
h = height if height is not None else MATRIX_HEIGHT
return [PanelTarget(-1, host, w, h)]
return [
- PanelTarget(i, panel_ip(i), *panel_layout(i))
+ _make_panel_target(i, *panel_layout(i))
for i in range(panels)
]
def format_panel_targets(targets: Sequence[PanelTarget], port: int) -> str:
if len(targets) == 1:
- return f"{targets[0].label()}:{port}"
+ t = targets[0]
+ suffix = "" if t.local else f":{port}"
+ return f"{t.label()}{suffix}"
layouts = ", ".join(f"{t.width}×{t.height}" for t in targets)
- return f"{len(targets)} panels ({targets[0].host} … {targets[-1].host}):{port} [{layouts}]"
+ udp = [t for t in targets if not t.local]
+ if udp:
+ return (
+ f"{len(targets)} panels ({udp[0].host} … {udp[-1].host}):{port} [{layouts}]"
+ )
+ return f"{len(targets)} Pi-direct panels [{layouts}]"
+
+
+def host_is_reachable(host: str) -> bool:
+ """Return False for hosts that would block UDP sends (ARP INCOMPLETE/FAILED).
+
+ A host may have neigh entries on more than one interface (e.g. eth0 REACHABLE
+ and wlan0 FAILED). Treat it as reachable if *any* entry is usable.
+ """
+ if host in ("127.0.0.1", "localhost", "::1"):
+ return True
+ try:
+ result = subprocess.run(
+ ["ip", "neigh", "show", host],
+ capture_output=True,
+ text=True,
+ timeout=1,
+ check=False,
+ )
+ lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
+ if lines:
+ if any(
+ "FAILED" not in ln and "INCOMPLETE" not in ln
+ for ln in lines
+ ):
+ return True
+ except (OSError, subprocess.SubprocessError):
+ pass
+ try:
+ result = subprocess.run(
+ ["ping", "-c", "1", "-W", "1", host],
+ capture_output=True,
+ timeout=2,
+ check=False,
+ )
+ return result.returncode == 0
+ except (OSError, subprocess.SubprocessError):
+ return False
+
+
+def filter_reachable_targets(targets: Sequence[PanelTarget]) -> list[PanelTarget]:
+ """Drop offline UDP panels so sendto() does not stall the animation loop on ARP."""
+ reachable: list[PanelTarget] = []
+ for target in targets:
+ if target.local:
+ reachable.append(target)
+ elif host_is_reachable(target.host):
+ reachable.append(target)
+ else:
+ print(f"Skipping offline panel {target.index} ({target.host})")
+ if not reachable:
+ raise OSError("No reachable panels on the network")
+ return reachable
def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
@@ -113,6 +202,29 @@ def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--panel-id", type=int, default=None, help="Prefix UDP frames with panel id")
+ parser.add_argument(
+ "--pin",
+ type=int,
+ default=None,
+ metavar="GP",
+ help="Set strip 0 WS2812 GPIO over UDP before sending frames",
+ )
+ parser.add_argument(
+ "--pins",
+ default=None,
+ metavar="GP[:LEDS],...",
+ help="Bind strips 0..n-1, e.g. 28,27 or 28:405,27:351 (per-pin length)",
+ )
+ parser.add_argument(
+ "--include-offline",
+ action="store_true",
+ help="Send to all configured panels even if unreachable (ARP stalls hit background thread only)",
+ )
+ parser.add_argument(
+ "--sync-send",
+ action="store_true",
+ help="Send UDP on the animation thread (blocks; not recommended)",
+ )
def add_panel_args(parser: argparse.ArgumentParser) -> None:
@@ -139,13 +251,18 @@ def add_panel_args(parser: argparse.ArgumentParser) -> None:
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
- return resolve_panel_targets(
+ targets = resolve_panel_targets(
args.host,
args.panel_index,
args.panels,
width=getattr(args, "width", None),
height=getattr(args, "height", None),
)
+ if getattr(args, "include_offline", False):
+ return targets
+ if args.panel_index is not None or args.host:
+ return targets
+ return filter_reachable_targets(targets)
def panel_count_from_args(args: argparse.Namespace) -> int:
@@ -205,6 +322,79 @@ class HeadlessMatrix:
return bytes(out)
+class PanelMatrixBundle:
+ """Drawing surfaces for UDP (headless) and Pi-direct (LedMatrix) panels."""
+
+ def __init__(
+ self,
+ targets: Sequence[PanelTarget],
+ brightness: float,
+ ) -> None:
+ from leds.config import panel_strip_config
+ from leds.matrix import LedMatrix
+
+ self.targets = list(targets)
+ self.matrices: list[tuple[PanelTarget, Any]] = []
+ self._local: list[LedMatrix] = []
+ for target in self.targets:
+ if target.local:
+ matrix = LedMatrix(
+ target.width,
+ target.height,
+ config0=panel_strip_config(
+ target.index, target.pixels, brightness=brightness
+ ),
+ )
+ self._local.append(matrix)
+ self.matrices.append((target, matrix))
+ else:
+ self.matrices.append(
+ (target, HeadlessMatrix(target.width, target.height, brightness=brightness))
+ )
+
+ def close(self) -> None:
+ for matrix in self._local:
+ matrix.close()
+
+ def __enter__(self) -> PanelMatrixBundle:
+ return self
+
+ def __exit__(self, *_exc) -> None:
+ self.close()
+
+
+def push_panel_frame(
+ sock: socket.socket | None,
+ target: PanelTarget,
+ matrix: Any,
+ port: int,
+ panel_id: int | None,
+) -> None:
+ if target.local:
+ matrix.show()
+ else:
+ if sock is None:
+ raise ValueError("UDP panel requires a socket")
+ send_frame(
+ sock,
+ target.host,
+ port,
+ matrix.rgb_bytes(),
+ panel_id,
+ strip=target.strip,
+ )
+
+
+def push_panel_frames(
+ sock: socket.socket | None,
+ matrices: Sequence[tuple[PanelTarget, Any]],
+ port: int,
+ panel_id: int | None,
+) -> None:
+ for target, matrix in matrices:
+ push_panel_frame(sock, target, matrix, port, panel_id)
+
+
def solid_frame_bytes(
color: RGB,
count: int = MATRIX_PIXELS,
@@ -225,33 +415,264 @@ def pixels_to_frame_bytes(
return bytes(out)
+def encode_frame(rgb: bytes, panel_id: int | None = None, *, strip: int = 0) -> bytes:
+ """Prefix strip index (or explicit panel_id) so multi-strip Picos route correctly."""
+ prefix = panel_id if panel_id is not None else strip
+ if prefix is None:
+ return rgb
+ # Always prefix when strip!=0; strip 0 may omit for tiny payloads but
+ # prefixing is harmless and keeps multi-strip hosts consistent.
+ if panel_id is None and strip == 0:
+ return rgb
+ return bytes([prefix & 0xFF]) + rgb
+
+
+def encode_pin_command(pin: int, strip: int = 0, length: int = 0) -> bytes:
+ """UDP PIN command.
+
+ strip 0, no length → 5 bytes ``PIN\\0`` + gpio.
+ Otherwise → 8 bytes ``PIN\\0`` + strip + gpio + len_lo + len_hi (little-endian).
+ """
+ if not 0 <= pin <= 29:
+ raise ValueError(f"WS2812 pin must be 0–29, got {pin}")
+ if not 0 <= strip <= 255:
+ raise ValueError(f"strip must be 0–255, got {strip}")
+ if length < 0 or length > 0xFFFF:
+ raise ValueError(f"length must be 0–65535, got {length}")
+ if strip == 0 and length == 0:
+ return b"PIN\0" + bytes([pin & 0xFF])
+ return b"PIN\0" + bytes(
+ [strip & 0xFF, pin & 0xFF, length & 0xFF, (length >> 8) & 0xFF]
+ )
+
+
+def send_ws2812_pin(
+ sock: socket.socket,
+ host: str,
+ port: int,
+ pin: int,
+ strip: int = 0,
+ length: int = 0,
+) -> None:
+ sock.sendto(encode_pin_command(pin, strip, length), (host, port))
+
+
+def send_ws2812_pin_to_targets(
+ sock: socket.socket | None,
+ targets: Sequence[PanelTarget],
+ port: int,
+ pin: int,
+ strip: int = 0,
+ length: int = 0,
+) -> None:
+ if sock is None:
+ return
+ for target in targets:
+ if target.local:
+ continue
+ send_ws2812_pin(sock, target.host, port, pin, strip, length)
+ extra = f" leds={length}" if length else ""
+ print(f"WS2812 strip {strip} GP{pin}{extra} -> {target.host}:{port}")
+
+
+def configure_target_pins(
+ sock: socket.socket | None,
+ targets: Sequence[PanelTarget],
+ port: int,
+) -> None:
+ """Send PIN for each target that has pin set (length = panel pixel count)."""
+ if sock is None:
+ return
+ seen: set[tuple[str, int, int]] = set()
+ for target in targets:
+ if target.local or target.pin is None:
+ continue
+ key = (target.host, target.strip, target.pin)
+ if key in seen:
+ continue
+ seen.add(key)
+ length = target.pixels
+ send_ws2812_pin(sock, target.host, port, target.pin, target.strip, length)
+ print(
+ f"WS2812 strip {target.strip} GP{target.pin} leds={length} "
+ f"-> {target.host}:{port}"
+ )
+
+
+def setup_panel_pins(
+ sock: socket.socket | None,
+ targets: Sequence[PanelTarget],
+ port: int,
+ *,
+ pin: int | None = None,
+ pins: Sequence[tuple[int, int | None]] | None = None,
+) -> None:
+ """Apply array_config pins, then optional CLI overrides.
+
+ ``pins`` entries are ``(gpio, length|None)`` — length None means unset.
+ """
+ configure_target_pins(sock, targets, port)
+ if sock is None:
+ return
+ if pins:
+ hosts = sorted({t.host for t in targets if not t.local})
+ for host in hosts:
+ for strip, (gp, length) in enumerate(pins):
+ leds = length or 0
+ send_ws2812_pin(sock, host, port, gp, strip, leds)
+ extra = f" leds={leds}" if leds else ""
+ print(f"WS2812 strip {strip} GP{gp}{extra} -> {host}:{port}")
+ return
+ if pin is not None:
+ send_ws2812_pin_to_targets(sock, targets, port, pin, strip=0)
+
+
+def parse_pins_arg(args: argparse.Namespace) -> list[tuple[int, int | None]] | None:
+ """Parse ``--pins 28,27`` or ``--pins 28:405,27:351`` → [(gpio, leds|None), ...]."""
+ raw = getattr(args, "pins", None)
+ if not raw:
+ return None
+ out: list[tuple[int, int | None]] = []
+ for part in raw.split(","):
+ part = part.strip()
+ if not part:
+ continue
+ if ":" in part:
+ gp_s, led_s = part.split(":", 1)
+ out.append((int(gp_s.strip()), int(led_s.strip())))
+ else:
+ out.append((int(part), None))
+ return out or None
+
+
+def expand_targets_for_pins(
+ targets: Sequence[PanelTarget],
+ pins: Sequence[tuple[int, int | None]],
+) -> list[PanelTarget]:
+ """One PanelTarget per strip so frames reach every GPIO from ``--pins``."""
+ out: list[PanelTarget] = []
+ for target in targets:
+ if target.local:
+ out.append(target)
+ continue
+ for strip, (gp, length) in enumerate(pins):
+ if length and length > 0 and target.height > 0 and length % target.height == 0:
+ width = length // target.height
+ height = target.height
+ elif length and length > 0:
+ width, height = length, 1
+ else:
+ width, height = target.width, target.height
+ out.append(
+ PanelTarget(
+ target.index,
+ target.host,
+ width,
+ height,
+ local=False,
+ strip=strip,
+ pin=gp,
+ )
+ )
+ return out
+
+
+def apply_pin_arg(
+ args: argparse.Namespace,
+ sock: socket.socket | None,
+ targets: Sequence[PanelTarget],
+) -> list[PanelTarget]:
+ """Send PIN commands; return targets expanded for ``--pins`` multi-strip."""
+ pins = parse_pins_arg(args)
+ pin = getattr(args, "pin", None)
+ setup_panel_pins(
+ sock,
+ targets,
+ args.port,
+ pin=pin,
+ pins=pins,
+ )
+ if pins:
+ return expand_targets_for_pins(targets, pins)
+ if pin is not None:
+ return [
+ PanelTarget(
+ t.index,
+ t.host,
+ t.width,
+ t.height,
+ local=t.local,
+ strip=t.strip,
+ pin=pin if not t.local else t.pin,
+ )
+ for t in targets
+ ]
+ return list(targets)
+
+
def send_frame(
sock: socket.socket,
host: str,
port: int,
rgb: bytes,
panel_id: int | None,
+ *,
+ strip: int = 0,
) -> None:
- payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
- sock.sendto(payload, (host, port))
+ # Firmware expects logical RGB; converts to GRB on the wire.
+ sock.sendto(encode_frame(rgb, panel_id, strip=strip), (host, port))
+
+
+async def send_frames_async(
+ sock: socket.socket | None,
+ matrices: Sequence[tuple[PanelTarget, Any]],
+ port: int,
+ panel_id: int | None,
+ *,
+ sync_send: bool = False,
+ send_timeout: float = PANEL_SEND_TIMEOUT_S,
+) -> None:
+ """Send one frame to every panel (UDP async, Pi-direct via show())."""
+ if sync_send:
+ push_panel_frames(sock, matrices, port, panel_id)
+ return
+ tasks: list[asyncio.Task[None]] = []
+ for target, matrix in matrices:
+ if target.local:
+ matrix.show()
+ else:
+ tasks.append(
+ asyncio.create_task(
+ asyncio.to_thread(
+ send_frame,
+ sock,
+ target.host,
+ port,
+ matrix.rgb_bytes(),
+ panel_id,
+ strip=target.strip,
+ )
+ )
+ )
+ if not tasks:
+ return
+ _, pending = await asyncio.wait(tasks, timeout=send_timeout)
+ for task in pending:
+ task.cancel()
def send_solid_to_targets(
- sock: socket.socket,
+ sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
color: RGB,
brightness: float,
panel_id: int | None,
) -> None:
- for target in targets:
- send_frame(
- sock,
- target.host,
- port,
- solid_frame_bytes(color, target.pixels, brightness),
- panel_id,
- )
+ with PanelMatrixBundle(targets, brightness) as bundle:
+ for _, matrix in bundle.matrices:
+ matrix.fill(color)
+ push_panel_frames(sock, bundle.matrices, port, panel_id)
class PanelClient:
@@ -278,17 +699,27 @@ class PanelClient:
self.port = port
self.panel_id = panel_id
self.brightness = brightness
- self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self._has_local = any(t.local for t in self.targets)
+ self._bundle: PanelMatrixBundle | None = None
+ if self._has_local:
+ self._bundle = PanelMatrixBundle(self.targets, brightness)
+ self._sock = (
+ socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ if any(not t.local for t in self.targets)
+ else None
+ )
@classmethod
def from_args(cls, args: argparse.Namespace) -> PanelClient:
- return cls(
+ client = cls(
panel_targets_from_args(args),
args.port,
pixel_count=panel_count_from_args(args),
panel_id=args.panel_id,
brightness=args.brightness,
)
+ client.targets = apply_pin_arg(args, client._sock, client.targets)
+ return client
@property
def pixel_count(self) -> int:
@@ -301,7 +732,11 @@ class PanelClient:
return [t.host for t in self.targets]
def close(self) -> None:
- self._sock.close()
+ if self._sock is not None:
+ self._sock.close()
+ if self._bundle is not None:
+ self._bundle.close()
+ self._bundle = None
def __enter__(self) -> PanelClient:
return self
@@ -314,11 +749,42 @@ class PanelClient:
if len(self.targets) != 1:
raise ValueError("send_bytes requires a single panel target")
target = self.targets[0]
+ if target.local:
+ if self._bundle is None:
+ raise RuntimeError("local panel bundle not initialized")
+ _, matrix = self._bundle.matrices[0]
+ if len(rgb) != target.pixels * 3:
+ raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
+ for i in range(target.pixels):
+ base = i * 3
+ matrix.set_pixel(
+ i % target.width,
+ i // target.width,
+ (rgb[base], rgb[base + 1], rgb[base + 2]),
+ )
+ matrix.show()
+ return
+ if self._sock is None:
+ raise RuntimeError("UDP socket not initialized")
if len(rgb) != target.pixels * 3:
raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
- send_frame(self._sock, target.host, self.port, rgb, self.panel_id)
+ send_frame(
+ self._sock,
+ target.host,
+ self.port,
+ rgb,
+ self.panel_id,
+ strip=target.strip,
+ )
def fill(self, color: RGB = (0, 0, 0)) -> None:
+ if self._bundle is not None:
+ for _, matrix in self._bundle.matrices:
+ matrix.fill(color)
+ push_panel_frames(self._sock, self._bundle.matrices, self.port, self.panel_id)
+ return
+ if self._sock is None:
+ raise RuntimeError("UDP socket not initialized")
for target in self.targets:
send_frame(
self._sock,
@@ -326,6 +792,7 @@ class PanelClient:
self.port,
solid_frame_bytes(color, target.pixels, self.brightness),
self.panel_id,
+ strip=target.strip,
)
def send_pixels(self, pixels: list[RGB]) -> None:
@@ -349,8 +816,59 @@ class PanelClient:
def _panel_matrices(
targets: Sequence[PanelTarget],
brightness: float,
-) -> list[tuple[PanelTarget, HeadlessMatrix]]:
- return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
+) -> PanelMatrixBundle:
+ return PanelMatrixBundle(targets, brightness)
+
+
+async def run_panel_playlist_async(
+ targets: Sequence[PanelTarget],
+ port: int,
+ playlist: list[str],
+ *,
+ duration: float = 12.0,
+ fps_override: float | None = None,
+ brightness: float = 0.25,
+ panel_id: int | None = None,
+ loop: bool = True,
+ sync_send: bool = False,
+ pin: int | None = None,
+ pins: Sequence[tuple[int, int | None]] | None = None,
+) -> None:
+ bundle = _panel_matrices(targets, brightness)
+ sock = (
+ socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ if any(not t.local for t in targets)
+ else None
+ )
+ try:
+ setup_panel_pins(sock, targets, port, pin=pin, pins=pins)
+ while True:
+ for name in playlist:
+ draw = ANIMATIONS[name]
+ fps = min(fps_override or DEFAULT_FPS.get(name, 30), PANEL_MAX_FPS)
+ delay = 1.0 / fps
+ frames = max(int(duration * fps), 1)
+ print(f" {name} ({fps:.0f} fps, {frames} frames)")
+ for frame in range(frames):
+ t0 = time.monotonic()
+ for _, matrix in bundle.matrices:
+ draw(matrix, frame)
+ await send_frames_async(
+ sock, bundle.matrices, port, panel_id, sync_send=sync_send
+ )
+ elapsed = time.monotonic() - t0
+ if elapsed < delay:
+ await asyncio.sleep(delay - elapsed)
+ if not loop:
+ break
+ print(" --- loop ---")
+ for _, matrix in bundle.matrices:
+ matrix.clear()
+ await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
+ finally:
+ if sock is not None:
+ sock.close()
+ bundle.close()
def run_panel_playlist(
@@ -363,33 +881,69 @@ def run_panel_playlist(
brightness: float = 0.25,
panel_id: int | None = None,
loop: bool = True,
+ sync_send: bool = False,
+ pin: int | None = None,
+ pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
- matrices = _panel_matrices(targets, brightness)
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ asyncio.run(
+ run_panel_playlist_async(
+ targets,
+ port,
+ playlist,
+ duration=duration,
+ fps_override=fps_override,
+ brightness=brightness,
+ panel_id=panel_id,
+ loop=loop,
+ sync_send=sync_send,
+ pin=pin,
+ pins=pins,
+ )
+ )
+
+
+async def run_panel_animation_loop_async(
+ targets: Sequence[PanelTarget],
+ port: int,
+ animation: str,
+ *,
+ fps: float | None = None,
+ brightness: float = 0.25,
+ panel_id: int | None = None,
+ sync_send: bool = False,
+ pin: int | None = None,
+ pins: Sequence[tuple[int, int | None]] | None = None,
+) -> None:
+ draw = ANIMATIONS[animation]
+ rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
+ interval = 1.0 / rate
+ bundle = _panel_matrices(targets, brightness)
+ sock = (
+ socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ if any(not t.local for t in targets)
+ else None
+ )
+ frame = 0
try:
+ setup_panel_pins(
+ sock, targets, port, pin=pin, pins=pins
+ )
while True:
- for name in playlist:
- draw = ANIMATIONS[name]
- fps = min(fps_override or DEFAULT_FPS.get(name, 30), PANEL_MAX_FPS)
- delay = 1.0 / fps
- frames = max(int(duration * fps), 1)
- print(f" {name} ({fps:.0f} fps, {frames} frames)")
- for frame in range(frames):
- t0 = time.monotonic()
- for target, matrix in matrices:
- draw(matrix, frame)
- send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
- elapsed = time.monotonic() - t0
- if elapsed < delay:
- time.sleep(delay - elapsed)
- if not loop:
- break
- print(" --- loop ---")
- for target, matrix in matrices:
- matrix.clear()
- send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
+ t0 = time.monotonic()
+ for _, matrix in bundle.matrices:
+ draw(matrix, frame)
+ await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
+ frame += 1
+ elapsed = time.monotonic() - t0
+ if elapsed < interval:
+ await asyncio.sleep(interval - elapsed)
finally:
- sock.close()
+ for _, matrix in bundle.matrices:
+ matrix.clear()
+ await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
+ if sock is not None:
+ sock.close()
+ bundle.close()
def run_panel_animation_loop(
@@ -400,28 +954,23 @@ def run_panel_animation_loop(
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
+ sync_send: bool = False,
+ pin: int | None = None,
+ pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
- draw = ANIMATIONS[animation]
- rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
- interval = 1.0 / rate
- matrices = _panel_matrices(targets, brightness)
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- frame = 0
- try:
- while True:
- t0 = time.monotonic()
- for target, matrix in matrices:
- draw(matrix, frame)
- send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
- frame += 1
- elapsed = time.monotonic() - t0
- if elapsed < interval:
- time.sleep(interval - elapsed)
- finally:
- for target, matrix in matrices:
- matrix.clear()
- send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
- sock.close()
+ asyncio.run(
+ run_panel_animation_loop_async(
+ targets,
+ port,
+ animation,
+ fps=fps,
+ brightness=brightness,
+ panel_id=panel_id,
+ sync_send=sync_send,
+ pin=pin,
+ pins=pins,
+ )
+ )
def animation_playlist(animation: str) -> list[str]:
diff --git a/leds/portal_sim.py b/leds/portal_sim.py
new file mode 100644
index 0000000..fa1e424
--- /dev/null
+++ b/leds/portal_sim.py
@@ -0,0 +1,69 @@
+"""Render portal animations for the web simulator."""
+
+from __future__ import annotations
+
+from leds.animations import ANIMATIONS, DEFAULT_FPS
+from leds.array_config import (
+ MATRIX_BRIGHTNESS,
+ PANEL_COUNT,
+ PANEL_FACE_BY_INDEX,
+ PANEL_POSITION_BY_INDEX,
+ PORTAL_PANEL_ORDER_CLOCKWISE,
+ TOP_PANEL_INDEX,
+ panel_layout,
+ panel_label,
+)
+from leds.panel_udp import HeadlessMatrix
+
+
+def portal_config() -> dict:
+ panels = []
+ for index in range(PANEL_COUNT):
+ width, height = panel_layout(index)
+ panels.append(
+ {
+ "index": index,
+ "width": width,
+ "height": height,
+ "position": PANEL_POSITION_BY_INDEX.get(index),
+ "face": PANEL_FACE_BY_INDEX.get(index),
+ "label": panel_label(index),
+ }
+ )
+ return {
+ "panels": panels,
+ "topPanel": TOP_PANEL_INDEX,
+ "orderClockwise": list(PORTAL_PANEL_ORDER_CLOCKWISE),
+ "animations": list(ANIMATIONS.keys()),
+ "defaultFps": DEFAULT_FPS,
+ "defaultBrightness": MATRIX_BRIGHTNESS,
+ }
+
+
+def render_portal_frame(
+ animation: str,
+ frame: int,
+ *,
+ brightness: float = MATRIX_BRIGHTNESS,
+) -> dict:
+ if animation not in ANIMATIONS:
+ raise ValueError(f"unknown animation: {animation}")
+ draw = ANIMATIONS[animation]
+ panels = []
+ for index in range(PANEL_COUNT):
+ width, height = panel_layout(index)
+ matrix = HeadlessMatrix(width, height, brightness=brightness)
+ draw(matrix, frame)
+ panels.append(
+ {
+ "index": index,
+ "width": width,
+ "height": height,
+ "rgb": list(matrix.rgb_bytes()),
+ }
+ )
+ return {
+ "frame": frame,
+ "animation": animation,
+ "panels": panels,
+ }
diff --git a/leds/portal_web.py b/leds/portal_web.py
new file mode 100644
index 0000000..8d27d79
--- /dev/null
+++ b/leds/portal_web.py
@@ -0,0 +1,145 @@
+"""FastAPI app for the portal web simulator."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException, Query
+from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+
+from leds.animations import DEFAULT_FPS
+from leds.portal_sim import portal_config, render_portal_frame
+
+WEB_DIR = Path(__file__).resolve().parent.parent / "web"
+
+_reload_queues: list[asyncio.Queue[str]] = []
+
+
+def _dev_reload_enabled() -> bool:
+ return os.environ.get("PORTAL_DEV_RELOAD", "0") == "1"
+
+
+async def _notify_reload_clients() -> None:
+ for queue in list(_reload_queues):
+ await queue.put("reload")
+
+
+async def _watch_web_files() -> None:
+ from watchfiles import awatch
+
+ async for _changes in awatch(WEB_DIR):
+ await _notify_reload_clients()
+
+
+@asynccontextmanager
+async def _lifespan(_app: FastAPI):
+ watcher: asyncio.Task | None = None
+ if _dev_reload_enabled():
+ watcher = asyncio.create_task(_watch_web_files())
+ yield
+ if watcher is not None:
+ watcher.cancel()
+ try:
+ await watcher
+ except asyncio.CancelledError:
+ pass
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(title="Portal Simulator", lifespan=_lifespan)
+
+ @app.get("/")
+ async def index() -> HTMLResponse:
+ html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
+ if _dev_reload_enabled():
+ snippet = ''
+ html = html.replace("
+
+
+
+
+ ", f" {snippet}\n ")
+ return HTMLResponse(html)
+
+ @app.get("/api/config")
+ async def api_config() -> dict:
+ return portal_config()
+
+ @app.get("/api/frame")
+ async def api_frame(
+ animation: str = Query("rainbow"),
+ frame: int = Query(0),
+ brightness: float = Query(0.35),
+ ) -> dict:
+ try:
+ return await asyncio.to_thread(
+ render_portal_frame, animation, frame, brightness=brightness
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ @app.get("/api/stream")
+ async def api_stream(
+ animation: str = Query("rainbow"),
+ brightness: float = Query(0.35),
+ fps: float = Query(None),
+ ) -> StreamingResponse:
+ rate = fps if fps is not None else DEFAULT_FPS.get(animation, 25)
+ rate = max(1.0, min(rate, 60.0))
+ delay = 1.0 / rate
+
+ async def generate() -> object:
+ frame = 0
+ while True:
+ payload = await asyncio.to_thread(
+ render_portal_frame,
+ animation,
+ frame,
+ brightness=brightness,
+ )
+ yield f"data: {json.dumps(payload)}\n\n"
+ frame += 1
+ await asyncio.sleep(delay)
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
+ )
+
+ @app.get("/api/dev/reload")
+ async def api_dev_reload() -> StreamingResponse:
+ if not _dev_reload_enabled():
+ raise HTTPException(status_code=404)
+
+ queue: asyncio.Queue[str] = asyncio.Queue()
+ _reload_queues.append(queue)
+
+ async def generate() -> object:
+ try:
+ yield "data: connected\n\n"
+ while True:
+ message = await queue.get()
+ yield f"data: {message}\n\n"
+ finally:
+ if queue in _reload_queues:
+ _reload_queues.remove(queue)
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
+ )
+
+ @app.get("/favicon.ico", include_in_schema=False)
+ async def favicon() -> FileResponse:
+ path = WEB_DIR / "favicon.ico"
+ if path.is_file():
+ return FileResponse(path)
+ raise HTTPException(status_code=404)
+
+ app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
+ return app
+
+
+app = create_app()
diff --git a/leds/strip.py b/leds/strip.py
index 5465c7d..6a712ce 100644
--- a/leds/strip.py
+++ b/leds/strip.py
@@ -5,13 +5,15 @@ from __future__ import annotations
from typing import Literal, Sequence, Tuple, Union
from leds.backends.pio import PioBackend
+from leds.backends.rpi5_ws2812 import Rpi5Ws2812Backend
from leds.backends.spi import SpiBackend
from leds.backends.ws281x import Ws281xBackend
from leds.config import pi5_setup_hint
from leds.detect import board_family, board_model, pio_available, supports_ws281x
+from leds.wire import normalize_wire_order, rgb_bytes_to_wire, swap_rg_bytes
Color = Union[Tuple[int, int, int], Sequence[int]]
-BackendName = Literal["auto", "pio", "spi", "ws281x"]
+BackendName = Literal["auto", "pio", "spi", "rpi5_ws2812", "ws281x"]
def _clamp(value: int) -> int:
@@ -41,10 +43,12 @@ def _resolve_backend(name: BackendName) -> str:
)
if name == "pio" and board_family() == "pi5" and not pio_available():
raise RuntimeError(pi5_setup_hint(10, 10))
+ if name == "rpi5_ws2812" and board_family() != "pi5":
+ raise RuntimeError("rpi5_ws2812 backend requires Raspberry Pi 5")
return name
if board_family() == "pi5":
- return "pio" if pio_available() else "spi"
+ return "rpi5_ws2812"
return "ws281x"
@@ -53,7 +57,8 @@ class LedStrip:
Drive a WS2812 / NeoPixel strip on Pi 5, 4, or Zero.
Default backends:
- - Pi 5: ws2812-pio on GPIO 18/13 (/dev/leds0, /dev/leds1)
+ - Pi 5: rpi5-ws2812 SPI on GPIO 10 (/dev/spidev0.0)
+ - Pi 5 (pio): ws2812-pio on GPIO 18/13 (/dev/leds0, /dev/leds1) when backend='pio'
- Pi 4 / Zero: PWM on GPIO 18/13 via rpi_ws281x
"""
@@ -68,20 +73,35 @@ class LedStrip:
brightness: float = 1.0,
spi_bus: int = 0,
spi_device: int = 0,
+ spi_max_speed_hz: int | None = None,
+ wire_order: str | None = None,
+ passthrough: bool = False,
+ swap_rg: bool = True,
) -> None:
if count < 1:
raise ValueError("count must be at least 1")
self._count = count
self._brightness = max(0.0, min(1.0, brightness))
+ self._passthrough = passthrough
+ self._swap_rg = swap_rg
+ self._wire_order = normalize_wire_order(wire_order)
self._pixels: list[Tuple[int, int, int]] = [(0, 0, 0)] * count
resolved = _resolve_backend(backend)
if resolved == "pio":
self._backend = PioBackend(count, device=device)
elif resolved == "spi":
+ spi_kwargs: dict[str, int] = {}
+ if spi_max_speed_hz is not None:
+ spi_kwargs["max_speed_hz"] = spi_max_speed_hz
self._backend = SpiBackend(
- count, spi_bus=spi_bus, spi_device=spi_device
+ count, spi_bus=spi_bus, spi_device=spi_device, **spi_kwargs
+ )
+ elif resolved == "rpi5_ws2812":
+ hz = spi_max_speed_hz if spi_max_speed_hz is not None else 4_200_000
+ self._backend = Rpi5Ws2812Backend(
+ count, spi_bus=spi_bus, spi_device=spi_device, max_speed_hz=hz
)
else:
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
@@ -121,14 +141,43 @@ class LedStrip:
self[index] = color
def show(self) -> None:
- grb = bytearray(self._count * 3)
+ self._write_frame(self._logical_frame_bytes())
+
+ def show_rgb_bytes(self, rgb: bytes) -> None:
+ """Push pre-dimmed RGB frame to the strip (skips pixel buffer)."""
+ if len(rgb) != self._count * 3:
+ raise ValueError(f"expected {self._count * 3} bytes, got {len(rgb)}")
+ scale = self._brightness
+ if scale >= 1.0:
+ logical = rgb
+ else:
+ dimmed = bytearray(len(rgb))
+ for i in range(0, len(rgb), 3):
+ dimmed[i] = int(rgb[i] * scale)
+ dimmed[i + 1] = int(rgb[i + 1] * scale)
+ dimmed[i + 2] = int(rgb[i + 2] * scale)
+ logical = bytes(dimmed)
+ self._write_frame(logical)
+
+ def _write_frame(self, logical_rgb: bytes) -> None:
+ frame = swap_rg_bytes(logical_rgb) if self._swap_rg else logical_rgb
+ if self._passthrough:
+ self._backend.write_grb(frame)
+ return
+ self._backend.write_grb(self._encode_frame_bytes(frame))
+
+ def _logical_frame_bytes(self) -> bytes:
+ logical = bytearray(self._count * 3)
for i, (r, g, b) in enumerate(self._pixels):
r, g, b = _apply_brightness((r, g, b), self._brightness)
base = i * 3
- grb[base] = g
- grb[base + 1] = r
- grb[base + 2] = b
- self._backend.write_grb(bytes(grb))
+ logical[base] = r
+ logical[base + 1] = g
+ logical[base + 2] = b
+ return bytes(logical)
+
+ def _encode_frame_bytes(self, logical_rgb: bytes) -> bytes:
+ return rgb_bytes_to_wire(logical_rgb, self._wire_order)
def clear(self) -> None:
self.fill((0, 0, 0))
diff --git a/leds/strips.py b/leds/strips.py
index 10dd6ae..f5d99c2 100644
--- a/leds/strips.py
+++ b/leds/strips.py
@@ -72,4 +72,8 @@ def _strip_from_config(config: StripConfig) -> LedStrip:
brightness=config.brightness,
spi_bus=config.spi_bus,
spi_device=config.spi_device,
+ spi_max_speed_hz=config.spi_max_speed_hz,
+ wire_order=config.wire_order,
+ passthrough=config.passthrough,
+ swap_rg=config.swap_rg,
)
diff --git a/leds/text.py b/leds/text.py
index bac3421..16e5cf4 100644
--- a/leds/text.py
+++ b/leds/text.py
@@ -1,7 +1,8 @@
-"""5×7 bitmap text for LED matrices."""
+"""5×7 bitmap text and optional TrueType (cursive) rendering for LED matrices."""
from __future__ import annotations
+from pathlib import Path
from typing import Tuple
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
@@ -53,6 +54,15 @@ CHAR_WIDTH = 5
CHAR_HEIGHT = 7
CHAR_SPACING = 1
+CURSIVE_FONT_CANDIDATES = (
+ # Prefer readable italics at 9px; true script fonts turn to mush.
+ "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf",
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
+ "/usr/share/fonts/opentype/dancingscript/DancingScript-Bold.otf",
+ "/usr/share/fonts/opentype/dancingscript/DancingScript-Regular.otf",
+)
+
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
text = text.upper()
@@ -122,3 +132,160 @@ def draw_text_centered(
x = max(0, (w - tw) // 2)
y = max(0, (h - th) // 2)
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)
+
+
+def resolve_cursive_font(path: str | None = None) -> str:
+ if path:
+ if not Path(path).is_file():
+ raise FileNotFoundError(f"font not found: {path}")
+ return path
+ for candidate in CURSIVE_FONT_CANDIDATES:
+ if Path(candidate).is_file():
+ return candidate
+ raise FileNotFoundError(
+ "No cursive font found. Install with: sudo apt install fonts-dancingscript"
+ )
+
+
+def render_font_mask(
+ text: str,
+ *,
+ height: int = MATRIX_HEIGHT,
+ font_path: str | None = None,
+ size: int | None = None,
+ threshold: int = 100,
+ letter_spacing: int = 1,
+) -> tuple[int, int, list[tuple[int, int]]]:
+ """Rasterize ``text`` into on-pixels for an LED row height.
+
+ Letters are drawn one-by-one with spacing so names stay readable on
+ short matrices. Returns ``(width, height, [(x, y), ...])``.
+ """
+ try:
+ from PIL import Image, ImageDraw, ImageFont
+ except ImportError as exc:
+ raise ImportError(
+ "Pillow is required for cursive text (pipenv install pillow)"
+ ) from exc
+
+ path = resolve_cursive_font(font_path)
+ # Slightly larger than panel height reads better after vertical fit.
+ point_size = size if size is not None else max(10, height + 3)
+ font = ImageFont.truetype(path, point_size)
+
+ glyphs: list[Image.Image] = []
+ for ch in text:
+ bbox = font.getbbox(ch)
+ gw = max(1, bbox[2] - bbox[0])
+ gh = max(1, bbox[3] - bbox[1])
+ g = Image.new("L", (gw + 2, height), 0)
+ draw = ImageDraw.Draw(g)
+ y = (height - gh) // 2 - bbox[1]
+ draw.text((1 - bbox[0], y), ch, font=font, fill=255)
+ glyphs.append(g)
+
+ total_w = sum(g.width for g in glyphs)
+ if glyphs:
+ total_w += letter_spacing * (len(glyphs) - 1)
+ canvas = Image.new("L", (max(1, total_w), height), 0)
+ x = 0
+ for i, g in enumerate(glyphs):
+ canvas.paste(g.point(lambda v: 255 if v > threshold else 0), (x, 0))
+ x += g.width + (letter_spacing if i < len(glyphs) - 1 else 0)
+
+ pixels = canvas.load()
+ xs = [
+ xx
+ for xx in range(canvas.width)
+ for yy in range(height)
+ if pixels[xx, yy] > 0
+ ]
+ if not xs:
+ return 0, height, []
+ left, right = min(xs), max(xs) + 1
+ ons: list[tuple[int, int]] = []
+ for yy in range(height):
+ for xx in range(left, right):
+ if pixels[xx, yy] > 0:
+ ons.append((xx - left, yy))
+ return right - left, height, ons
+
+
+def blit_mask(
+ surface,
+ ons: list[tuple[int, int]],
+ x: int,
+ y: int,
+ color: RGB,
+) -> None:
+ for px, py in ons:
+ _set(surface, x + px, y + py, color)
+
+
+def blit_mask_gradient(
+ surface,
+ ons: list[tuple[int, int]],
+ x: int,
+ y: int,
+ *,
+ width: int,
+ phase: float = 0.0,
+ cycles: float = 2.0,
+) -> None:
+ """Blit mask with a horizontal rainbow (hue shifts with ``phase``).
+
+ ``cycles`` = how many full rainbows span the text width (higher = more colorful).
+ """
+ from leds.colors import hsv_to_rgb
+
+ span = max(1, width)
+ for px, py in ons:
+ hue = ((px / span) * cycles + phase) % 1.0
+ _set(surface, x + px, y + py, hsv_to_rgb(hue, 1.0, 1.0))
+
+
+def draw_font_text(
+ surface,
+ text: str,
+ x: int,
+ y: int,
+ color: RGB,
+ *,
+ font_path: str | None = None,
+ size: int | None = None,
+ letter_spacing: int = 1,
+) -> int:
+ """Draw TrueType text; returns rendered width in pixels."""
+ h = getattr(surface, "height", MATRIX_HEIGHT)
+ width, _, ons = render_font_mask(
+ text,
+ height=h,
+ font_path=font_path,
+ size=size,
+ letter_spacing=letter_spacing,
+ )
+ blit_mask(surface, ons, x, y, color)
+ return width
+
+
+def draw_font_text_centered(
+ surface,
+ text: str,
+ color: RGB,
+ *,
+ font_path: str | None = None,
+ size: int | None = None,
+ letter_spacing: int = 1,
+) -> int:
+ w = getattr(surface, "width", MATRIX_WIDTH)
+ h = getattr(surface, "height", MATRIX_HEIGHT)
+ width, _, ons = render_font_mask(
+ text,
+ height=h,
+ font_path=font_path,
+ size=size,
+ letter_spacing=letter_spacing,
+ )
+ x = max(0, (w - width) // 2)
+ blit_mask(surface, ons, x, 0, color)
+ return width
diff --git a/leds/wire.py b/leds/wire.py
new file mode 100644
index 0000000..40d225e
--- /dev/null
+++ b/leds/wire.py
@@ -0,0 +1,47 @@
+"""Map logical RGB to WS2812 wire byte order."""
+
+from __future__ import annotations
+
+from leds.array_config import WIRE_ORDER
+
+# wire[i] = rgb_channel[perm[i]] where rgb = (r, g, b)
+_PERM: dict[str, tuple[int, int, int]] = {
+ "rgb": (0, 1, 2),
+ "grb": (1, 0, 2),
+ "rbg": (0, 2, 1),
+ "brg": (2, 0, 1),
+ "bgr": (2, 1, 0),
+ "gbr": (1, 2, 0),
+}
+
+
+def normalize_wire_order(order: str | None = None) -> str:
+ key = (order or WIRE_ORDER).lower()
+ if key not in _PERM:
+ raise ValueError(f"unknown wire order {key!r}; use one of {sorted(_PERM)}")
+ return key
+
+
+def rgb_to_wire(r: int, g: int, b: int, order: str | None = None) -> tuple[int, int, int]:
+ perm = _PERM[normalize_wire_order(order)]
+ ch = (r, g, b)
+ return ch[perm[0]], ch[perm[1]], ch[perm[2]]
+
+
+def rgb_bytes_to_wire(rgb: bytes, order: str | None = None) -> bytes:
+ perm = _PERM[normalize_wire_order(order)]
+ out = bytearray(len(rgb))
+ for i in range(0, len(rgb), 3):
+ ch = rgb[i], rgb[i + 1], rgb[i + 2]
+ out[i] = ch[perm[0]]
+ out[i + 1] = ch[perm[1]]
+ out[i + 2] = ch[perm[2]]
+ return bytes(out)
+
+
+def swap_rg_bytes(rgb: bytes) -> bytes:
+ """Swap R and G channels (hardware examples only — not the web simulator)."""
+ out = bytearray(rgb)
+ for i in range(0, len(out), 3):
+ out[i], out[i + 1] = out[i + 1], out[i]
+ return bytes(out)
diff --git a/pyproject.toml b/pyproject.toml
index 6096d20..fbefe1d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,13 @@ requires-python = ">=3.9"
dependencies = [
"spidev>=3.6",
"rpi-ws281x>=5.0.0",
+ "rpi5-ws2812>=0.1.2",
+]
+
+[project.optional-dependencies]
+sim = [
+ "fastapi>=0.115",
+ "uvicorn[standard]>=0.32",
]
[tool.setuptools.packages.find]
diff --git a/scripts/panel_deploy.sh b/scripts/panel_deploy.sh
deleted file mode 100755
index f599d84..0000000
--- a/scripts/panel_deploy.sh
+++ /dev/null
@@ -1,116 +0,0 @@
-#!/usr/bin/env bash
-# Build and upload portal panel firmware over USB.
-#
-# Usage:
-# ./scripts/panel_deploy.sh
-# ./scripts/panel_deploy.sh --build-only
-# ./scripts/panel_deploy.sh --upload-only
-#
-# Options:
-# --build-only Build, do not upload
-# --upload-only Upload existing build, skip compile
-# --panel-id N Pass -DPANEL_ID_BUILD=N to cmake
-# --no-dhcp Pass -DPORTAL_USE_DHCP=OFF to cmake
-# --num-leds N Pass -DNUM_LEDS_BUILD=N to cmake
-#
-# Environment:
-# PICO_SDK_PATH Path to pico-sdk (default: ~/pico/pico-sdk)
-
-set -euo pipefail
-
-ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-BUILD_DIR="${ROOT}/firmware/panel/build"
-PICO_SDK_PATH="${PICO_SDK_PATH:-${HOME}/pico/pico-sdk}"
-JOBS="$(nproc)"
-
-DO_BUILD=1
-DO_UPLOAD=1
-CMAKE_EXTRA=()
-
-die() {
- echo "Error: $*" >&2
- exit 1
-}
-
-usage() {
- sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
- exit "${1:-0}"
-}
-
-while (($# > 0)); do
- case "$1" in
- --build-only)
- DO_UPLOAD=0
- shift
- ;;
- --upload-only)
- DO_BUILD=0
- shift
- ;;
- --panel-id)
- [[ $# -ge 2 ]] || die "--panel-id requires a number"
- CMAKE_EXTRA+=("-DPANEL_ID_BUILD=${2}")
- shift 2
- ;;
- --no-dhcp)
- CMAKE_EXTRA+=("-DPORTAL_USE_DHCP=OFF")
- shift
- ;;
- --num-leds)
- [[ $# -ge 2 ]] || die "--num-leds requires a number"
- CMAKE_EXTRA+=("-DNUM_LEDS_BUILD=${2}")
- shift 2
- ;;
- -h|--help)
- usage 0
- ;;
- *)
- die "unknown option: $1 (try --help)"
- ;;
- esac
-done
-
-build_firmware() {
- [[ -d "${PICO_SDK_PATH}" ]] || die "pico-sdk not found at ${PICO_SDK_PATH} (set PICO_SDK_PATH)"
- command -v cmake >/dev/null || die "cmake is required"
- command -v make >/dev/null || die "make is required"
-
- export PICO_SDK_PATH
- mkdir -p "${BUILD_DIR}"
-
- echo "==> Building panel firmware in ${BUILD_DIR}"
- if [[ ! -f "${BUILD_DIR}/CMakeCache.txt" ]]; then
- cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
- elif ((${#CMAKE_EXTRA[@]} > 0)); then
- cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
- fi
- cmake --build "${BUILD_DIR}" -j "${JOBS}"
-
- [[ -f "${BUILD_DIR}/panel.uf2" ]] || die "build did not produce panel.uf2"
- echo "==> Build OK: ${BUILD_DIR}/panel.uf2"
-}
-
-upload_usb() {
- command -v picotool >/dev/null || die "picotool not found (install from https://github.com/raspberrypi/picotool)"
- if picotool version 2>&1 | grep -q 'without USB support'; then
- die "picotool was built without USB support; rebuild with libusb-1.0-0-dev installed"
- fi
-
- echo "==> Uploading over USB"
- if ! picotool load -x -f "${BUILD_DIR}/panel.uf2" 2>&1 | tee /tmp/picotool_load.log; then
- die "picotool load failed"
- fi
- if ! grep -q 'Loading into Flash' /tmp/picotool_load.log; then
- die "picotool did not flash (hold BOOTSEL, plug USB, retry)"
- fi
- echo "==> USB upload OK"
-}
-
-if ((DO_BUILD)); then
- build_firmware
-fi
-
-if ((DO_UPLOAD)); then
- [[ -f "${BUILD_DIR}/panel.uf2" ]] || die "panel.uf2 not found; run without --upload-only first"
- upload_usb
-fi
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..564a5a9
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,22 @@
+
+
+