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:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -3,5 +3,12 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
firmware/panel/build/
|
.mypy_cache/
|
||||||
firmware/_deps/
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
firmware/pico/build/
|
||||||
|
*.uf2
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -3,7 +3,7 @@
|
|||||||
# make deploy Build and flash panel firmware over USB
|
# make deploy Build and flash panel firmware over USB
|
||||||
# make build Build only
|
# make build Build only
|
||||||
|
|
||||||
.PHONY: build deploy flash upload monitor clean help
|
.PHONY: build deploy flash upload reset monitor clean help
|
||||||
|
|
||||||
_PANEL_MAKE_FLAGS :=
|
_PANEL_MAKE_FLAGS :=
|
||||||
ifdef PANEL_ID
|
ifdef PANEL_ID
|
||||||
@@ -12,15 +12,15 @@ endif
|
|||||||
ifdef NO_DHCP
|
ifdef NO_DHCP
|
||||||
_PANEL_MAKE_FLAGS += NO_DHCP=$(NO_DHCP)
|
_PANEL_MAKE_FLAGS += NO_DHCP=$(NO_DHCP)
|
||||||
endif
|
endif
|
||||||
ifdef NUM_LEDS
|
|
||||||
_PANEL_MAKE_FLAGS += NUM_LEDS=$(NUM_LEDS)
|
|
||||||
endif
|
|
||||||
ifdef WS2812_PIN
|
ifdef WS2812_PIN
|
||||||
_PANEL_MAKE_FLAGS += WS2812_PIN=$(WS2812_PIN)
|
_PANEL_MAKE_FLAGS += WS2812_PIN=$(WS2812_PIN)
|
||||||
endif
|
endif
|
||||||
|
ifdef WS2812_PIN1
|
||||||
|
_PANEL_MAKE_FLAGS += WS2812_PIN1=$(WS2812_PIN1)
|
||||||
|
endif
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@$(MAKE) -C firmware/panel help
|
@$(MAKE) -C firmware/pico help
|
||||||
|
|
||||||
build deploy flash upload monitor clean:
|
build deploy flash upload reset monitor clean:
|
||||||
@$(MAKE) -C firmware/panel $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)
|
@$(MAKE) -C firmware/pico $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)
|
||||||
|
|||||||
4
Pipfile
4
Pipfile
@@ -5,6 +5,10 @@ name = "pypi"
|
|||||||
|
|
||||||
[packages]
|
[packages]
|
||||||
portal = {editable = true, path = "."}
|
portal = {editable = true, path = "."}
|
||||||
|
"uvicorn[standard]" = "*"
|
||||||
|
fastapi = "*"
|
||||||
|
uvicorn = {extras = ["standard"], version = "*"}
|
||||||
|
pillow = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
|
|||||||
700
Pipfile.lock
generated
700
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "2d6705b77e526b8f5f55e85b57b98d5e5249097e4d02415229aabbd0744e0fa1"
|
"sha256": "0295b09710d67de544fc79d41d9aa8cc8f53ba2fd3309736bd7866608ca9979d"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -16,10 +16,438 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": {
|
"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": {
|
"portal": {
|
||||||
"editable": true,
|
"editable": true,
|
||||||
"path": "."
|
"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": {
|
"rpi-ws281x": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
|
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
|
||||||
@@ -33,6 +461,276 @@
|
|||||||
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
|
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
|
||||||
],
|
],
|
||||||
"version": "==3.8"
|
"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": {}
|
"develop": {}
|
||||||
|
|||||||
103
README.md
103
README.md
@@ -1,38 +1,86 @@
|
|||||||
# portal
|
# 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
|
## 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
|
## Panel sizes
|
||||||
|
|
||||||
Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` in `leds/array_config.py`:
|
Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` in `leds/array_config.py`:
|
||||||
|
|
||||||
| Panel | IP | Size | LEDs | Flash |
|
| Panel | Face | IP | Size | LEDs | Firmware |
|
||||||
|-------|-----|------|------|-------|
|
|-------|------|-----|------|------|----------|
|
||||||
| 0 | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0 NUM_LEDS=351` |
|
| 0 | bottom-left | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0` |
|
||||||
| 1 | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1 NUM_LEDS=405` |
|
| 1 | top-left | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1` |
|
||||||
| 2 | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2 NUM_LEDS=405` |
|
| 2 | **top** | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2` |
|
||||||
| 3 | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3 NUM_LEDS=405` |
|
| 3 | top-right | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3` |
|
||||||
| 4 | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4 NUM_LEDS=351` |
|
| 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:
|
Panel 4 may be **38 or 39** wide — find the exact width:
|
||||||
|
|
||||||
```bash
|
```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
|
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.
|
Animations render at each panel's own width automatically.
|
||||||
|
|
||||||
## Examples
|
## 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)
|
### 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 |
|
| Command | What it does |
|
||||||
|---------|----------------|
|
|---------|----------------|
|
||||||
| `pipenv run python examples/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
|
| `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
|
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 |
|
| 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/matrix_demo.py` | Rainbow only |
|
||||||
| `pipenv run python examples/led_demo.py` | Dual-strip chase + rainbow |
|
| `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
|
```bash
|
||||||
make deploy PANEL_ID=0 NUM_LEDS=351
|
pipenv run python examples/panel_rgb_cycle.py # same colors over UDP
|
||||||
make monitor
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ SPI1_RX = board.GP12
|
|||||||
SPI1_CSn = board.GP9
|
SPI1_CSn = board.GP9
|
||||||
W5500_RSTn = board.GP13
|
W5500_RSTn = board.GP13
|
||||||
|
|
||||||
pixel_pin = board.GP27
|
pixel_pin = board.GP28
|
||||||
num_pixels = 2
|
num_pixels = 2
|
||||||
|
|
||||||
print("Wiznet5k SimpleServer Test (DHCP)")
|
print("Wiznet5k SimpleServer Test (DHCP)")
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from leds.panel_udp import (
|
|||||||
animation_playlist,
|
animation_playlist,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
|
parse_pins_arg,
|
||||||
run_panel_animation_loop,
|
run_panel_animation_loop,
|
||||||
run_panel_playlist,
|
run_panel_playlist,
|
||||||
)
|
)
|
||||||
@@ -102,6 +103,9 @@ def main() -> int:
|
|||||||
fps=args.fps,
|
fps=args.fps,
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
|
sync_send=args.sync_send,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
run_panel_playlist(
|
run_panel_playlist(
|
||||||
@@ -113,6 +117,9 @@ def main() -> int:
|
|||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
loop=not args.once,
|
loop=not args.once,
|
||||||
|
sync_send=args.sync_send,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nStopped.")
|
print("\nStopped.")
|
||||||
@@ -138,7 +145,7 @@ def main() -> int:
|
|||||||
if args.linear:
|
if args.linear:
|
||||||
with LedStrip(
|
with LedStrip(
|
||||||
count,
|
count,
|
||||||
backend="spi",
|
backend=strip_cfg.backend,
|
||||||
spi_bus=args.spi_bus,
|
spi_bus=args.spi_bus,
|
||||||
spi_device=args.spi_device,
|
spi_device=args.spi_device,
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from leds.panel_udp import (
|
|||||||
add_panel_network_args,
|
add_panel_network_args,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
|
parse_pins_arg,
|
||||||
run_panel_animation_loop,
|
run_panel_animation_loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,12 +37,16 @@ def main() -> int:
|
|||||||
|
|
||||||
if args.panel:
|
if args.panel:
|
||||||
targets = panel_targets_from_args(args)
|
targets = panel_targets_from_args(args)
|
||||||
|
print(f"Panel -> {format_panel_targets(targets, args.port)}")
|
||||||
try:
|
try:
|
||||||
run_panel_animation_loop(
|
run_panel_animation_loop(
|
||||||
targets,
|
targets,
|
||||||
args.port,
|
args.port,
|
||||||
"rainbow",
|
"rainbow",
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
|
panel_id=args.panel_id,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nStopped.")
|
print("\nStopped.")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from leds.panel_udp import (
|
|||||||
animation_playlist,
|
animation_playlist,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
|
parse_pins_arg,
|
||||||
run_panel_animation_loop,
|
run_panel_animation_loop,
|
||||||
run_panel_playlist,
|
run_panel_playlist,
|
||||||
)
|
)
|
||||||
@@ -47,6 +48,9 @@ def main() -> int:
|
|||||||
fps=args.fps,
|
fps=args.fps,
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
|
sync_send=args.sync_send,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
run_panel_playlist(
|
run_panel_playlist(
|
||||||
@@ -58,6 +62,9 @@ def main() -> int:
|
|||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
loop=not args.once,
|
loop=not args.once,
|
||||||
|
sync_send=args.sync_send,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nStopped.")
|
print("\nStopped.")
|
||||||
|
|||||||
@@ -8,19 +8,21 @@ import socket
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from leds.colors import BLUE, GREEN, OFF, RED, WHITE
|
||||||
from leds.panel_udp import (
|
from leds.panel_udp import (
|
||||||
add_panel_args,
|
add_panel_args,
|
||||||
|
apply_pin_arg,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
send_solid_to_targets,
|
send_solid_to_targets,
|
||||||
)
|
)
|
||||||
|
|
||||||
COLORS = (
|
COLORS = (
|
||||||
("RED", (255, 0, 0)),
|
("RED", RED),
|
||||||
("GREEN", (0, 255, 0)),
|
("GREEN", GREEN),
|
||||||
("BLUE", (0, 0, 255)),
|
("BLUE", BLUE),
|
||||||
("WHITE", (255, 255, 255)),
|
("WHITE", WHITE),
|
||||||
("OFF", (0, 0, 0)),
|
("OFF", OFF),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -31,7 +33,9 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
targets = panel_targets_from_args(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")
|
print(f"Color test -> {format_panel_targets(targets, args.port)}\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -46,6 +50,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
|
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
|
||||||
|
if sock is not None:
|
||||||
sock.close()
|
sock.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ import argparse
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from leds.colors import RGB_CYCLE
|
||||||
from leds.panel_udp import PanelClient, add_panel_args, format_panel_targets, panel_targets_from_args
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="RGB cycle on Pico panel(s)")
|
parser = argparse.ArgumentParser(description="RGB cycle on Pico panel(s)")
|
||||||
@@ -28,7 +23,7 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
with PanelClient.from_args(args) as client:
|
with PanelClient.from_args(args) as client:
|
||||||
while True:
|
while True:
|
||||||
for label, color in COLORS:
|
for label, color in RGB_CYCLE:
|
||||||
print(f" {label}")
|
print(f" {label}")
|
||||||
client.fill(color)
|
client.fill(color)
|
||||||
time.sleep(args.pause)
|
time.sleep(args.pause)
|
||||||
|
|||||||
9
examples/panel_spi_bridge.py
Normal file
9
examples/panel_spi_bridge.py
Normal 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())
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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):
|
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)
|
corners - red/green/blue/yellow corners (check orientation)
|
||||||
crosshair - center row + column
|
crosshair - center row + column
|
||||||
columns - sweep the same column index on every panel together
|
columns - sweep the same column index on every panel together
|
||||||
@@ -23,12 +23,15 @@ import time
|
|||||||
|
|
||||||
from leds.panel_udp import (
|
from leds.panel_udp import (
|
||||||
HeadlessMatrix,
|
HeadlessMatrix,
|
||||||
|
PanelMatrixBundle,
|
||||||
PanelTarget,
|
PanelTarget,
|
||||||
add_panel_args,
|
add_panel_args,
|
||||||
|
apply_pin_arg,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
send_frame,
|
push_panel_frames,
|
||||||
)
|
)
|
||||||
|
from leds.array_config import panel_label
|
||||||
from leds.text import draw_text_centered
|
from leds.text import draw_text_centered
|
||||||
|
|
||||||
PANEL_COLORS = (
|
PANEL_COLORS = (
|
||||||
@@ -47,37 +50,35 @@ CORNER_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _matrices(targets: list[PanelTarget], brightness: float) -> list[tuple[PanelTarget, HeadlessMatrix]]:
|
def _matrices(targets: list[PanelTarget], brightness: float) -> PanelMatrixBundle:
|
||||||
return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
|
return PanelMatrixBundle(targets, brightness)
|
||||||
|
|
||||||
|
|
||||||
def _push(
|
def _push(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
bundle: PanelMatrixBundle,
|
||||||
matrices: list[tuple[PanelTarget, HeadlessMatrix]],
|
|
||||||
port: int,
|
port: int,
|
||||||
panel_id: int | None,
|
panel_id: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send all panel frames in a tight burst for best sync."""
|
"""Send all panel frames in a tight burst for best sync."""
|
||||||
for target, matrix in matrices:
|
push_panel_frames(sock, bundle.matrices, port, panel_id)
|
||||||
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_all(
|
def _clear_all(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
panel_id: int | None,
|
panel_id: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
matrices = _matrices(targets, brightness)
|
with PanelMatrixBundle(targets, brightness) as bundle:
|
||||||
for _, matrix in matrices:
|
for _, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
|
|
||||||
|
|
||||||
def test_identify(
|
def test_identify(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -85,18 +86,18 @@ def test_identify(
|
|||||||
pause: float,
|
pause: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
print("Test: identify — each panel shows its index (0–4)")
|
print("Test: identify — each panel shows its index (0–4)")
|
||||||
matrices = _matrices(targets, brightness)
|
with _matrices(targets, brightness) as bundle:
|
||||||
for target, matrix in matrices:
|
for target, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
|
color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
|
||||||
draw_text_centered(matrix, str(target.index), color)
|
draw_text_centered(matrix, str(target.index), color)
|
||||||
print(f" panel {target.index} -> {target.host} ({target.width}×{target.height})")
|
print(f" {panel_label(target.index)} -> {target.label()}")
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
|
|
||||||
|
|
||||||
def test_corners(
|
def test_corners(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -104,8 +105,8 @@ def test_corners(
|
|||||||
pause: float,
|
pause: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
print("Test: corners — TL=red TR=green BL=blue BR=yellow (all panels)")
|
print("Test: corners — TL=red TR=green BL=blue BR=yellow (all panels)")
|
||||||
matrices = _matrices(targets, brightness)
|
with _matrices(targets, brightness) as bundle:
|
||||||
for target, matrix in matrices:
|
for target, matrix in bundle.matrices:
|
||||||
w, h = target.width, target.height
|
w, h = target.width, target.height
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
points = {
|
points = {
|
||||||
@@ -116,12 +117,12 @@ def test_corners(
|
|||||||
}
|
}
|
||||||
for name, (x, y) in points.items():
|
for name, (x, y) in points.items():
|
||||||
matrix[x, y] = CORNER_COLORS[name]
|
matrix[x, y] = CORNER_COLORS[name]
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
|
|
||||||
|
|
||||||
def test_crosshair(
|
def test_crosshair(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -129,8 +130,8 @@ def test_crosshair(
|
|||||||
pause: float,
|
pause: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
print("Test: crosshair — center row + column (white)")
|
print("Test: crosshair — center row + column (white)")
|
||||||
matrices = _matrices(targets, brightness)
|
with _matrices(targets, brightness) as bundle:
|
||||||
for target, matrix in matrices:
|
for target, matrix in bundle.matrices:
|
||||||
w, h = target.width, target.height
|
w, h = target.width, target.height
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
mid_y = h // 2
|
mid_y = h // 2
|
||||||
@@ -139,12 +140,12 @@ def test_crosshair(
|
|||||||
matrix[x, mid_y] = (255, 255, 255)
|
matrix[x, mid_y] = (255, 255, 255)
|
||||||
for y in range(h):
|
for y in range(h):
|
||||||
matrix[mid_x, y] = (200, 200, 200)
|
matrix[mid_x, y] = (200, 200, 200)
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
|
|
||||||
|
|
||||||
def test_columns(
|
def test_columns(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -153,22 +154,22 @@ def test_columns(
|
|||||||
) -> None:
|
) -> None:
|
||||||
max_width = max(t.width for t in targets)
|
max_width = max(t.width for t in targets)
|
||||||
print(f"Test: column sweep — x=0..{max_width - 1} on all panels together")
|
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):
|
for x in range(max_width):
|
||||||
matrices = _matrices(targets, brightness)
|
for target, matrix in bundle.matrices:
|
||||||
for target, matrix in matrices:
|
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
if x >= target.width:
|
if x >= target.width:
|
||||||
continue
|
continue
|
||||||
hue = int(255 * x / max(max_width - 1, 1))
|
hue = int(255 * x / max(max_width - 1, 1))
|
||||||
for y in range(target.height):
|
for y in range(target.height):
|
||||||
matrix[x, y] = (hue, 80, 255 - hue)
|
matrix[x, y] = (hue, 80, 255 - hue)
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
_clear_all(sock, targets, port, brightness, panel_id)
|
_clear_all(sock, targets, port, brightness, panel_id)
|
||||||
|
|
||||||
|
|
||||||
def test_rows(
|
def test_rows(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -177,19 +178,19 @@ def test_rows(
|
|||||||
) -> None:
|
) -> None:
|
||||||
height = targets[0].height if targets else 9
|
height = targets[0].height if targets else 9
|
||||||
print(f"Test: row sweep — y=0..{height - 1} on all panels together")
|
print(f"Test: row sweep — y=0..{height - 1} on all panels together")
|
||||||
|
with _matrices(targets, brightness) as bundle:
|
||||||
for y in range(height):
|
for y in range(height):
|
||||||
matrices = _matrices(targets, brightness)
|
for target, matrix in bundle.matrices:
|
||||||
for target, matrix in matrices:
|
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
for x in range(target.width):
|
for x in range(target.width):
|
||||||
matrix[x, y] = (255, 255, 255)
|
matrix[x, y] = (255, 255, 255)
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
_clear_all(sock, targets, port, brightness, panel_id)
|
_clear_all(sock, targets, port, brightness, panel_id)
|
||||||
|
|
||||||
|
|
||||||
def test_flash(
|
def test_flash(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -199,10 +200,10 @@ def test_flash(
|
|||||||
) -> None:
|
) -> None:
|
||||||
print(f"Test: sync flash — {count} white flashes on all panels")
|
print(f"Test: sync flash — {count} white flashes on all panels")
|
||||||
for n in range(count):
|
for n in range(count):
|
||||||
matrices = _matrices(targets, brightness)
|
with _matrices(targets, brightness) as bundle:
|
||||||
for _, matrix in matrices:
|
for _, matrix in bundle.matrices:
|
||||||
matrix.fill((255, 255, 255))
|
matrix.fill((255, 255, 255))
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
_clear_all(sock, targets, port, brightness, panel_id)
|
_clear_all(sock, targets, port, brightness, panel_id)
|
||||||
time.sleep(pause / 2)
|
time.sleep(pause / 2)
|
||||||
@@ -210,7 +211,7 @@ def test_flash(
|
|||||||
|
|
||||||
|
|
||||||
def test_pulse(
|
def test_pulse(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -220,20 +221,20 @@ def test_pulse(
|
|||||||
) -> None:
|
) -> None:
|
||||||
print(f"Test: sync pulse — {seconds:.0f}s solid red fade (all panels)")
|
print(f"Test: sync pulse — {seconds:.0f}s solid red fade (all panels)")
|
||||||
frames = max(int(seconds * fps), 1)
|
frames = max(int(seconds * fps), 1)
|
||||||
|
with _matrices(targets, brightness) as bundle:
|
||||||
for frame in range(frames):
|
for frame in range(frames):
|
||||||
t = frame / frames
|
t = frame / frames
|
||||||
level = 0.15 + 0.85 * (0.5 - 0.5 * math.cos(t * 6.28318))
|
level = 0.15 + 0.85 * (0.5 - 0.5 * math.cos(t * 6.28318))
|
||||||
color = (int(255 * level), 0, 0)
|
color = (int(255 * level), 0, 0)
|
||||||
matrices = _matrices(targets, min(brightness * level, 1.0))
|
for _, matrix in bundle.matrices:
|
||||||
for _, matrix in matrices:
|
|
||||||
matrix.fill(color)
|
matrix.fill(color)
|
||||||
_push(sock, targets, matrices, port, panel_id)
|
_push(sock, bundle, port, panel_id)
|
||||||
time.sleep(1.0 / fps)
|
time.sleep(1.0 / fps)
|
||||||
_clear_all(sock, targets, port, brightness, panel_id)
|
_clear_all(sock, targets, port, brightness, panel_id)
|
||||||
|
|
||||||
|
|
||||||
def test_chain_ends(
|
def test_chain_ends(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -241,8 +242,8 @@ def test_chain_ends(
|
|||||||
pause: float,
|
pause: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
print("Test: chain ends — LED 0=red, last LED=green (per panel)")
|
print("Test: chain ends — LED 0=red, last LED=green (per panel)")
|
||||||
matrices = _matrices(targets, brightness)
|
with _matrices(targets, brightness) as bundle:
|
||||||
for target, matrix in matrices:
|
for target, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
matrix[0, 0] = (255, 0, 0)
|
matrix[0, 0] = (255, 0, 0)
|
||||||
last = target.pixels - 1
|
last = target.pixels - 1
|
||||||
@@ -250,12 +251,12 @@ def test_chain_ends(
|
|||||||
y = last // target.width
|
y = last // target.width
|
||||||
matrix[x, y] = (0, 255, 0)
|
matrix[x, y] = (0, 255, 0)
|
||||||
print(f" panel {target.index}: index 0 and {last} ({x},{y})")
|
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)
|
time.sleep(pause)
|
||||||
|
|
||||||
|
|
||||||
def test_width(
|
def test_width(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: list[PanelTarget],
|
targets: list[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
@@ -264,25 +265,26 @@ def test_width(
|
|||||||
col_start: int,
|
col_start: int,
|
||||||
col_end: int,
|
col_end: int,
|
||||||
) -> None:
|
) -> 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_w = col_end + 1
|
||||||
probe_leds = probe_w * targets[0].height if targets else probe_w * 9
|
probe_leds = probe_w * udp_targets[0].height
|
||||||
print("Test: width — one full column at a time (right-edge probe)")
|
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(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(" The last column that lights a real LED → width = column + 1")
|
||||||
print(" (column 37 lit → width 38; column 38 lit → width 39)\n")
|
print(" (column 37 lit → width 38; column 38 lit → width 39)\n")
|
||||||
for x in range(col_start, col_end + 1):
|
for x in range(col_start, col_end + 1):
|
||||||
burst: list[tuple[PanelTarget, HeadlessMatrix]] = []
|
burst: list[tuple[PanelTarget, HeadlessMatrix]] = []
|
||||||
for target in targets:
|
for target in udp_targets:
|
||||||
matrix = HeadlessMatrix(probe_w, target.height, brightness=brightness)
|
matrix = HeadlessMatrix(probe_w, target.height, brightness=brightness)
|
||||||
matrix.clear()
|
matrix.clear()
|
||||||
for y in range(target.height):
|
for y in range(target.height):
|
||||||
matrix[x, y] = (255, 255, 255)
|
matrix[x, y] = (255, 255, 255)
|
||||||
probe_target = PanelTarget(target.index, target.host, probe_w, target.height)
|
probe_target = PanelTarget(target.index, target.host, probe_w, target.height)
|
||||||
burst.append((probe_target, matrix))
|
burst.append((probe_target, matrix))
|
||||||
for probe_target, matrix in burst:
|
push_panel_frames(sock, burst, port, panel_id)
|
||||||
send_frame(sock, probe_target.host, port, matrix.rgb_bytes(), panel_id)
|
|
||||||
print(f" column {x} → width {x + 1} if this is the last real column")
|
print(f" column {x} → width {x + 1} if this is the last real column")
|
||||||
time.sleep(pause)
|
time.sleep(pause)
|
||||||
_clear_all(sock, targets, port, brightness, panel_id)
|
_clear_all(sock, targets, port, brightness, panel_id)
|
||||||
@@ -323,7 +325,9 @@ def main() -> int:
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
targets = panel_targets_from_args(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(f"Panel sync test -> {format_panel_targets(targets, args.port)}")
|
||||||
print("Ctrl+C to stop\n")
|
print("Ctrl+C to stop\n")
|
||||||
|
|
||||||
@@ -353,6 +357,7 @@ def main() -> int:
|
|||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
|
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
|
||||||
|
if sock is not None:
|
||||||
sock.close()
|
sock.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ import sys
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from leds.panel_udp import (
|
from leds.panel_udp import (
|
||||||
HeadlessMatrix,
|
PanelMatrixBundle,
|
||||||
add_panel_args,
|
add_panel_args,
|
||||||
|
apply_pin_arg,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
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"
|
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("--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("--hold", type=float, default=0.0, help="0 = show until Ctrl+C")
|
||||||
parser.add_argument("--scroll", action="store_true")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.cursive:
|
||||||
|
text = args.text or "Allyssia"
|
||||||
|
else:
|
||||||
text = (args.text or _default_name()).upper()
|
text = (args.text or _default_name()).upper()
|
||||||
parts = [int(p.strip()) for p in args.color.split(",")]
|
parts = [int(p.strip()) for p in args.color.split(",")]
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
@@ -54,33 +93,99 @@ def main() -> int:
|
|||||||
color = (parts[0], parts[1], parts[2])
|
color = (parts[0], parts[1], parts[2])
|
||||||
|
|
||||||
targets = panel_targets_from_args(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)
|
||||||
print(f"Displaying '{text}' on {format_panel_targets(targets, args.port)}")
|
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:
|
try:
|
||||||
matrices = [
|
with PanelMatrixBundle(targets, args.brightness) as bundle:
|
||||||
(target, HeadlessMatrix(target.width, target.height, brightness=args.brightness))
|
if do_scroll:
|
||||||
for target in targets
|
|
||||||
]
|
|
||||||
if args.scroll:
|
|
||||||
start = time.monotonic()
|
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:
|
while True:
|
||||||
for target, matrix in matrices:
|
phase = (time.monotonic() - start) * 0.55
|
||||||
|
for target, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
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)
|
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
|
offsets[target.index] -= 1
|
||||||
if offsets[target.index] < -len(text) * 6:
|
if offsets[target.index] < -wrap:
|
||||||
offsets[target.index] = matrix.width
|
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:
|
if args.hold > 0 and time.monotonic() - start >= args.hold:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
for target, matrix in matrices:
|
for target, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
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)
|
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:
|
if args.hold <= 0:
|
||||||
print("Showing (Ctrl+C to clear)")
|
print("Showing (Ctrl+C to clear)")
|
||||||
while True:
|
while True:
|
||||||
@@ -90,9 +195,11 @@ def main() -> int:
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nClearing.")
|
print("\nClearing.")
|
||||||
finally:
|
finally:
|
||||||
for target, matrix in matrices:
|
with PanelMatrixBundle(targets, args.brightness) as bundle:
|
||||||
|
for _, matrix in bundle.matrices:
|
||||||
matrix.clear()
|
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()
|
sock.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from leds.panel_udp import (
|
|||||||
add_panel_args,
|
add_panel_args,
|
||||||
format_panel_targets,
|
format_panel_targets,
|
||||||
panel_targets_from_args,
|
panel_targets_from_args,
|
||||||
|
parse_pins_arg,
|
||||||
run_panel_animation_loop,
|
run_panel_animation_loop,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ def main() -> int:
|
|||||||
fps=args.fps,
|
fps=args.fps,
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
|
pin=args.pin,
|
||||||
|
pins=parse_pins_arg(args),
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nStopped.")
|
print("\nStopped.")
|
||||||
|
|||||||
67
examples/portal_simulator.py
Normal file
67
examples/portal_simulator.py
Normal 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())
|
||||||
69
examples/setup_spi_bufsiz.py
Normal file
69
examples/setup_spi_bufsiz.py
Normal 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
153
examples/spi_panel_test.py
Normal 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
114
examples/spi_rgb_test.py
Normal 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())
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
# Portal panel firmware (Pico SDK)
|
|
||||||
|
|
||||||
C firmware for a **Pico + W5500 + WS2812** panel adapter. Replaces the slow CircuitPython `adapter/code.py` path with native UDP receive and PIO WS2812 output.
|
|
||||||
|
|
||||||
## Hardware
|
|
||||||
|
|
||||||
Matches `adapter/code.py`:
|
|
||||||
|
|
||||||
| Signal | GPIO |
|
|
||||||
|--------|------|
|
|
||||||
| W5500 CS | GP9 |
|
|
||||||
| SPI1 SCK | GP10 |
|
|
||||||
| SPI1 MOSI | GP11 |
|
|
||||||
| SPI1 MISO | GP12 |
|
|
||||||
| W5500 RST | GP13 |
|
|
||||||
| Status LED | GP25 |
|
|
||||||
| WS2812 data | GP27 |
|
|
||||||
|
|
||||||
Default: **405 LEDs** (45×9 matrix chain), one data pin per panel.
|
|
||||||
|
|
||||||
WS2812 output uses [ForsakenNGS/Pico_WS2812](https://github.com/ForsakenNGS/Pico_WS2812) (FORMAT_GRB on GP27).
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
Requires [pico-sdk](https://github.com/raspberrypi/pico-sdk) and the ARM GCC toolchain (`arm-none-eabi-gcc`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From repo root
|
|
||||||
make deploy # build + flash over USB
|
|
||||||
make build # build only
|
|
||||||
|
|
||||||
# Or from this directory
|
|
||||||
make deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Manual cmake (one-time pico-sdk setup):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/raspberrypi/pico-sdk.git ~/pico/pico-sdk
|
|
||||||
cd ~/pico/pico-sdk && git submodule update --init
|
|
||||||
export PICO_SDK_PATH=~/pico/pico-sdk
|
|
||||||
|
|
||||||
mkdir -p build && cd build
|
|
||||||
cmake ..
|
|
||||||
make -j$(nproc)
|
|
||||||
```
|
|
||||||
|
|
||||||
UF2 output: `build/panel.uf2` — hold BOOTSEL and copy to the Pico.
|
|
||||||
|
|
||||||
### Options
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Panel 0 of 5 → 10.1.1.10, unique MAC, UDP panel_id filter
|
|
||||||
make deploy PANEL_ID=0 # 10.1.1.10
|
|
||||||
make deploy PANEL_ID=1 # 10.1.1.11
|
|
||||||
|
|
||||||
# DHCP instead of static 10.1.1.10–14
|
|
||||||
make deploy PANEL_ID=0 NO_DHCP=0
|
|
||||||
|
|
||||||
# Fewer LEDs for bench test
|
|
||||||
make deploy NUM_LEDS=2
|
|
||||||
```
|
|
||||||
|
|
||||||
Static IP and MAC are derived from `PANEL_ID`:
|
|
||||||
|
|
||||||
| Panel | IP | MAC |
|
|
||||||
|-------|-----|-----|
|
|
||||||
| 0 | 10.1.1.10 | 02:50:52:54:4C:00 |
|
|
||||||
| 1 | 10.1.1.11 | 02:50:52:54:4C:01 |
|
|
||||||
| … | … | … |
|
|
||||||
| 4 | 10.1.1.14 | 02:50:52:54:4C:04 |
|
|
||||||
| 255 (bench) | 10.1.1.19 | 02:50:52:54:4C:FF |
|
|
||||||
|
|
||||||
Edit `board_config.h` for gateway, subnet, and pin changes.
|
|
||||||
|
|
||||||
## UDP protocol (port 50007)
|
|
||||||
|
|
||||||
| Payload | Action |
|
|
||||||
|---------|--------|
|
|
||||||
| **1215 bytes** | Raw RGB (`405 × 3`), show immediately |
|
|
||||||
| **1216 bytes** | `panel_id` (byte 0) + RGB; `255` = accept on any panel |
|
|
||||||
| **4 bytes `SHOW`** | Push buffered pixels to the strip (sync helper) |
|
|
||||||
|
|
||||||
Pi sends **RGB** order; firmware converts to WS2812 **GRB**.
|
|
||||||
|
|
||||||
Target throughput: ~30–50 fps per panel (vs ~10–20 fps on CircuitPython).
|
|
||||||
|
|
||||||
## Pi test sender
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipenv run python examples/panel_udp_send.py --host 192.168.2.111 --animation rainbow
|
|
||||||
```
|
|
||||||
|
|
||||||
## Flashing
|
|
||||||
|
|
||||||
Requires [picotool](https://github.com/raspberrypi/picotool) with libusb support:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Or `./scripts/panel_deploy.sh` (same thing).
|
|
||||||
|
|
||||||
Or manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picotool load -x -f build/panel.uf2
|
|
||||||
```
|
|
||||||
|
|
||||||
`-f` forces a USB reboot into BOOTSEL when the panel is already running.
|
|
||||||
|
|
||||||
Legacy UF2 drag-and-drop also works: hold BOOTSEL, plug USB, copy `panel.uf2` to `RPI-RP2`.
|
|
||||||
|
|
||||||
Serial debug:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make monitor
|
|
||||||
# or: picocom /dev/ttyACM0
|
|
||||||
```
|
|
||||||
|
|
||||||
Ctrl+A then Ctrl+X to exit picocom.
|
|
||||||
|
|
||||||
## Multi-panel (5 Picos)
|
|
||||||
|
|
||||||
Flash each Pico with `PANEL_ID` 0–4 (`make deploy PANEL_ID=N`). Each gets `10.1.1.1N` (10–14), a unique MAC, and UDP filtering. Send frames from the Pi to each panel’s IP, or use a leading `panel_id` byte with `255` broadcast filtering.
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -47,14 +47,14 @@ if (DEFINED PANEL_ID_BUILD)
|
|||||||
target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD})
|
target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD})
|
||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
if (DEFINED NUM_LEDS_BUILD)
|
|
||||||
target_compile_definitions(panel PRIVATE NUM_LEDS=${NUM_LEDS_BUILD})
|
|
||||||
endif ()
|
|
||||||
|
|
||||||
if (DEFINED WS2812_PIN_BUILD)
|
if (DEFINED WS2812_PIN_BUILD)
|
||||||
target_compile_definitions(panel PRIVATE PIN_WS2812=${WS2812_PIN_BUILD})
|
target_compile_definitions(panel PRIVATE PIN_WS2812=${WS2812_PIN_BUILD})
|
||||||
endif ()
|
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)
|
option(PORTAL_USE_DHCP "Use DHCP for W5500" OFF)
|
||||||
if (PORTAL_USE_DHCP)
|
if (PORTAL_USE_DHCP)
|
||||||
target_compile_definitions(panel PRIVATE USE_DHCP=1)
|
target_compile_definitions(panel PRIVATE USE_DHCP=1)
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
# Options (passed to cmake on configure/reconfigure):
|
# Options (passed to cmake on configure/reconfigure):
|
||||||
# make deploy PANEL_ID=0
|
# make deploy PANEL_ID=0
|
||||||
# make deploy NO_DHCP=1
|
# 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:
|
# Environment:
|
||||||
# PICO_SDK_PATH default: ~/pico/pico-sdk
|
# PICO_SDK_PATH default: ~/pico/pico-sdk
|
||||||
@@ -28,24 +28,13 @@ SERIAL_BAUD ?= 115200
|
|||||||
CMAKE_ARGS :=
|
CMAKE_ARGS :=
|
||||||
ifdef PANEL_ID
|
ifdef PANEL_ID
|
||||||
CMAKE_ARGS += -DPANEL_ID_BUILD=$(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
|
endif
|
||||||
ifdef WS2812_PIN
|
ifdef WS2812_PIN
|
||||||
CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN)
|
CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN)
|
||||||
endif
|
endif
|
||||||
|
ifdef WS2812_PIN1
|
||||||
|
CMAKE_ARGS += -DWS2812_PIN1_BUILD=$(WS2812_PIN1)
|
||||||
|
endif
|
||||||
ifeq ($(NO_DHCP),0)
|
ifeq ($(NO_DHCP),0)
|
||||||
CMAKE_ARGS += -DPORTAL_USE_DHCP=ON
|
CMAKE_ARGS += -DPORTAL_USE_DHCP=ON
|
||||||
else
|
else
|
||||||
@@ -56,7 +45,7 @@ CMAKE_STAMP_BODY := $(strip $(CMAKE_ARGS))
|
|||||||
|
|
||||||
SOURCES := $(wildcard *.c *.cpp *.h) WS2812.pio CMakeLists.txt pico_sdk_import.cmake
|
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
|
all: build
|
||||||
|
|
||||||
@@ -65,9 +54,12 @@ help:
|
|||||||
@echo " make build Build panel.uf2"
|
@echo " make build Build panel.uf2"
|
||||||
@echo " make deploy Build and flash over USB"
|
@echo " make deploy Build and flash over USB"
|
||||||
@echo " make flash Alias for deploy"
|
@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 monitor USB serial console (picocom, Ctrl+A Ctrl+X to quit)"
|
||||||
@echo " make clean Remove build directory"
|
@echo " make clean Remove build directory"
|
||||||
@echo " make reconfigure Re-run cmake (e.g. after changing PANEL_ID)"
|
@echo " make reconfigure Re-run cmake (e.g. after changing PANEL_ID)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Typical: make reset && make monitor"
|
||||||
|
|
||||||
build: configure
|
build: configure
|
||||||
@echo "==> Building panel firmware"
|
@echo "==> Building panel firmware"
|
||||||
@@ -103,11 +95,32 @@ deploy: build
|
|||||||
flash: deploy
|
flash: deploy
|
||||||
upload: 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:
|
monitor:
|
||||||
@command -v picocom >/dev/null || (echo "Error: picocom not found (sudo apt install picocom)" && exit 1)
|
@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)
|
@SERIAL="$(SERIAL_PORT)"; \
|
||||||
@echo "==> Serial monitor on $(SERIAL_PORT) ($(SERIAL_BAUD) baud, Ctrl+A Ctrl+X to quit)"
|
if [ ! -e "$$SERIAL" ]; then \
|
||||||
picocom --baud $(SERIAL_BAUD) --flow n --echo $(SERIAL_PORT)
|
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:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
66
firmware/pico/README.md
Normal file
66
firmware/pico/README.md
Normal 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`).
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
#include "WS2812.pio.h"
|
#include "WS2812.pio.h"
|
||||||
|
|
||||||
|
#include "hardware/clocks.h"
|
||||||
|
#include "hardware/gpio.h"
|
||||||
|
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
|
||||||
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm) {
|
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[1] = b2;
|
||||||
this->bytes[2] = b3;
|
this->bytes[2] = b3;
|
||||||
this->bytes[3] = b4;
|
this->bytes[3] = b4;
|
||||||
uint offset = pio_add_program(pio, &ws2812_program);
|
this->bits = (b1 == NONE ? 24 : 32);
|
||||||
uint bits = (b1 == NONE ? 24 : 32);
|
/* One copy of the program per PIO block — shared by all strips on that PIO. */
|
||||||
ws2812_program_init(pio, sm, offset, pin, 800000, bits);
|
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) {
|
uint32_t WS2812::convertData(uint32_t rgbw) {
|
||||||
@@ -86,7 +125,14 @@ void WS2812::fill(uint32_t color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void WS2812::show() {
|
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]);
|
pio_sm_put_blocking(pio, sm, data[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,18 +21,24 @@ class WS2812 {
|
|||||||
void setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue);
|
void setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue);
|
||||||
void fill(uint32_t color);
|
void fill(uint32_t color);
|
||||||
void show();
|
void show();
|
||||||
|
void show(uint count);
|
||||||
|
void setPin(uint pin);
|
||||||
|
uint getPin() const { return pin; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
uint pin;
|
uint pin;
|
||||||
uint length;
|
uint length;
|
||||||
PIO pio;
|
PIO pio;
|
||||||
uint sm;
|
uint sm;
|
||||||
|
uint offset;
|
||||||
|
uint bits;
|
||||||
DataByte bytes[4];
|
DataByte bytes[4];
|
||||||
uint32_t *data;
|
uint32_t *data;
|
||||||
|
|
||||||
void initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
|
void initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
|
||||||
DataByte b3, DataByte b4);
|
DataByte b3, DataByte b4);
|
||||||
uint32_t convertData(uint32_t rgbw);
|
uint32_t convertData(uint32_t rgbw);
|
||||||
|
void applyPinConfig();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#ifndef BOARD_CONFIG_H
|
#ifndef BOARD_CONFIG_H
|
||||||
#define 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
|
#define PIN_LED_STATUS 25
|
||||||
|
|
||||||
@@ -14,15 +14,25 @@
|
|||||||
#define PIN_MISO 12
|
#define PIN_MISO 12
|
||||||
#define PIN_RST 13
|
#define PIN_RST 13
|
||||||
|
|
||||||
|
/* Strip 0 / strip 1 data pins (override with -DPIN_WS2812=… / -DPIN_WS2812_1=…). */
|
||||||
#ifndef PIN_WS2812
|
#ifndef PIN_WS2812
|
||||||
#define PIN_WS2812 27
|
#define PIN_WS2812 28
|
||||||
|
#endif
|
||||||
|
#ifndef PIN_WS2812_1
|
||||||
|
#define PIN_WS2812_1 27
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef NUM_LEDS
|
/* Strip / buffer capacity. Pixel count comes from each UDP frame length. */
|
||||||
#define NUM_LEDS 405
|
#ifndef MAX_LEDS
|
||||||
|
#define MAX_LEDS 512
|
||||||
#endif
|
#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 */
|
/* W5500 socket assignments */
|
||||||
#define SOCKET_DHCP 0
|
#define SOCKET_DHCP 0
|
||||||
362
firmware/pico/main.c
Normal file
362
firmware/pico/main.c
Normal 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 strip’s 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
firmware/pico/ws2812_led.cpp
Normal file
176
firmware/pico/ws2812_led.cpp
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
firmware/pico/ws2812_led.h
Normal file
33
firmware/pico/ws2812_led.h
Normal 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
|
||||||
@@ -3,11 +3,45 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
MATRIX_WIDTH = 45
|
MATRIX_WIDTH = 45
|
||||||
MATRIX_HEIGHT = 9
|
MATRIX_HEIGHT = 9
|
||||||
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
|
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
|
||||||
|
|
||||||
|
# --- Hexagonal portal -------------------------------------------------------
|
||||||
|
# Bottom face = floor (no LEDs). Panels 0–4 numbered clockwise from bottom-left.
|
||||||
|
# Panel 2 is the top face. All five panels are Pico UDP adapters.
|
||||||
|
#
|
||||||
|
# Viewed from outside:
|
||||||
|
#
|
||||||
|
# [2] TOP
|
||||||
|
# [1] [3]
|
||||||
|
# top-left top-right
|
||||||
|
#
|
||||||
|
# [0] [4]
|
||||||
|
# bottom-left bottom-right
|
||||||
|
#
|
||||||
|
# Floor (no LEDs) is below.
|
||||||
|
TOP_PANEL_INDEX = 2
|
||||||
|
PORTAL_PANEL_ORDER_CLOCKWISE: tuple[int, ...] = (0, 1, 2, 3, 4)
|
||||||
|
SIDE_PANEL_ORDER: tuple[int, ...] = (0, 1, 3, 4)
|
||||||
|
PANEL_POSITION_BY_INDEX: dict[int, str] = {
|
||||||
|
0: "bottom-left",
|
||||||
|
1: "top-left",
|
||||||
|
2: "top",
|
||||||
|
3: "top-right",
|
||||||
|
4: "bottom-right",
|
||||||
|
}
|
||||||
|
PortalFace = Literal["top", "side", "floor"]
|
||||||
|
PANEL_FACE_BY_INDEX: dict[int, PortalFace] = {
|
||||||
|
2: "top",
|
||||||
|
0: "side",
|
||||||
|
1: "side",
|
||||||
|
3: "side",
|
||||||
|
4: "side",
|
||||||
|
}
|
||||||
|
|
||||||
# Per-panel width (height is always 9 rows). Edit to match your hardware.
|
# 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 4 may still be 38 or 39 — run: panel_sync_test.py --test width --panel-index 4
|
||||||
PANEL_HEIGHT = 9
|
PANEL_HEIGHT = 9
|
||||||
@@ -21,6 +55,24 @@ PANEL_WIDTH_BY_INDEX: dict[int, int] = {
|
|||||||
DEFAULT_PANEL_WIDTH = 45
|
DEFAULT_PANEL_WIDTH = 45
|
||||||
PANEL_COUNT = 5
|
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)
|
# SPI data line: bus 0 = GPIO 10 (MOSI), bus 1 = GPIO 20 (SPI1 MOSI)
|
||||||
MATRIX_SPI_BUS = 0
|
MATRIX_SPI_BUS = 0
|
||||||
MATRIX_SPI_DEVICE = 0
|
MATRIX_SPI_DEVICE = 0
|
||||||
@@ -34,8 +86,9 @@ MATRIX_FLIP_Y = False
|
|||||||
|
|
||||||
MATRIX_BRIGHTNESS = 0.25
|
MATRIX_BRIGHTNESS = 0.25
|
||||||
|
|
||||||
# LED wire order for direct SPI on Pi (not used for Pico UDP — firmware packs GRB).
|
# Optional wire reorder for direct SPI tests only (set PORTAL_WIRE_ORDER env).
|
||||||
WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "grb").lower()
|
# 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:
|
def panel_width(panel_index: int) -> int:
|
||||||
@@ -51,6 +104,22 @@ def panel_pixel_count(panel_index: int) -> int:
|
|||||||
return w * h
|
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(
|
def matrix_pixel_index(
|
||||||
x: int,
|
x: int,
|
||||||
y: int,
|
y: int,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class PioBackend:
|
|||||||
frame = bytearray(self._led_count * 4)
|
frame = bytearray(self._led_count * 4)
|
||||||
for i in range(self._led_count):
|
for i in range(self._led_count):
|
||||||
base = i * 3
|
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
|
offset = i * 4
|
||||||
frame[offset] = r
|
frame[offset] = r
|
||||||
frame[offset + 1] = g
|
frame[offset + 1] = g
|
||||||
|
|||||||
54
leds/backends/rpi5_ws2812.py
Normal file
54
leds/backends/rpi5_ws2812.py
Normal 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()
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from array import array
|
from array import array
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from spidev import SpiDev
|
from spidev import SpiDev
|
||||||
@@ -16,6 +17,48 @@ else:
|
|||||||
_LED_ZERO = 0b1100_0000
|
_LED_ZERO = 0b1100_0000
|
||||||
_LED_ONE = 0b1111_1100
|
_LED_ONE = 0b1111_1100
|
||||||
_PREAMBLE = 42
|
_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:
|
class SpiBackend:
|
||||||
@@ -39,12 +82,16 @@ class SpiBackend:
|
|||||||
self._device.mode = 0b00
|
self._device.mode = 0b00
|
||||||
self._device.lsbfirst = False
|
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._frame = array("B", [0] * frame_len)
|
||||||
self._clear_frame = array("B", [0] * frame_len)
|
self._clear_frame = array("B", [0] * frame_len)
|
||||||
for i in range(_PREAMBLE, frame_len):
|
for i in range(_PREAMBLE, frame_len):
|
||||||
self._clear_frame[i] = _LED_ZERO
|
self._clear_frame[i] = _LED_ZERO
|
||||||
|
|
||||||
|
hint = spidev_bufsize_hint(led_count)
|
||||||
|
if hint:
|
||||||
|
raise OSError(hint)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def led_count(self) -> int:
|
def led_count(self) -> int:
|
||||||
return self._led_count
|
return self._led_count
|
||||||
@@ -62,11 +109,10 @@ class SpiBackend:
|
|||||||
out[pos] = _LED_ONE if value & bit else _LED_ZERO
|
out[pos] = _LED_ONE if value & bit else _LED_ZERO
|
||||||
pos += 1
|
pos += 1
|
||||||
|
|
||||||
# writebytes2 handles large frames (400+ LEDs); writebytes is capped at 4 KiB.
|
_write_spi_frame(self._device, out)
|
||||||
self._device.writebytes2(out)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self._device.writebytes2(self._clear_frame)
|
_write_spi_frame(self._device, self._clear_frame)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ from typing import Tuple
|
|||||||
|
|
||||||
RGB = Tuple[int, int, int]
|
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:
|
def wheel(pos: int) -> RGB:
|
||||||
pos = pos % 256
|
pos = pos % 256
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ except ImportError:
|
|||||||
MATRIX_SPI_BUS = 0
|
MATRIX_SPI_BUS = 0
|
||||||
MATRIX_SPI_DEVICE = 0
|
MATRIX_SPI_DEVICE = 0
|
||||||
|
|
||||||
BackendName = Literal["auto", "pio", "spi", "ws281x"]
|
BackendName = Literal["auto", "pio", "spi", "rpi5_ws2812", "ws281x"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -25,7 +25,41 @@ class StripConfig:
|
|||||||
device: str = "/dev/leds0"
|
device: str = "/dev/leds0"
|
||||||
spi_bus: int = 0
|
spi_bus: int = 0
|
||||||
spi_device: int = 0
|
spi_device: int = 0
|
||||||
|
spi_max_speed_hz: int | None = None
|
||||||
|
wire_order: str | None = None
|
||||||
brightness: float = 1.0
|
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(
|
def spi_strip_config(
|
||||||
@@ -33,14 +67,25 @@ def spi_strip_config(
|
|||||||
*,
|
*,
|
||||||
spi_bus: int = MATRIX_SPI_BUS,
|
spi_bus: int = MATRIX_SPI_BUS,
|
||||||
spi_device: int = MATRIX_SPI_DEVICE,
|
spi_device: int = MATRIX_SPI_DEVICE,
|
||||||
|
spi_max_speed_hz: int | None = None,
|
||||||
|
wire_order: str | None = None,
|
||||||
brightness: float = 1.0,
|
brightness: float = 1.0,
|
||||||
|
backend: BackendName | None = None,
|
||||||
|
passthrough: bool = False,
|
||||||
|
swap_rg: bool = True,
|
||||||
) -> StripConfig:
|
) -> StripConfig:
|
||||||
|
if backend is None:
|
||||||
|
backend = "rpi5_ws2812" if board_family() == "pi5" else "spi"
|
||||||
return StripConfig(
|
return StripConfig(
|
||||||
count,
|
count,
|
||||||
backend="spi",
|
backend=backend,
|
||||||
spi_bus=spi_bus,
|
spi_bus=spi_bus,
|
||||||
spi_device=spi_device,
|
spi_device=spi_device,
|
||||||
|
spi_max_speed_hz=spi_max_speed_hz,
|
||||||
|
wire_order=wire_order,
|
||||||
brightness=brightness,
|
brightness=brightness,
|
||||||
|
passthrough=passthrough,
|
||||||
|
swap_rg=swap_rg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ class LedMatrix:
|
|||||||
brightness=config0.brightness,
|
brightness=config0.brightness,
|
||||||
spi_bus=config0.spi_bus,
|
spi_bus=config0.spi_bus,
|
||||||
spi_device=config0.spi_device,
|
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:
|
else:
|
||||||
total_rows = sum(rows_per_strip)
|
total_rows = sum(rows_per_strip)
|
||||||
@@ -148,6 +152,12 @@ class LedMatrix:
|
|||||||
assert self._strips is not None
|
assert self._strips is not None
|
||||||
self._strips.show()
|
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:
|
def clear(self) -> None:
|
||||||
if self._strip is not None:
|
if self._strip is not None:
|
||||||
self._strip.clear()
|
self._strip.clear()
|
||||||
|
|||||||
214
leds/panel_bridge.py
Normal file
214
leds/panel_bridge.py
Normal 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())
|
||||||
@@ -1,31 +1,39 @@
|
|||||||
"""UDP helpers for Pico panel firmware."""
|
"""UDP helpers for Pico panel firmware and Pi-direct panels."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
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.animations import ANIMATIONS, DEFAULT_FPS
|
||||||
from leds.array_config import (
|
from leds.array_config import (
|
||||||
|
LOCAL_PANEL_INDICES,
|
||||||
MATRIX_HEIGHT,
|
MATRIX_HEIGHT,
|
||||||
MATRIX_PIXELS,
|
MATRIX_PIXELS,
|
||||||
MATRIX_WIDTH,
|
MATRIX_WIDTH,
|
||||||
PANEL_COUNT,
|
PANEL_COUNT,
|
||||||
|
PANEL_HOST_BY_INDEX,
|
||||||
|
PANEL_STRIP_BY_INDEX,
|
||||||
|
PANEL_WS2812_PIN_BY_INDEX,
|
||||||
|
PI_BRIDGE_PANEL_INDICES,
|
||||||
matrix_pixel_index,
|
matrix_pixel_index,
|
||||||
panel_layout,
|
panel_layout,
|
||||||
panel_pixel_count,
|
panel_pixel_count,
|
||||||
)
|
)
|
||||||
from leds.colors import dim
|
from leds.colors import dim
|
||||||
|
|
||||||
|
RGB = Tuple[int, int, int]
|
||||||
|
|
||||||
PANEL_IP_NET = "10.1.1"
|
PANEL_IP_NET = "10.1.1"
|
||||||
PANEL_IP_BASE = 10
|
PANEL_IP_BASE = 10
|
||||||
PANEL_MAX_FPS = 25.0
|
PANEL_MAX_FPS = 15.0
|
||||||
|
PANEL_SEND_TIMEOUT_S = 0.05
|
||||||
RGB = Tuple[int, int, int]
|
|
||||||
|
|
||||||
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
|
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
|
||||||
|
|
||||||
@@ -44,15 +52,37 @@ class PanelTarget:
|
|||||||
host: str
|
host: str
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
|
local: bool = False
|
||||||
|
strip: int = 0
|
||||||
|
pin: int | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pixels(self) -> int:
|
def pixels(self) -> int:
|
||||||
return self.width * self.height
|
return self.width * self.height
|
||||||
|
|
||||||
def label(self) -> str:
|
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:
|
if self.index >= 0:
|
||||||
return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
|
return f"{self.host} ({self.width}×{self.height}, panel {self.index}{strip}{pin})"
|
||||||
return f"{self.host} ({self.width}×{self.height})"
|
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]:
|
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
|
||||||
@@ -73,22 +103,81 @@ def resolve_panel_targets(
|
|||||||
w = width
|
w = width
|
||||||
if height is not None:
|
if height is not None:
|
||||||
h = height
|
h = height
|
||||||
return [PanelTarget(panel_index, panel_ip(panel_index), w, h)]
|
return [_make_panel_target(panel_index, w, h)]
|
||||||
if host:
|
if host:
|
||||||
w = width if width is not None else MATRIX_WIDTH
|
w = width if width is not None else MATRIX_WIDTH
|
||||||
h = height if height is not None else MATRIX_HEIGHT
|
h = height if height is not None else MATRIX_HEIGHT
|
||||||
return [PanelTarget(-1, host, w, h)]
|
return [PanelTarget(-1, host, w, h)]
|
||||||
return [
|
return [
|
||||||
PanelTarget(i, panel_ip(i), *panel_layout(i))
|
_make_panel_target(i, *panel_layout(i))
|
||||||
for i in range(panels)
|
for i in range(panels)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def format_panel_targets(targets: Sequence[PanelTarget], port: int) -> str:
|
def format_panel_targets(targets: Sequence[PanelTarget], port: int) -> str:
|
||||||
if len(targets) == 1:
|
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)
|
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:
|
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("--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("--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:
|
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]:
|
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
|
||||||
return resolve_panel_targets(
|
targets = resolve_panel_targets(
|
||||||
args.host,
|
args.host,
|
||||||
args.panel_index,
|
args.panel_index,
|
||||||
args.panels,
|
args.panels,
|
||||||
width=getattr(args, "width", None),
|
width=getattr(args, "width", None),
|
||||||
height=getattr(args, "height", 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:
|
def panel_count_from_args(args: argparse.Namespace) -> int:
|
||||||
@@ -205,6 +322,79 @@ class HeadlessMatrix:
|
|||||||
return bytes(out)
|
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(
|
def solid_frame_bytes(
|
||||||
color: RGB,
|
color: RGB,
|
||||||
count: int = MATRIX_PIXELS,
|
count: int = MATRIX_PIXELS,
|
||||||
@@ -225,33 +415,264 @@ def pixels_to_frame_bytes(
|
|||||||
return bytes(out)
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_frame(rgb: bytes, panel_id: int | None = None, *, strip: int = 0) -> bytes:
|
||||||
|
"""Prefix strip index (or explicit panel_id) so multi-strip Picos route correctly."""
|
||||||
|
prefix = panel_id if panel_id is not None else strip
|
||||||
|
if prefix is None:
|
||||||
|
return rgb
|
||||||
|
# Always prefix when strip!=0; strip 0 may omit for tiny payloads but
|
||||||
|
# prefixing is harmless and keeps multi-strip hosts consistent.
|
||||||
|
if panel_id is None and strip == 0:
|
||||||
|
return rgb
|
||||||
|
return bytes([prefix & 0xFF]) + rgb
|
||||||
|
|
||||||
|
|
||||||
|
def encode_pin_command(pin: int, strip: int = 0, length: int = 0) -> bytes:
|
||||||
|
"""UDP PIN command.
|
||||||
|
|
||||||
|
strip 0, no length → 5 bytes ``PIN\\0`` + gpio.
|
||||||
|
Otherwise → 8 bytes ``PIN\\0`` + strip + gpio + len_lo + len_hi (little-endian).
|
||||||
|
"""
|
||||||
|
if not 0 <= pin <= 29:
|
||||||
|
raise ValueError(f"WS2812 pin must be 0–29, got {pin}")
|
||||||
|
if not 0 <= strip <= 255:
|
||||||
|
raise ValueError(f"strip must be 0–255, got {strip}")
|
||||||
|
if length < 0 or length > 0xFFFF:
|
||||||
|
raise ValueError(f"length must be 0–65535, got {length}")
|
||||||
|
if strip == 0 and length == 0:
|
||||||
|
return b"PIN\0" + bytes([pin & 0xFF])
|
||||||
|
return b"PIN\0" + bytes(
|
||||||
|
[strip & 0xFF, pin & 0xFF, length & 0xFF, (length >> 8) & 0xFF]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_ws2812_pin(
|
||||||
|
sock: socket.socket,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
pin: int,
|
||||||
|
strip: int = 0,
|
||||||
|
length: int = 0,
|
||||||
|
) -> None:
|
||||||
|
sock.sendto(encode_pin_command(pin, strip, length), (host, port))
|
||||||
|
|
||||||
|
|
||||||
|
def send_ws2812_pin_to_targets(
|
||||||
|
sock: socket.socket | None,
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
port: int,
|
||||||
|
pin: int,
|
||||||
|
strip: int = 0,
|
||||||
|
length: int = 0,
|
||||||
|
) -> None:
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
for target in targets:
|
||||||
|
if target.local:
|
||||||
|
continue
|
||||||
|
send_ws2812_pin(sock, target.host, port, pin, strip, length)
|
||||||
|
extra = f" leds={length}" if length else ""
|
||||||
|
print(f"WS2812 strip {strip} GP{pin}{extra} -> {target.host}:{port}")
|
||||||
|
|
||||||
|
|
||||||
|
def configure_target_pins(
|
||||||
|
sock: socket.socket | None,
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
port: int,
|
||||||
|
) -> None:
|
||||||
|
"""Send PIN for each target that has pin set (length = panel pixel count)."""
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
seen: set[tuple[str, int, int]] = set()
|
||||||
|
for target in targets:
|
||||||
|
if target.local or target.pin is None:
|
||||||
|
continue
|
||||||
|
key = (target.host, target.strip, target.pin)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
length = target.pixels
|
||||||
|
send_ws2812_pin(sock, target.host, port, target.pin, target.strip, length)
|
||||||
|
print(
|
||||||
|
f"WS2812 strip {target.strip} GP{target.pin} leds={length} "
|
||||||
|
f"-> {target.host}:{port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_panel_pins(
|
||||||
|
sock: socket.socket | None,
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
pin: int | None = None,
|
||||||
|
pins: Sequence[tuple[int, int | None]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Apply array_config pins, then optional CLI overrides.
|
||||||
|
|
||||||
|
``pins`` entries are ``(gpio, length|None)`` — length None means unset.
|
||||||
|
"""
|
||||||
|
configure_target_pins(sock, targets, port)
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
if pins:
|
||||||
|
hosts = sorted({t.host for t in targets if not t.local})
|
||||||
|
for host in hosts:
|
||||||
|
for strip, (gp, length) in enumerate(pins):
|
||||||
|
leds = length or 0
|
||||||
|
send_ws2812_pin(sock, host, port, gp, strip, leds)
|
||||||
|
extra = f" leds={leds}" if leds else ""
|
||||||
|
print(f"WS2812 strip {strip} GP{gp}{extra} -> {host}:{port}")
|
||||||
|
return
|
||||||
|
if pin is not None:
|
||||||
|
send_ws2812_pin_to_targets(sock, targets, port, pin, strip=0)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pins_arg(args: argparse.Namespace) -> list[tuple[int, int | None]] | None:
|
||||||
|
"""Parse ``--pins 28,27`` or ``--pins 28:405,27:351`` → [(gpio, leds|None), ...]."""
|
||||||
|
raw = getattr(args, "pins", None)
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
out: list[tuple[int, int | None]] = []
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if ":" in part:
|
||||||
|
gp_s, led_s = part.split(":", 1)
|
||||||
|
out.append((int(gp_s.strip()), int(led_s.strip())))
|
||||||
|
else:
|
||||||
|
out.append((int(part), None))
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def expand_targets_for_pins(
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
pins: Sequence[tuple[int, int | None]],
|
||||||
|
) -> list[PanelTarget]:
|
||||||
|
"""One PanelTarget per strip so frames reach every GPIO from ``--pins``."""
|
||||||
|
out: list[PanelTarget] = []
|
||||||
|
for target in targets:
|
||||||
|
if target.local:
|
||||||
|
out.append(target)
|
||||||
|
continue
|
||||||
|
for strip, (gp, length) in enumerate(pins):
|
||||||
|
if length and length > 0 and target.height > 0 and length % target.height == 0:
|
||||||
|
width = length // target.height
|
||||||
|
height = target.height
|
||||||
|
elif length and length > 0:
|
||||||
|
width, height = length, 1
|
||||||
|
else:
|
||||||
|
width, height = target.width, target.height
|
||||||
|
out.append(
|
||||||
|
PanelTarget(
|
||||||
|
target.index,
|
||||||
|
target.host,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
local=False,
|
||||||
|
strip=strip,
|
||||||
|
pin=gp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_pin_arg(
|
||||||
|
args: argparse.Namespace,
|
||||||
|
sock: socket.socket | None,
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
) -> list[PanelTarget]:
|
||||||
|
"""Send PIN commands; return targets expanded for ``--pins`` multi-strip."""
|
||||||
|
pins = parse_pins_arg(args)
|
||||||
|
pin = getattr(args, "pin", None)
|
||||||
|
setup_panel_pins(
|
||||||
|
sock,
|
||||||
|
targets,
|
||||||
|
args.port,
|
||||||
|
pin=pin,
|
||||||
|
pins=pins,
|
||||||
|
)
|
||||||
|
if pins:
|
||||||
|
return expand_targets_for_pins(targets, pins)
|
||||||
|
if pin is not None:
|
||||||
|
return [
|
||||||
|
PanelTarget(
|
||||||
|
t.index,
|
||||||
|
t.host,
|
||||||
|
t.width,
|
||||||
|
t.height,
|
||||||
|
local=t.local,
|
||||||
|
strip=t.strip,
|
||||||
|
pin=pin if not t.local else t.pin,
|
||||||
|
)
|
||||||
|
for t in targets
|
||||||
|
]
|
||||||
|
return list(targets)
|
||||||
|
|
||||||
|
|
||||||
def send_frame(
|
def send_frame(
|
||||||
sock: socket.socket,
|
sock: socket.socket,
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
rgb: bytes,
|
rgb: bytes,
|
||||||
panel_id: int | None,
|
panel_id: int | None,
|
||||||
|
*,
|
||||||
|
strip: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
|
# Firmware expects logical RGB; converts to GRB on the wire.
|
||||||
sock.sendto(payload, (host, port))
|
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(
|
def send_solid_to_targets(
|
||||||
sock: socket.socket,
|
sock: socket.socket | None,
|
||||||
targets: Sequence[PanelTarget],
|
targets: Sequence[PanelTarget],
|
||||||
port: int,
|
port: int,
|
||||||
color: RGB,
|
color: RGB,
|
||||||
brightness: float,
|
brightness: float,
|
||||||
panel_id: int | None,
|
panel_id: int | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
for target in targets:
|
with PanelMatrixBundle(targets, brightness) as bundle:
|
||||||
send_frame(
|
for _, matrix in bundle.matrices:
|
||||||
sock,
|
matrix.fill(color)
|
||||||
target.host,
|
push_panel_frames(sock, bundle.matrices, port, panel_id)
|
||||||
port,
|
|
||||||
solid_frame_bytes(color, target.pixels, brightness),
|
|
||||||
panel_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PanelClient:
|
class PanelClient:
|
||||||
@@ -278,17 +699,27 @@ class PanelClient:
|
|||||||
self.port = port
|
self.port = port
|
||||||
self.panel_id = panel_id
|
self.panel_id = panel_id
|
||||||
self.brightness = brightness
|
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
|
@classmethod
|
||||||
def from_args(cls, args: argparse.Namespace) -> PanelClient:
|
def from_args(cls, args: argparse.Namespace) -> PanelClient:
|
||||||
return cls(
|
client = cls(
|
||||||
panel_targets_from_args(args),
|
panel_targets_from_args(args),
|
||||||
args.port,
|
args.port,
|
||||||
pixel_count=panel_count_from_args(args),
|
pixel_count=panel_count_from_args(args),
|
||||||
panel_id=args.panel_id,
|
panel_id=args.panel_id,
|
||||||
brightness=args.brightness,
|
brightness=args.brightness,
|
||||||
)
|
)
|
||||||
|
client.targets = apply_pin_arg(args, client._sock, client.targets)
|
||||||
|
return client
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pixel_count(self) -> int:
|
def pixel_count(self) -> int:
|
||||||
@@ -301,7 +732,11 @@ class PanelClient:
|
|||||||
return [t.host for t in self.targets]
|
return [t.host for t in self.targets]
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
|
if self._sock is not None:
|
||||||
self._sock.close()
|
self._sock.close()
|
||||||
|
if self._bundle is not None:
|
||||||
|
self._bundle.close()
|
||||||
|
self._bundle = None
|
||||||
|
|
||||||
def __enter__(self) -> PanelClient:
|
def __enter__(self) -> PanelClient:
|
||||||
return self
|
return self
|
||||||
@@ -314,11 +749,42 @@ class PanelClient:
|
|||||||
if len(self.targets) != 1:
|
if len(self.targets) != 1:
|
||||||
raise ValueError("send_bytes requires a single panel target")
|
raise ValueError("send_bytes requires a single panel target")
|
||||||
target = self.targets[0]
|
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:
|
if len(rgb) != target.pixels * 3:
|
||||||
raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
|
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:
|
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:
|
for target in self.targets:
|
||||||
send_frame(
|
send_frame(
|
||||||
self._sock,
|
self._sock,
|
||||||
@@ -326,6 +792,7 @@ class PanelClient:
|
|||||||
self.port,
|
self.port,
|
||||||
solid_frame_bytes(color, target.pixels, self.brightness),
|
solid_frame_bytes(color, target.pixels, self.brightness),
|
||||||
self.panel_id,
|
self.panel_id,
|
||||||
|
strip=target.strip,
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_pixels(self, pixels: list[RGB]) -> None:
|
def send_pixels(self, pixels: list[RGB]) -> None:
|
||||||
@@ -349,8 +816,59 @@ class PanelClient:
|
|||||||
def _panel_matrices(
|
def _panel_matrices(
|
||||||
targets: Sequence[PanelTarget],
|
targets: Sequence[PanelTarget],
|
||||||
brightness: float,
|
brightness: float,
|
||||||
) -> list[tuple[PanelTarget, HeadlessMatrix]]:
|
) -> PanelMatrixBundle:
|
||||||
return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
|
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(
|
def run_panel_playlist(
|
||||||
@@ -363,33 +881,69 @@ def run_panel_playlist(
|
|||||||
brightness: float = 0.25,
|
brightness: float = 0.25,
|
||||||
panel_id: int | None = None,
|
panel_id: int | None = None,
|
||||||
loop: bool = True,
|
loop: bool = True,
|
||||||
|
sync_send: bool = False,
|
||||||
|
pin: int | None = None,
|
||||||
|
pins: Sequence[tuple[int, int | None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
matrices = _panel_matrices(targets, brightness)
|
asyncio.run(
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
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:
|
try:
|
||||||
|
setup_panel_pins(
|
||||||
|
sock, targets, port, pin=pin, pins=pins
|
||||||
|
)
|
||||||
while True:
|
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()
|
t0 = time.monotonic()
|
||||||
for target, matrix in matrices:
|
for _, matrix in bundle.matrices:
|
||||||
draw(matrix, frame)
|
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
|
elapsed = time.monotonic() - t0
|
||||||
if elapsed < delay:
|
if elapsed < interval:
|
||||||
time.sleep(delay - elapsed)
|
await asyncio.sleep(interval - 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)
|
|
||||||
finally:
|
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()
|
sock.close()
|
||||||
|
bundle.close()
|
||||||
|
|
||||||
|
|
||||||
def run_panel_animation_loop(
|
def run_panel_animation_loop(
|
||||||
@@ -400,28 +954,23 @@ def run_panel_animation_loop(
|
|||||||
fps: float | None = None,
|
fps: float | None = None,
|
||||||
brightness: float = 0.25,
|
brightness: float = 0.25,
|
||||||
panel_id: int | None = None,
|
panel_id: int | None = None,
|
||||||
|
sync_send: bool = False,
|
||||||
|
pin: int | None = None,
|
||||||
|
pins: Sequence[tuple[int, int | None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
draw = ANIMATIONS[animation]
|
asyncio.run(
|
||||||
rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
|
run_panel_animation_loop_async(
|
||||||
interval = 1.0 / rate
|
targets,
|
||||||
matrices = _panel_matrices(targets, brightness)
|
port,
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
animation,
|
||||||
frame = 0
|
fps=fps,
|
||||||
try:
|
brightness=brightness,
|
||||||
while True:
|
panel_id=panel_id,
|
||||||
t0 = time.monotonic()
|
sync_send=sync_send,
|
||||||
for target, matrix in matrices:
|
pin=pin,
|
||||||
draw(matrix, frame)
|
pins=pins,
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def animation_playlist(animation: str) -> list[str]:
|
def animation_playlist(animation: str) -> list[str]:
|
||||||
|
|||||||
69
leds/portal_sim.py
Normal file
69
leds/portal_sim.py
Normal 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
145
leds/portal_web.py
Normal 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()
|
||||||
@@ -5,13 +5,15 @@ from __future__ import annotations
|
|||||||
from typing import Literal, Sequence, Tuple, Union
|
from typing import Literal, Sequence, Tuple, Union
|
||||||
|
|
||||||
from leds.backends.pio import PioBackend
|
from leds.backends.pio import PioBackend
|
||||||
|
from leds.backends.rpi5_ws2812 import Rpi5Ws2812Backend
|
||||||
from leds.backends.spi import SpiBackend
|
from leds.backends.spi import SpiBackend
|
||||||
from leds.backends.ws281x import Ws281xBackend
|
from leds.backends.ws281x import Ws281xBackend
|
||||||
from leds.config import pi5_setup_hint
|
from leds.config import pi5_setup_hint
|
||||||
from leds.detect import board_family, board_model, pio_available, supports_ws281x
|
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]]
|
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:
|
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():
|
if name == "pio" and board_family() == "pi5" and not pio_available():
|
||||||
raise RuntimeError(pi5_setup_hint(10, 10))
|
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
|
return name
|
||||||
|
|
||||||
if board_family() == "pi5":
|
if board_family() == "pi5":
|
||||||
return "pio" if pio_available() else "spi"
|
return "rpi5_ws2812"
|
||||||
return "ws281x"
|
return "ws281x"
|
||||||
|
|
||||||
|
|
||||||
@@ -53,7 +57,8 @@ class LedStrip:
|
|||||||
Drive a WS2812 / NeoPixel strip on Pi 5, 4, or Zero.
|
Drive a WS2812 / NeoPixel strip on Pi 5, 4, or Zero.
|
||||||
|
|
||||||
Default backends:
|
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
|
- Pi 4 / Zero: PWM on GPIO 18/13 via rpi_ws281x
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -68,20 +73,35 @@ class LedStrip:
|
|||||||
brightness: float = 1.0,
|
brightness: float = 1.0,
|
||||||
spi_bus: int = 0,
|
spi_bus: int = 0,
|
||||||
spi_device: 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:
|
) -> None:
|
||||||
if count < 1:
|
if count < 1:
|
||||||
raise ValueError("count must be at least 1")
|
raise ValueError("count must be at least 1")
|
||||||
|
|
||||||
self._count = count
|
self._count = count
|
||||||
self._brightness = max(0.0, min(1.0, brightness))
|
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
|
self._pixels: list[Tuple[int, int, int]] = [(0, 0, 0)] * count
|
||||||
|
|
||||||
resolved = _resolve_backend(backend)
|
resolved = _resolve_backend(backend)
|
||||||
if resolved == "pio":
|
if resolved == "pio":
|
||||||
self._backend = PioBackend(count, device=device)
|
self._backend = PioBackend(count, device=device)
|
||||||
elif resolved == "spi":
|
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(
|
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:
|
else:
|
||||||
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
|
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
|
||||||
@@ -121,14 +141,43 @@ class LedStrip:
|
|||||||
self[index] = color
|
self[index] = color
|
||||||
|
|
||||||
def show(self) -> None:
|
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):
|
for i, (r, g, b) in enumerate(self._pixels):
|
||||||
r, g, b = _apply_brightness((r, g, b), self._brightness)
|
r, g, b = _apply_brightness((r, g, b), self._brightness)
|
||||||
base = i * 3
|
base = i * 3
|
||||||
grb[base] = g
|
logical[base] = r
|
||||||
grb[base + 1] = r
|
logical[base + 1] = g
|
||||||
grb[base + 2] = b
|
logical[base + 2] = b
|
||||||
self._backend.write_grb(bytes(grb))
|
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:
|
def clear(self) -> None:
|
||||||
self.fill((0, 0, 0))
|
self.fill((0, 0, 0))
|
||||||
|
|||||||
@@ -72,4 +72,8 @@ def _strip_from_config(config: StripConfig) -> LedStrip:
|
|||||||
brightness=config.brightness,
|
brightness=config.brightness,
|
||||||
spi_bus=config.spi_bus,
|
spi_bus=config.spi_bus,
|
||||||
spi_device=config.spi_device,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
169
leds/text.py
169
leds/text.py
@@ -1,7 +1,8 @@
|
|||||||
"""5×7 bitmap text for LED matrices."""
|
"""5×7 bitmap text and optional TrueType (cursive) rendering for LED matrices."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
|
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
|
||||||
@@ -53,6 +54,15 @@ CHAR_WIDTH = 5
|
|||||||
CHAR_HEIGHT = 7
|
CHAR_HEIGHT = 7
|
||||||
CHAR_SPACING = 1
|
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:
|
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
|
||||||
text = text.upper()
|
text = text.upper()
|
||||||
@@ -122,3 +132,160 @@ def draw_text_centered(
|
|||||||
x = max(0, (w - tw) // 2)
|
x = max(0, (w - tw) // 2)
|
||||||
y = max(0, (h - th) // 2)
|
y = max(0, (h - th) // 2)
|
||||||
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)
|
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
47
leds/wire.py
Normal 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)
|
||||||
@@ -10,6 +10,13 @@ requires-python = ">=3.9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"spidev>=3.6",
|
"spidev>=3.6",
|
||||||
"rpi-ws281x>=5.0.0",
|
"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]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -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
22
web/index.html
Normal 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
56
web/js/api.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
web/js/components/portal-app.js
Normal file
154
web/js/components/portal-app.js
Normal 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);
|
||||||
185
web/js/components/portal-controls.js
Normal file
185
web/js/components/portal-controls.js
Normal 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);
|
||||||
90
web/js/components/portal-panel.js
Normal file
90
web/js/components/portal-panel.js
Normal 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);
|
||||||
169
web/js/components/portal-schematic.js
Normal file
169
web/js/components/portal-schematic.js
Normal 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);
|
||||||
244
web/js/components/portal-viewport.js
Normal file
244
web/js/components/portal-viewport.js
Normal 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
17
web/js/dev-reload.js
Normal 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
1
web/js/main.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "./components/portal-app.js";
|
||||||
51
web/js/panel-paint.js
Normal file
51
web/js/panel-paint.js
Normal 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
168
web/js/portal-geometry.js
Normal 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
9
web/js/portal-layout.js
Normal 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
20
web/style.css
Normal 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
1523
web/vendor/controls/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
231
web/vendor/postprocessing/EffectComposer.js
vendored
Normal file
231
web/vendor/postprocessing/EffectComposer.js
vendored
Normal 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
104
web/vendor/postprocessing/MaskPass.js
vendored
Normal 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
95
web/vendor/postprocessing/Pass.js
vendored
Normal 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
99
web/vendor/postprocessing/RenderPass.js
vendored
Normal 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
77
web/vendor/postprocessing/ShaderPass.js
vendored
Normal 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 };
|
||||||
415
web/vendor/postprocessing/UnrealBloomPass.js
vendored
Normal file
415
web/vendor/postprocessing/UnrealBloomPass.js
vendored
Normal 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
45
web/vendor/shaders/CopyShader.js
vendored
Normal 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 };
|
||||||
64
web/vendor/shaders/LuminosityHighPassShader.js
vendored
Normal file
64
web/vendor/shaders/LuminosityHighPassShader.js
vendored
Normal 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
54571
web/vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user