Add portal web simulator, SPI bridges, and Pico firmware updates.

Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

11
.gitignore vendored
View File

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

View File

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

View File

@@ -5,6 +5,10 @@ name = "pypi"
[packages]
portal = {editable = true, path = "."}
"uvicorn[standard]" = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
pillow = "*"
[dev-packages]

700
Pipfile.lock generated
View File

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

103
README.md
View File

@@ -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 |
|--------|------|
| `<portal-app>` | Root layout, frame streaming |
| `<portal-controls>` | Sidebar settings |
| `<portal-viewport>` | Three.js 3D scene |
| `<portal-schematic>` | Flat layout map |
| `<portal-panel>` | 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
```

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,6 +50,7 @@ def main() -> int:
return 1
finally:
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
if sock is not None:
sock.close()
return 0

View File

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

View File

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

View File

@@ -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:
with PanelMatrixBundle(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.clear()
_push(sock, targets, matrices, port, panel_id)
_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 (04)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
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 {target.index} -> {target.host} ({target.width}×{target.height})")
_push(sock, targets, matrices, port, panel_id)
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,8 +105,8 @@ 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:
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.matrices:
w, h = target.width, target.height
matrix.clear()
points = {
@@ -116,12 +117,12 @@ def test_corners(
}
for name, (x, y) in points.items():
matrix[x, y] = CORNER_COLORS[name]
_push(sock, targets, matrices, port, panel_id)
_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,8 +130,8 @@ def test_crosshair(
pause: float,
) -> None:
print("Test: crosshair — center row + column (white)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.matrices:
w, h = target.width, target.height
matrix.clear()
mid_y = h // 2
@@ -139,12 +140,12 @@ def test_crosshair(
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)
_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")
with _matrices(targets, brightness) as bundle:
for x in range(max_width):
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
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, targets, matrices, port, panel_id)
_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")
with _matrices(targets, brightness) as bundle:
for y in range(height):
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
for target, matrix in bundle.matrices:
matrix.clear()
for x in range(target.width):
matrix[x, y] = (255, 255, 255)
_push(sock, targets, matrices, port, panel_id)
_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,10 +200,10 @@ 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:
with _matrices(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.fill((255, 255, 255))
_push(sock, targets, matrices, port, panel_id)
_push(sock, bundle, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
time.sleep(pause / 2)
@@ -210,7 +211,7 @@ def test_flash(
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)
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)
matrices = _matrices(targets, min(brightness * level, 1.0))
for _, matrix in matrices:
for _, matrix in bundle.matrices:
matrix.fill(color)
_push(sock, targets, matrices, port, panel_id)
_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,8 +242,8 @@ 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:
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.matrices:
matrix.clear()
matrix[0, 0] = (255, 0, 0)
last = target.pixels - 1
@@ -250,12 +251,12 @@ def test_chain_ends(
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)
_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=<n> 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,6 +357,7 @@ def main() -> int:
return 1
finally:
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
if sock is not None:
sock.close()
return 0

View File

@@ -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,8 +52,39 @@ 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()
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:
@@ -54,33 +93,99 @@ 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:
with PanelMatrixBundle(targets, args.brightness) as bundle:
if do_scroll:
start = time.monotonic()
offsets = {target.index: matrix.width for target, matrix in matrices}
offsets = {
target.index: matrix.width for target, matrix in bundle.matrices
}
while True:
for target, matrix in matrices:
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)
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
wrap = len(text) * 6
offsets[target.index] -= 1
if offsets[target.index] < -len(text) * 6:
if offsets[target.index] < -wrap:
offsets[target.index] = matrix.width
time.sleep(0.08)
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:
for target, matrix in matrices:
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)
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
if args.hold <= 0:
print("Showing (Ctrl+C to clear)")
while True:
@@ -90,9 +195,11 @@ def main() -> int:
except KeyboardInterrupt:
print("\nClearing.")
finally:
for target, matrix in matrices:
with PanelMatrixBundle(targets, args.brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.clear()
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
if sock is not None:
sock.close()
return 0

View File

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

View File

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

View File

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

153
examples/spi_panel_test.py Normal file
View File

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

114
examples/spi_rgb_test.py Normal file
View File

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

View File

@@ -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.1014
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: ~3050 fps per panel (vs ~1020 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` 04 (`make deploy PANEL_ID=N`). Each gets `10.1.1.1N` (1014), a unique MAC, and UDP filtering. Send frames from the Pi to each panels IP, or use a leading `panel_id` byte with `255` broadcast filtering.

View File

@@ -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 <stdio.h>
#include <stdint.h>
#include <string.h>
#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);
}
}

View File

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

View File

@@ -1,19 +0,0 @@
#ifndef WS2812_LED_H
#define WS2812_LED_H
#include <stdint.h>
#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

View File

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

View File

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

66
firmware/pico/README.md Normal file
View File

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

View File

@@ -4,6 +4,9 @@
#include "WS2812.pio.h"
#include "hardware/clocks.h"
#include "hardware/gpio.h"
#include <cstdlib>
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]);
}
}

View File

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

View File

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

362
firmware/pico/main.c Normal file
View File

@@ -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 strips pixel count comes from its own UDP frame length (strips may differ).
* MAX_LEDS is per-strip capacity.
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#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);
}
}

View File

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

View File

@@ -0,0 +1,33 @@
#ifndef WS2812_LED_H
#define WS2812_LED_H
#include <stdint.h>
#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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

214
leds/panel_bridge.py Normal file
View File

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

View File

@@ -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 029, got {pin}")
if not 0 <= strip <= 255:
raise ValueError(f"strip must be 0255, got {strip}")
if length < 0 or length > 0xFFFF:
raise ValueError(f"length must be 065535, 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:
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)}")
send_frame(self._sock, target.host, self.port, rgb, self.panel_id)
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,
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:
for _, matrix in bundle.matrices:
draw(matrix, frame)
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
frame += 1
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)
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
finally:
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]:

69
leds/portal_sim.py Normal file
View File

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

145
leds/portal_web.py Normal file
View File

@@ -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 = '<script type="module" src="/static/js/dev-reload.js"></script>'
html = html.replace("</body>", f" {snippet}\n </body>")
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()

View File

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

View File

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

View File

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

47
leds/wire.py Normal file
View File

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

View File

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

View File

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

22
web/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Portal Simulator</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<portal-app></portal-app>
<script type="importmap">
{
"imports": {
"three": "/static/vendor/three.module.js",
"three/addons/": "/static/vendor/"
}
}
</script>
<script type="module" src="/static/js/main.js"></script>
</body>
</html>

56
web/js/api.js Normal file
View File

@@ -0,0 +1,56 @@
/** @typedef {{ index: number, width: number, height: number, position: string, label?: string }} PanelConfig */
/** @typedef {{ frame: number, animation: string, panels: Array<{ index: number, width: number, height: number, rgb: number[] }> }} FramePayload */
/** @typedef {{ panels: PanelConfig[], animations: string[], defaultFps: Record<string, number>, defaultBrightness: number }} PortalConfig */
/**
* @param {string} path
* @returns {Promise<PortalConfig>}
*/
export async function fetchConfig(path = "/api/config") {
const res = await fetch(path);
if (!res.ok) throw new Error(`config ${res.status}`);
return res.json();
}
export class FrameStream {
/** @param {() => void} onFrame */
constructor(onFrame) {
this._onFrame = onFrame;
this._source = null;
this._retry = null;
/** @type {(() => void) | null} */
this.onDisconnect = null;
}
/** @param {{ animation: string, brightness: number, fps: number }} opts */
start(opts) {
this.stop();
const params = new URLSearchParams({
animation: opts.animation,
brightness: String(opts.brightness),
fps: String(opts.fps),
});
this._source = new EventSource(`/api/stream?${params}`);
this._source.onmessage = (event) => {
this._onFrame(JSON.parse(event.data));
};
this._source.onerror = () => {
this.onDisconnect?.();
this.stop();
this._retry = setTimeout(() => this.start(opts), 1500);
};
}
stop() {
if (this._retry) {
clearTimeout(this._retry);
this._retry = null;
}
if (this._source) {
this._source.close();
this._source = null;
}
}
}

View File

@@ -0,0 +1,154 @@
import { fetchConfig, FrameStream } from "../api.js";
import "./portal-controls.js";
import "./portal-viewport.js";
import "./portal-schematic.js";
const APP_STYLES = `
:host {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
--border: #2a3044;
--panel: #12151f;
--text: #e8ecf7;
--muted: #8b93a8;
--accent: #5b8cff;
}
main {
position: relative;
min-width: 0;
height: 100%;
}
portal-viewport {
width: 100%;
height: 100%;
}
portal-viewport[hidden] {
display: none;
}
.status {
position: absolute;
left: 1rem;
bottom: 1rem;
padding: 0.35rem 0.6rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--muted);
pointer-events: none;
font-family: system-ui, sans-serif;
}
@media (max-width: 800px) {
:host {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
portal-controls {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
`;
export class PortalApp extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
/** @type {FrameStream | null} */
this._stream = null;
/** @type {import('./portal-controls.js').PortalControls | null} */
this._controls = null;
/** @type {import('./portal-viewport.js').PortalViewport | null} */
this._viewport = null;
/** @type {import('./portal-schematic.js').PortalSchematic | null} */
this._schematic = null;
this._settings = {
animation: "rainbow",
brightness: 0.35,
fps: 25,
paused: false,
identify: false,
view3d: true,
};
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${APP_STYLES}</style>
<portal-controls></portal-controls>
<main>
<portal-viewport></portal-viewport>
<portal-schematic></portal-schematic>
<div class="status">Loading…</div>
</main>
`;
this._controls = root.querySelector("portal-controls");
this._viewport = root.querySelector("portal-viewport");
this._schematic = root.querySelector("portal-schematic");
this._status = root.querySelector(".status");
this.addEventListener("portal-settings", (e) => {
this._settings = e.detail;
this._viewport.hidden = !this._settings.view3d;
this._schematic.visible = !this._settings.view3d;
if (this._settings.paused && this._settings.identify && this._config) {
for (const panel of this._config.panels) {
this._viewport.updatePanel(panel.index, [], panel.width, panel.height, true);
this._schematic.updatePanel(panel.index, [], panel.width, panel.height, true);
}
}
this._syncStream();
});
this._init();
}
disconnectedCallback() {
this._stream?.stop();
}
async _init() {
try {
this._config = await fetchConfig();
this._controls.setConfig(this._config);
this._viewport.setPanels(this._config.panels);
this._schematic.setPanels(this._config.panels);
this._status.textContent = "Ready";
this._syncStream();
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
_syncStream() {
this._stream?.stop();
if (this._settings.paused) return;
this._stream = new FrameStream((payload) => this._onFrame(payload));
this._stream.onDisconnect = () => {
this._status.textContent = "Stream disconnected — retrying…";
};
this._stream.start({
animation: this._settings.animation,
brightness: this._settings.brightness,
fps: this._settings.fps,
});
}
/** @param {import('../api.js').FramePayload} payload */
_onFrame(payload) {
const { identify } = this._settings;
for (const panel of payload.panels) {
this._viewport.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
this._schematic.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
}
this._status.textContent = `${payload.animation} · frame ${payload.frame}`;
}
}
customElements.define("portal-app", PortalApp);

View File

@@ -0,0 +1,185 @@
const CONTROL_STYLES = `
:host {
display: block;
border-right: 1px solid var(--border, #2a3044);
background: var(--panel, #12151f);
padding: 1rem;
overflow-y: auto;
color: var(--text, #e8ecf7);
font-family: system-ui, sans-serif;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
p {
margin: 0 0 1rem;
color: var(--muted, #8b93a8);
font-size: 0.85rem;
line-height: 1.4;
}
a {
color: var(--accent, #5b8cff);
}
label {
display: block;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: var(--muted, #8b93a8);
}
select, input[type="range"], button {
width: 100%;
}
select, button {
background: #1a1f2e;
color: inherit;
border: 1px solid var(--border, #2a3044);
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
margin-top: 0.75rem;
cursor: pointer;
background: #243049;
}
button:hover { border-color: var(--accent, #5b8cff); }
button.active {
background: #2a3f6e;
border-color: var(--accent, #5b8cff);
}
.view-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
`;
export class PortalControls extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
this._animation = "rainbow";
this._brightness = 0.35;
this._fps = 25;
this._paused = false;
this._identify = false;
this._view3d = true;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${CONTROL_STYLES}</style>
<h1>Portal Simulator</h1>
<p>Hexagonal LED arch (see <a href="https://technical.kiwi/images/portal/IMG_20241029_220222.jpg" target="_blank" rel="noopener">photo</a>). Panel 2 top (Pi); 0, 1, 3, 4 walls; floor bare.</p>
<label for="animation">Animation</label>
<select id="animation"></select>
<label for="brightness">Brightness</label>
<input id="brightness" type="range" min="0.05" max="1" step="0.05" value="0.35" />
<label for="fps">FPS</label>
<input id="fps" type="range" min="5" max="40" step="1" value="25" />
<div class="view-toggle">
<button type="button" data-view="3d" class="active">3D view</button>
<button type="button" data-view="flat">Flat map</button>
</div>
<button type="button" data-action="identify">Identify panels</button>
<button type="button" data-action="pause">Pause</button>
`;
this._animationEl = root.querySelector("#animation");
this._brightnessEl = root.querySelector("#brightness");
this._fpsEl = root.querySelector("#fps");
this._animationEl.addEventListener("change", () => {
this._animation = this._animationEl.value;
if (this._config?.defaultFps[this._animation]) {
this._fps = Math.min(40, Math.round(this._config.defaultFps[this._animation]));
this._fpsEl.value = String(this._fps);
}
this._emitChange();
});
this._brightnessEl.addEventListener("input", () => {
this._brightness = Number(this._brightnessEl.value);
this._emitChange();
});
this._fpsEl.addEventListener("input", () => {
this._fps = Number(this._fpsEl.value);
this._emitChange();
});
root.querySelector('[data-view="3d"]').addEventListener("click", () => {
this._view3d = true;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-view="flat"]').addEventListener("click", () => {
this._view3d = false;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-action="identify"]').addEventListener("click", (e) => {
this._identify = !this._identify;
e.currentTarget.classList.toggle("active", this._identify);
this._emitChange();
});
root.querySelector('[data-action="pause"]').addEventListener("click", (e) => {
this._paused = !this._paused;
e.currentTarget.textContent = this._paused ? "Resume" : "Pause";
this._emitChange();
});
}
/** @param {import('../api.js').PortalConfig} config */
setConfig(config) {
this._config = config;
if (!this.shadowRoot) this.connectedCallback();
this._animationEl.innerHTML = "";
for (const name of config.animations) {
if (name === "solid") continue;
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
this._animationEl.appendChild(opt);
}
this._animationEl.value = "rainbow";
this._animation = "rainbow";
this._brightness = config.defaultBrightness ?? 0.35;
this._brightnessEl.value = String(this._brightness);
this._fps = Math.min(40, Math.round(config.defaultFps.rainbow ?? 25));
this._fpsEl.value = String(this._fps);
this._emitChange();
}
_syncViewButtons() {
const root = this.shadowRoot;
root.querySelector('[data-view="3d"]').classList.toggle("active", this._view3d);
root.querySelector('[data-view="flat"]').classList.toggle("active", !this._view3d);
}
_emitChange() {
this.dispatchEvent(
new CustomEvent("portal-settings", {
bubbles: true,
composed: true,
detail: {
animation: this._animation,
brightness: this._brightness,
fps: this._fps,
paused: this._paused,
identify: this._identify,
view3d: this._view3d,
},
}),
);
}
/** @returns {import('../api.js').PortalConfig | null} */
get config() {
return this._config;
}
}
customElements.define("portal-controls", PortalControls);

View File

@@ -0,0 +1,90 @@
import { paintIdentify, paintRgb } from "../panel-paint.js";
const PANEL_STYLES = `
:host {
display: block;
background: #000;
border: 1px solid var(--border, #2a3044);
border-radius: 4px;
image-rendering: pixelated;
}
canvas {
display: block;
width: 100%;
height: auto;
}
.label {
text-align: center;
font-size: 0.75rem;
color: var(--muted, #8b93a8);
margin: 0.35rem 0;
font-family: system-ui, sans-serif;
}
`;
export class PortalPanel extends HTMLElement {
static observedAttributes = ["index", "position", "width", "height"];
constructor() {
super();
this._index = 0;
this._pixelWidth = 45;
this._pixelHeight = 9;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${PANEL_STYLES}</style>
<canvas part="canvas"></canvas>
<div class="label" part="label"></div>
`;
this._canvas = /** @type {HTMLCanvasElement} */ (root.querySelector("canvas"));
this._ctx = /** @type {CanvasRenderingContext2D} */ (this._canvas.getContext("2d"));
this._label = root.querySelector(".label");
this._syncFromAttributes();
}
attributeChangedCallback(name) {
if (!this.shadowRoot) return;
this._syncFromAttributes();
}
_syncFromAttributes() {
this._index = Number(this.getAttribute("index") ?? 0);
this._pixelWidth = Number(this.getAttribute("width") ?? 45);
this._pixelHeight = Number(this.getAttribute("height") ?? 9);
const position = this.getAttribute("position") ?? "";
if (this._label) {
this._label.textContent = `${this._index} · ${position}`;
}
if (this._canvas) {
this._canvas.width = this._pixelWidth;
this._canvas.height = this._pixelHeight;
}
}
/** @returns {HTMLCanvasElement} */
get canvas() {
if (!this._canvas) this.connectedCallback();
return this._canvas;
}
/**
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
setFrame(rgb, width, height) {
if (!this._ctx) this.connectedCallback();
paintRgb(this._ctx, this._canvas, rgb, width, height);
}
showIdentify() {
if (!this._ctx) this.connectedCallback();
paintIdentify(this._ctx, this._canvas, this._index, this._pixelWidth, this._pixelHeight);
}
}
customElements.define("portal-panel", PortalPanel);

View File

@@ -0,0 +1,169 @@
import {
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_HEX_PATH,
SCHEMATIC_PANEL_LAYOUT,
} from "../portal-layout.js";
import "./portal-panel.js";
const SCHEMATIC_STYLES = `
:host {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: 1rem;
box-sizing: border-box;
background: radial-gradient(ellipse at 50% 45%, #141820 0%, #0a0c12 70%);
}
:host([visible]) {
display: flex;
}
.hex-stage {
position: relative;
width: min(88vw, 420px);
aspect-ratio: 100 / 118;
}
.hex-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hex-fill {
fill: rgba(0, 0, 0, 0.35);
}
.hex-stroke {
fill: none;
stroke: #3a4458;
stroke-width: 1.2;
}
.hex-floor {
fill: none;
stroke: #2a3044;
stroke-width: 0.8;
stroke-dasharray: 3 3;
}
.floor-label {
position: absolute;
left: 50%;
top: 91%;
transform: translate(-50%, -50%);
font-size: 0.65rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #5a6278;
font-family: system-ui, sans-serif;
pointer-events: none;
}
.ground {
position: absolute;
left: 5%;
right: 5%;
bottom: -2%;
height: 2px;
background: #3a4050;
}
.slot {
position: absolute;
transform: translate(-50%, -50%);
z-index: 1;
}
.slot portal-panel {
display: block;
transform-origin: center center;
box-shadow: 0 0 12px rgba(255, 60, 40, 0.15);
}
`;
export class PortalSchematic extends HTMLElement {
constructor() {
super();
/** @type {Map<number, import('./portal-panel.js').PortalPanel>} */
this._panels = new Map();
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${SCHEMATIC_STYLES}</style>
<div class="hex-stage">
<svg class="hex-svg" viewBox="0 0 100 108" preserveAspectRatio="xMidYMid meet">
<path class="hex-fill" d="${SCHEMATIC_HEX_PATH}" />
<path class="hex-stroke" d="${SCHEMATIC_HEX_PATH}" />
<line class="hex-floor"
x1="${SCHEMATIC_FLOOR_LINE.x1}" y1="${SCHEMATIC_FLOOR_LINE.y1}"
x2="${SCHEMATIC_FLOOR_LINE.x2}" y2="${SCHEMATIC_FLOOR_LINE.y2}" />
</svg>
<div class="floor-label">floor · walk through</div>
<div class="ground"></div>
<div class="slots"></div>
</div>
`;
this._slotsEl = root.querySelector(".slots");
}
_ensureSlot(position) {
let slot = this._slotsEl.querySelector(`[data-position="${position}"]`);
if (slot) return slot;
const layout = SCHEMATIC_PANEL_LAYOUT[position];
if (!layout) return null;
slot = document.createElement("div");
slot.className = "slot";
slot.dataset.position = position;
slot.style.left = `${layout.x}%`;
slot.style.top = `${layout.y}%`;
slot.style.maxWidth = layout.maxW;
this._slotsEl.appendChild(slot);
return slot;
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
this._panels.clear();
this._slotsEl.querySelectorAll(".slot").forEach((s) => {
s.innerHTML = "";
});
for (const panel of panels) {
const slot = this._ensureSlot(panel.position);
if (!slot) continue;
const layout = SCHEMATIC_PANEL_LAYOUT[panel.position];
const el = document.createElement("portal-panel");
el.setAttribute("index", String(panel.index));
el.setAttribute("position", panel.position);
el.setAttribute("width", String(panel.width));
el.setAttribute("height", String(panel.height));
el.style.transform = `rotate(${layout.rot}deg)`;
slot.appendChild(el);
this._panels.set(panel.index, el);
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const panel = this._panels.get(index);
if (!panel) return;
if (identify) panel.showIdentify();
else panel.setFrame(rgb, width, height);
}
set visible(on) {
if (on) this.setAttribute("visible", "");
else this.removeAttribute("visible");
}
}
customElements.define("portal-schematic", PortalSchematic);

View File

@@ -0,0 +1,244 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { paintIdentify, paintRgb } from "../panel-paint.js";
import { hexFrameEdges, panelPlacement, PORTAL } from "../portal-geometry.js";
const HOST_STYLES = `
:host {
display: block;
width: 100%;
height: 100%;
}
.mount {
width: 100%;
height: 100%;
}
`;
const FRAME_MAT = new THREE.MeshStandardMaterial({
color: 0x0a0a0a,
roughness: 0.92,
metalness: 0.05,
});
const BASE_MAT = new THREE.MeshStandardMaterial({
color: 0x050505,
roughness: 0.95,
metalness: 0.02,
});
export class PortalViewport extends HTMLElement {
constructor() {
super();
/** @type {Map<number, object>} */
this._panels = new Map();
this._raf = 0;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `<style>${HOST_STYLES}</style><div class="mount"></div>`;
this._mount = root.querySelector(".mount");
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0x1a1a1e);
this._scene.fog = new THREE.Fog(0x1a1a1e, 8, 22);
this._camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50);
this._camera.position.set(0, 1.0, 3.8);
this._renderer = new THREE.WebGLRenderer({ antialias: true });
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this._renderer.toneMapping = THREE.ReinhardToneMapping;
this._renderer.toneMappingExposure = 1.15;
this._mount.appendChild(this._renderer.domElement);
this._composer = new EffectComposer(this._renderer);
this._composer.addPass(new RenderPass(this._scene, this._camera));
this._bloom = new UnrealBloomPass(
new THREE.Vector2(1, 1),
0.85,
0.35,
0.15,
);
this._composer.addPass(this._bloom);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.enableDamping = true;
this._controls.target.set(0, 0.95, 0);
this._controls.minDistance = 2.2;
this._controls.maxDistance = 8;
this._scene.add(new THREE.AmbientLight(0x332222, 0.25));
const key = new THREE.DirectionalLight(0xffeedd, 0.35);
key.position.set(2, 5, 4);
this._scene.add(key);
const rim = new THREE.DirectionalLight(0x445566, 0.2);
rim.position.set(-3, 2, -2);
this._scene.add(rim);
this._buildEnvironment();
this._buildFrame();
this._buildBase();
this._onResize = () => this._resize();
window.addEventListener("resize", this._onResize);
this._resize();
this._tick();
}
disconnectedCallback() {
window.removeEventListener("resize", this._onResize);
cancelAnimationFrame(this._raf);
this._renderer?.dispose();
this._composer?.dispose();
}
_buildEnvironment() {
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(14, 14),
new THREE.MeshStandardMaterial({ color: 0x2a2a30, roughness: 0.9, metalness: 0 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
this._scene.add(floor);
const back = new THREE.Mesh(
new THREE.PlaneGeometry(12, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.95 }),
);
back.position.set(0, 2.5, -4);
this._scene.add(back);
}
_buildFrame() {
const { height, frameDepth } = PORTAL;
const group = new THREE.Group();
for (const edge of hexFrameEdges()) {
if (edge.isFloor) continue;
const ax = edge.outerA[0];
const az = edge.outerA[1];
const bx = edge.outerB[0];
const bz = edge.outerB[1];
const dx = bx - ax;
const dz = bz - az;
const len = Math.hypot(dx, dz);
const beam = new THREE.Mesh(
new THREE.BoxGeometry(len, height, frameDepth),
FRAME_MAT,
);
beam.position.set((ax + bx) / 2, height / 2, (az + bz) / 2);
beam.rotation.y = Math.atan2(dx, dz);
group.add(beam);
const lip = new THREE.Mesh(
new THREE.BoxGeometry(len * 0.98, frameDepth * 0.6, frameDepth * 0.35),
FRAME_MAT,
);
lip.position.set((ax + bx) / 2, height - frameDepth * 0.25, (az + bz) / 2);
lip.rotation.y = Math.atan2(dx, dz);
group.add(lip);
}
this._scene.add(group);
}
_buildBase() {
const { apothem, baseThickness, baseExtend } = PORTAL;
const w = apothem * 2 + baseExtend * 2;
const base = new THREE.Mesh(
new THREE.BoxGeometry(w, baseThickness, w * 0.85),
BASE_MAT,
);
base.position.set(0, baseThickness / 2, 0.05);
this._scene.add(base);
}
_resize() {
const w = this.clientWidth || 1;
const h = this.clientHeight || 1;
this._camera.aspect = w / h;
this._camera.updateProjectionMatrix();
this._renderer.setSize(w, h);
this._composer.setSize(w, h);
this._bloom.resolution.set(w, h);
}
_tick() {
this._raf = requestAnimationFrame(() => this._tick());
this._controls.update();
this._composer.render();
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
for (const entry of this._panels.values()) {
this._scene.remove(entry.mesh);
entry.texture.dispose();
entry.material.dispose();
entry.geometry.dispose();
}
this._panels.clear();
for (const panel of panels) {
const place = panelPlacement(panel.position, panel.width);
const geometry = new THREE.PlaneGeometry(place.width, place.height);
const canvas = document.createElement("canvas");
canvas.width = panel.width;
canvas.height = panel.height;
const ctx = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.FrontSide,
toneMapped: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(place.pos[0], place.pos[1], place.pos[2]);
mesh.rotation.set(place.rot[0], place.rot[1], place.rot[2]);
this._scene.add(mesh);
this._panels.set(panel.index, {
mesh,
geometry,
material,
canvas,
ctx,
texture,
panel,
});
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const entry = this._panels.get(index);
if (!entry) return;
if (identify) {
paintIdentify(entry.ctx, entry.canvas, index, entry.panel.width, entry.panel.height);
} else {
paintRgb(entry.ctx, entry.canvas, rgb, width, height);
}
entry.texture.needsUpdate = true;
}
}
customElements.define("portal-viewport", PortalViewport);

17
web/js/dev-reload.js Normal file
View File

@@ -0,0 +1,17 @@
/** Live reload when web/ files change (dev server only). */
function connect() {
const source = new EventSource("/api/dev/reload");
source.onmessage = (event) => {
if (event.data === "reload") {
console.log("[portal] reloading…");
location.reload();
}
};
source.onerror = () => {
source.close();
setTimeout(connect, 1500);
};
}
connect();

1
web/js/main.js Normal file
View File

@@ -0,0 +1 @@
import "./components/portal-app.js";

51
web/js/panel-paint.js Normal file
View File

@@ -0,0 +1,51 @@
export const PANEL_COLORS = [
0xff5050, 0x50ff50, 0x50a0ff, 0xffc850, 0xc850ff,
];
/**
* Paint a panel frame. `rgb` is logical [R,G,B,R,G,B,…] from the Python simulator
* (same byte order as UDP panel frames). Canvas ImageData uses RGBA.
*
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
export function paintRgb(ctx, canvas, rgb, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const image = ctx.createImageData(width, height);
const data = image.data;
for (let i = 0, p = 0; i < rgb.length; i += 3, p += 4) {
data[p] = rgb[i];
data[p + 1] = rgb[i + 1];
data[p + 2] = rgb[i + 2];
data[p + 3] = 255;
}
ctx.putImageData(image, 0, 0);
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number} panelIndex
* @param {number} width
* @param {number} height
*/
export function paintIdentify(ctx, canvas, panelIndex, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
const hex = PANEL_COLORS[panelIndex % PANEL_COLORS.length].toString(16).padStart(6, "0");
ctx.fillStyle = `#${hex}`;
ctx.font = `bold ${Math.floor(height * 0.75)}px monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(panelIndex), width / 2, height / 2);
}

168
web/js/portal-geometry.js Normal file
View File

@@ -0,0 +1,168 @@
/** Hexagonal portal geometry — matches physical flat-top hex arch. */
/** Scene scale (~2.5 m tall portal). */
export const PORTAL = {
/** Center to flat-edge (apothem) of inner opening. */
apothem: 1.05,
height: 1.85,
frameDepth: 0.14,
frameWall: 0.09,
baseThickness: 0.07,
baseExtend: 0.18,
ledInset: 0.02,
};
const CELL = 0.078;
/**
* Flat-top hex vertex ring in XZ (y = 0).
* @param {number} apothem
* @returns {[number, number][]}
*/
export function hexVerticesXZ(apothem) {
const R = (apothem * 2) / Math.sqrt(3);
const verts = [];
for (let i = 0; i < 6; i++) {
const angle = Math.PI / 6 + (i * Math.PI) / 3;
verts.push([R * Math.cos(angle), R * Math.sin(angle)]);
}
return verts;
}
/**
* @param {[number, number]} a
* @param {[number, number]} b
*/
function edgeMid(a, b) {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
/**
* Inward normal for flat-top hex edge (XZ plane).
* @param {number} edgeIndex 0..5 clockwise from lower-right sloped edge
*/
function edgeInwardNormal(verts, edgeIndex) {
const a = verts[edgeIndex];
const b = verts[(edgeIndex + 1) % 6];
const dx = b[0] - a[0];
const dz = b[1] - a[1];
const len = Math.hypot(dx, dz) || 1;
const nx = dz / len;
const nz = -dx / len;
const mx = (a[0] + b[0]) / 2;
const mz = (a[1] + b[1]) / 2;
if (mx * nx + mz * nz < 0) {
return [-nx, -nz];
}
return [nx, nz];
}
/**
* Wall panel placement on inner hex face.
* Edge mapping (clockwise from bottom-right sloped edge):
* 0 bottom-right → panel 4
* 1 bottom (floor, no LEDs)
* 2 bottom-left → panel 0
* 3 top-left → panel 1
* 4 top (panel 2 is horizontal ceiling, not this edge)
* 5 top-right → panel 3
* @param {string} position
* @param {number} pixelWidth
*/
export function panelPlacement(position, pixelWidth = 45) {
const { apothem, height, frameDepth, ledInset } = PORTAL;
const verts = hexVerticesXZ(apothem);
const h = 9 * CELL;
const w = pixelWidth * CELL;
const wallY = height * 0.42;
const topY = height - frameDepth * 0.5;
/** @type {Record<string, { edge: number, y: number, rotYExtra?: number }>} */
const wallMap = {
"bottom-right": { edge: 0, y: wallY * 0.72 },
"bottom-left": { edge: 2, y: wallY * 0.72 },
"top-left": { edge: 3, y: wallY * 1.38 },
"top-right": { edge: 5, y: wallY * 1.38 },
};
if (position === "top") {
return {
pos: [0, topY, 0],
rot: [-Math.PI / 2, 0, 0],
width: 45 * CELL,
height: h,
};
}
const spec = wallMap[position];
if (!spec) {
return { pos: [0, 0, 0], rot: [0, 0, 0], width: w, height: h };
}
const a = verts[spec.edge];
const b = verts[(spec.edge + 1) % 6];
const mid = edgeMid(a, b);
const [nx, nz] = edgeInwardNormal(verts, spec.edge);
const inset = frameDepth * 0.5 + ledInset;
const px = mid[0] + nx * inset;
const pz = mid[1] + nz * inset;
const rotY = Math.atan2(nx, nz);
return {
pos: [px, spec.y, pz],
rot: [0, rotY, 0],
width: w,
height: h,
};
}
/**
* Build dark frame beam meshes data for each hex edge.
* @returns {Array<{ from: [number,number], to: [number,number], isFloor: boolean }>}
*/
export function hexFrameEdges() {
const outer = hexVerticesXZ(PORTAL.apothem + PORTAL.frameWall);
const inner = hexVerticesXZ(PORTAL.apothem);
const edges = [];
for (let i = 0; i < 6; i++) {
edges.push({
outerA: outer[i],
outerB: outer[(i + 1) % 6],
innerA: inner[i],
innerB: inner[(i + 1) % 6],
isFloor: i === 1,
});
}
return edges;
}
export { CELL };
/** @type {Record<string, string>} */
export const SCHEMATIC_SLOTS = {
"top-left": "slot-tl",
top: "slot-top",
"top-right": "slot-tr",
"bottom-left": "slot-bl",
"bottom-right": "slot-br",
};
/** Front elevation of upright flat-top hex arch (viewBox 0 0 100 108). */
export const SCHEMATIC_HEX_PATH =
"M 20 4 L 80 4 L 96 48 L 80 96 L 20 96 L 4 48 Z";
/** Bottom opening — floor, no LEDs. */
export const SCHEMATIC_FLOOR_LINE = { x1: 20, y1: 96, x2: 80, y2: 96 };
/**
* Panel placement on standing front view (% of stage, rotation deg).
* @type {Record<string, { x: number, y: number, rot: number, maxW: string }>}
*/
export const SCHEMATIC_PANEL_LAYOUT = {
top: { x: 50, y: 7, rot: 0, maxW: "52%" },
"top-left": { x: 15, y: 30, rot: -58, maxW: "34%" },
"top-right": { x: 85, y: 30, rot: 58, maxW: "34%" },
"bottom-left": { x: 15, y: 76, rot: 58, maxW: "32%" },
"bottom-right": { x: 85, y: 76, rot: -58, maxW: "32%" },
};

9
web/js/portal-layout.js Normal file
View File

@@ -0,0 +1,9 @@
export {
panelPlacement,
SCHEMATIC_SLOTS,
SCHEMATIC_HEX_PATH,
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_PANEL_LAYOUT,
PORTAL,
CELL,
} from "./portal-geometry.js";

20
web/style.css Normal file
View File

@@ -0,0 +1,20 @@
:root {
color-scheme: dark;
--bg: #0a0c12;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
}
portal-app {
display: block;
height: 100%;
}

1523
web/vendor/controls/OrbitControls.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import {
Clock,
HalfFloatType,
NoBlending,
Vector2,
WebGLRenderTarget
} from 'three';
import { CopyShader } from '../shaders/CopyShader.js';
import { ShaderPass } from './ShaderPass.js';
import { MaskPass } from './MaskPass.js';
import { ClearMaskPass } from './MaskPass.js';
class EffectComposer {
constructor( renderer, renderTarget ) {
this.renderer = renderer;
this._pixelRatio = renderer.getPixelRatio();
if ( renderTarget === undefined ) {
const size = renderer.getSize( new Vector2() );
this._width = size.width;
this._height = size.height;
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
renderTarget.texture.name = 'EffectComposer.rt1';
} else {
this._width = renderTarget.width;
this._height = renderTarget.height;
}
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
this.renderToScreen = true;
this.passes = [];
this.copyPass = new ShaderPass( CopyShader );
this.copyPass.material.blending = NoBlending;
this.clock = new Clock();
}
swapBuffers() {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
addPass( pass ) {
this.passes.push( pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
insertPass( pass, index ) {
this.passes.splice( index, 0, pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
removePass( pass ) {
const index = this.passes.indexOf( pass );
if ( index !== - 1 ) {
this.passes.splice( index, 1 );
}
}
isLastEnabledPass( passIndex ) {
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
if ( this.passes[ i ].enabled ) {
return false;
}
}
return true;
}
render( deltaTime ) {
// deltaTime value is in seconds
if ( deltaTime === undefined ) {
deltaTime = this.clock.getDelta();
}
const currentRenderTarget = this.renderer.getRenderTarget();
let maskActive = false;
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
const pass = this.passes[ i ];
if ( pass.enabled === false ) continue;
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
if ( pass.needsSwap ) {
if ( maskActive ) {
const context = this.renderer.getContext();
const stencil = this.renderer.state.buffers.stencil;
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
}
this.swapBuffers();
}
if ( MaskPass !== undefined ) {
if ( pass instanceof MaskPass ) {
maskActive = true;
} else if ( pass instanceof ClearMaskPass ) {
maskActive = false;
}
}
}
this.renderer.setRenderTarget( currentRenderTarget );
}
reset( renderTarget ) {
if ( renderTarget === undefined ) {
const size = this.renderer.getSize( new Vector2() );
this._pixelRatio = this.renderer.getPixelRatio();
this._width = size.width;
this._height = size.height;
renderTarget = this.renderTarget1.clone();
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
}
setSize( width, height ) {
this._width = width;
this._height = height;
const effectiveWidth = this._width * this._pixelRatio;
const effectiveHeight = this._height * this._pixelRatio;
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
for ( let i = 0; i < this.passes.length; i ++ ) {
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
}
}
setPixelRatio( pixelRatio ) {
this._pixelRatio = pixelRatio;
this.setSize( this._width, this._height );
}
dispose() {
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.copyPass.dispose();
}
}
export { EffectComposer };

104
web/vendor/postprocessing/MaskPass.js vendored Normal file
View File

@@ -0,0 +1,104 @@
import { Pass } from './Pass.js';
class MaskPass extends Pass {
constructor( scene, camera ) {
super();
this.scene = scene;
this.camera = camera;
this.clear = true;
this.needsSwap = false;
this.inverse = false;
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const context = renderer.getContext();
const state = renderer.state;
// don't update color or depth
state.buffers.color.setMask( false );
state.buffers.depth.setMask( false );
// lock buffers
state.buffers.color.setLocked( true );
state.buffers.depth.setLocked( true );
// set up stencil
let writeValue, clearValue;
if ( this.inverse ) {
writeValue = 0;
clearValue = 1;
} else {
writeValue = 1;
clearValue = 0;
}
state.buffers.stencil.setTest( true );
state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
state.buffers.stencil.setClear( clearValue );
state.buffers.stencil.setLocked( true );
// draw into the stencil buffer
renderer.setRenderTarget( readBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
// unlock color and depth buffer and make them writable for subsequent rendering/clearing
state.buffers.color.setLocked( false );
state.buffers.depth.setLocked( false );
state.buffers.color.setMask( true );
state.buffers.depth.setMask( true );
// only render where stencil is set to 1
state.buffers.stencil.setLocked( false );
state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
state.buffers.stencil.setLocked( true );
}
}
class ClearMaskPass extends Pass {
constructor() {
super();
this.needsSwap = false;
}
render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
renderer.state.buffers.stencil.setLocked( false );
renderer.state.buffers.stencil.setTest( false );
}
}
export { MaskPass, ClearMaskPass };

95
web/vendor/postprocessing/Pass.js vendored Normal file
View File

@@ -0,0 +1,95 @@
import {
BufferGeometry,
Float32BufferAttribute,
OrthographicCamera,
Mesh
} from 'three';
class Pass {
constructor() {
this.isPass = true;
// if set to true, the pass is processed by the composer
this.enabled = true;
// if set to true, the pass indicates to swap read and write buffer after rendering
this.needsSwap = true;
// if set to true, the pass clears its buffer before rendering
this.clear = false;
// if set to true, the result of the pass is rendered to screen. This is set automatically by EffectComposer.
this.renderToScreen = false;
}
setSize( /* width, height */ ) {}
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
}
dispose() {}
}
// Helper for passes that need to fill the viewport with a single quad.
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
// https://github.com/mrdoob/three.js/pull/21358
class FullscreenTriangleGeometry extends BufferGeometry {
constructor() {
super();
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
}
}
const _geometry = new FullscreenTriangleGeometry();
class FullScreenQuad {
constructor( material ) {
this._mesh = new Mesh( _geometry, material );
}
dispose() {
this._mesh.geometry.dispose();
}
render( renderer ) {
renderer.render( this._mesh, _camera );
}
get material() {
return this._mesh.material;
}
set material( value ) {
this._mesh.material = value;
}
}
export { Pass, FullScreenQuad };

99
web/vendor/postprocessing/RenderPass.js vendored Normal file
View File

@@ -0,0 +1,99 @@
import {
Color
} from 'three';
import { Pass } from './Pass.js';
class RenderPass extends Pass {
constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
super();
this.scene = scene;
this.camera = camera;
this.overrideMaterial = overrideMaterial;
this.clearColor = clearColor;
this.clearAlpha = clearAlpha;
this.clear = true;
this.clearDepth = false;
this.needsSwap = false;
this._oldClearColor = new Color();
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
let oldClearAlpha, oldOverrideMaterial;
if ( this.overrideMaterial !== null ) {
oldOverrideMaterial = this.scene.overrideMaterial;
this.scene.overrideMaterial = this.overrideMaterial;
}
if ( this.clearColor !== null ) {
renderer.getClearColor( this._oldClearColor );
renderer.setClearColor( this.clearColor, renderer.getClearAlpha() );
}
if ( this.clearAlpha !== null ) {
oldClearAlpha = renderer.getClearAlpha();
renderer.setClearAlpha( this.clearAlpha );
}
if ( this.clearDepth == true ) {
renderer.clearDepth();
}
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
if ( this.clear === true ) {
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
}
renderer.render( this.scene, this.camera );
// restore
if ( this.clearColor !== null ) {
renderer.setClearColor( this._oldClearColor );
}
if ( this.clearAlpha !== null ) {
renderer.setClearAlpha( oldClearAlpha );
}
if ( this.overrideMaterial !== null ) {
this.scene.overrideMaterial = oldOverrideMaterial;
}
renderer.autoClear = oldAutoClear;
}
}
export { RenderPass };

77
web/vendor/postprocessing/ShaderPass.js vendored Normal file
View File

@@ -0,0 +1,77 @@
import {
ShaderMaterial,
UniformsUtils
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
class ShaderPass extends Pass {
constructor( shader, textureID ) {
super();
this.textureID = ( textureID !== undefined ) ? textureID : 'tDiffuse';
if ( shader instanceof ShaderMaterial ) {
this.uniforms = shader.uniforms;
this.material = shader;
} else if ( shader ) {
this.uniforms = UniformsUtils.clone( shader.uniforms );
this.material = new ShaderMaterial( {
name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
defines: Object.assign( {}, shader.defines ),
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
}
this.fsQuad = new FullScreenQuad( this.material );
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
if ( this.uniforms[ this.textureID ] ) {
this.uniforms[ this.textureID ].value = readBuffer.texture;
}
this.fsQuad.material = this.material;
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this.fsQuad.render( renderer );
}
}
dispose() {
this.material.dispose();
this.fsQuad.dispose();
}
}
export { ShaderPass };

View File

@@ -0,0 +1,415 @@
import {
AdditiveBlending,
Color,
HalfFloatType,
MeshBasicMaterial,
ShaderMaterial,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderTarget
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
import { CopyShader } from '../shaders/CopyShader.js';
import { LuminosityHighPassShader } from '../shaders/LuminosityHighPassShader.js';
/**
* UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a
* mip map chain of bloom textures and blurs them with different radii. Because
* of the weighted combination of mips, and because larger blurs are done on
* higher mips, this effect provides good quality and performance.
*
* Reference:
* - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/
*/
class UnrealBloomPass extends Pass {
constructor( resolution, strength, radius, threshold ) {
super();
this.strength = ( strength !== undefined ) ? strength : 1;
this.radius = radius;
this.threshold = threshold;
this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
// create color only once here, reuse it later inside the render function
this.clearColor = new Color( 0, 0, 0 );
// render targets
this.renderTargetsHorizontal = [];
this.renderTargetsVertical = [];
this.nMips = 5;
let resx = Math.round( this.resolution.x / 2 );
let resy = Math.round( this.resolution.y / 2 );
this.renderTargetBright = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright';
this.renderTargetBright.texture.generateMipmaps = false;
for ( let i = 0; i < this.nMips; i ++ ) {
const renderTargetHorizontal = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetHorizontal.texture.name = 'UnrealBloomPass.h' + i;
renderTargetHorizontal.texture.generateMipmaps = false;
this.renderTargetsHorizontal.push( renderTargetHorizontal );
const renderTargetVertical = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i;
renderTargetVertical.texture.generateMipmaps = false;
this.renderTargetsVertical.push( renderTargetVertical );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// luminosity high pass material
const highPassShader = LuminosityHighPassShader;
this.highPassUniforms = UniformsUtils.clone( highPassShader.uniforms );
this.highPassUniforms[ 'luminosityThreshold' ].value = threshold;
this.highPassUniforms[ 'smoothWidth' ].value = 0.01;
this.materialHighPassFilter = new ShaderMaterial( {
uniforms: this.highPassUniforms,
vertexShader: highPassShader.vertexShader,
fragmentShader: highPassShader.fragmentShader
} );
// gaussian blur materials
this.separableBlurMaterials = [];
const kernelSizeArray = [ 3, 5, 7, 9, 11 ];
resx = Math.round( this.resolution.x / 2 );
resy = Math.round( this.resolution.y / 2 );
for ( let i = 0; i < this.nMips; i ++ ) {
this.separableBlurMaterials.push( this.getSeperableBlurMaterial( kernelSizeArray[ i ] ) );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// composite material
this.compositeMaterial = this.getCompositeMaterial( this.nMips );
this.compositeMaterial.uniforms[ 'blurTexture1' ].value = this.renderTargetsVertical[ 0 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture2' ].value = this.renderTargetsVertical[ 1 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture3' ].value = this.renderTargetsVertical[ 2 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture4' ].value = this.renderTargetsVertical[ 3 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture5' ].value = this.renderTargetsVertical[ 4 ].texture;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = 0.1;
const bloomFactors = [ 1.0, 0.8, 0.6, 0.4, 0.2 ];
this.compositeMaterial.uniforms[ 'bloomFactors' ].value = bloomFactors;
this.bloomTintColors = [ new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ) ];
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
// blend material
const copyShader = CopyShader;
this.copyUniforms = UniformsUtils.clone( copyShader.uniforms );
this.blendMaterial = new ShaderMaterial( {
uniforms: this.copyUniforms,
vertexShader: copyShader.vertexShader,
fragmentShader: copyShader.fragmentShader,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true
} );
this.enabled = true;
this.needsSwap = false;
this._oldClearColor = new Color();
this.oldClearAlpha = 1;
this.basic = new MeshBasicMaterial();
this.fsQuad = new FullScreenQuad( null );
}
dispose() {
for ( let i = 0; i < this.renderTargetsHorizontal.length; i ++ ) {
this.renderTargetsHorizontal[ i ].dispose();
}
for ( let i = 0; i < this.renderTargetsVertical.length; i ++ ) {
this.renderTargetsVertical[ i ].dispose();
}
this.renderTargetBright.dispose();
//
for ( let i = 0; i < this.separableBlurMaterials.length; i ++ ) {
this.separableBlurMaterials[ i ].dispose();
}
this.compositeMaterial.dispose();
this.blendMaterial.dispose();
this.basic.dispose();
//
this.fsQuad.dispose();
}
setSize( width, height ) {
let resx = Math.round( width / 2 );
let resy = Math.round( height / 2 );
this.renderTargetBright.setSize( resx, resy );
for ( let i = 0; i < this.nMips; i ++ ) {
this.renderTargetsHorizontal[ i ].setSize( resx, resy );
this.renderTargetsVertical[ i ].setSize( resx, resy );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
}
render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
renderer.getClearColor( this._oldClearColor );
this.oldClearAlpha = renderer.getClearAlpha();
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setClearColor( this.clearColor, 0 );
if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
// Render input to screen
if ( this.renderToScreen ) {
this.fsQuad.material = this.basic;
this.basic.map = readBuffer.texture;
renderer.setRenderTarget( null );
renderer.clear();
this.fsQuad.render( renderer );
}
// 1. Extract Bright Areas
this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold;
this.fsQuad.material = this.materialHighPassFilter;
renderer.setRenderTarget( this.renderTargetBright );
renderer.clear();
this.fsQuad.render( renderer );
// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright;
for ( let i = 0; i < this.nMips; i ++ ) {
this.fsQuad.material = this.separableBlurMaterials[ i ];
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX;
renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetsVertical[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
inputRenderTarget = this.renderTargetsVertical[ i ];
}
// Composite All the mips
this.fsQuad.material = this.compositeMaterial;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = this.strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = this.radius;
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] );
renderer.clear();
this.fsQuad.render( renderer );
// Blend it additively over the input texture
this.fsQuad.material = this.blendMaterial;
this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetsHorizontal[ 0 ].texture;
if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( readBuffer );
this.fsQuad.render( renderer );
}
// Restore renderer settings
renderer.setClearColor( this._oldClearColor, this.oldClearAlpha );
renderer.autoClear = oldAutoClear;
}
getSeperableBlurMaterial( kernelRadius ) {
const coefficients = [];
for ( let i = 0; i < kernelRadius; i ++ ) {
coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( kernelRadius * kernelRadius ) ) / kernelRadius );
}
return new ShaderMaterial( {
defines: {
'KERNEL_RADIUS': kernelRadius
},
uniforms: {
'colorTexture': { value: null },
'invSize': { value: new Vector2( 0.5, 0.5 ) }, // inverse texture size
'direction': { value: new Vector2( 0.5, 0.5 ) },
'gaussianCoefficients': { value: coefficients } // precomputed Gaussian coefficients
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 invSize;
uniform vec2 direction;
uniform float gaussianCoefficients[KERNEL_RADIUS];
void main() {
float weightSum = gaussianCoefficients[0];
vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
float x = float(i);
float w = gaussianCoefficients[i];
vec2 uvOffset = direction * invSize * x;
vec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;
vec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;
diffuseSum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
}`
} );
}
getCompositeMaterial( nMips ) {
return new ShaderMaterial( {
defines: {
'NUM_MIPS': nMips
},
uniforms: {
'blurTexture1': { value: null },
'blurTexture2': { value: null },
'blurTexture3': { value: null },
'blurTexture4': { value: null },
'blurTexture5': { value: null },
'bloomStrength': { value: 1.0 },
'bloomFactors': { value: null },
'bloomTintColors': { value: null },
'bloomRadius': { value: 0.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform sampler2D blurTexture3;
uniform sampler2D blurTexture4;
uniform sampler2D blurTexture5;
uniform float bloomStrength;
uniform float bloomRadius;
uniform float bloomFactors[NUM_MIPS];
uniform vec3 bloomTintColors[NUM_MIPS];
float lerpBloomFactor(const in float factor) {
float mirrorFactor = 1.2 - factor;
return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}`
} );
}
}
UnrealBloomPass.BlurDirectionX = new Vector2( 1.0, 0.0 );
UnrealBloomPass.BlurDirectionY = new Vector2( 0.0, 1.0 );
export { UnrealBloomPass };

45
web/vendor/shaders/CopyShader.js vendored Normal file
View File

@@ -0,0 +1,45 @@
/**
* Full-screen textured quad shader
*/
const CopyShader = {
name: 'CopyShader',
uniforms: {
'tDiffuse': { value: null },
'opacity': { value: 1.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform float opacity;
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;
}`
};
export { CopyShader };

View File

@@ -0,0 +1,64 @@
import {
Color
} from 'three';
/**
* Luminosity
* http://en.wikipedia.org/wiki/Luminosity
*/
const LuminosityHighPassShader = {
name: 'LuminosityHighPassShader',
shaderID: 'luminosityHighPass',
uniforms: {
'tDiffuse': { value: null },
'luminosityThreshold': { value: 1.0 },
'smoothWidth': { value: 1.0 },
'defaultColor': { value: new Color( 0x000000 ) },
'defaultOpacity': { value: 0.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform vec3 defaultColor;
uniform float defaultOpacity;
uniform float luminosityThreshold;
uniform float smoothWidth;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
float v = luminance( texel.xyz );
vec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );
float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );
gl_FragColor = mix( outputColor, texel, alpha );
}`
};
export { LuminosityHighPassShader };

54571
web/vendor/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long