Ship the portal FastAPI controller with presets, sequences, and live audio.

Move the app under src/, add ring-spanning patterns with shared fonts and
beat-driven motion, and wire global audio settings into the web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 00:59:03 +12:00
co-authored by Cursor
parent 5094c7bcee
commit 3d444d1cb5
127 changed files with 10318 additions and 62632 deletions
+2 -1
View File
@@ -11,4 +11,5 @@ build/
.DS_Store
Thumbs.db
firmware/pico/build/
*.uf2
db/
!db/.gitkeep
+7 -2
View File
@@ -4,13 +4,18 @@ verify_ssl = true
name = "pypi"
[packages]
portal = {editable = true, path = "."}
"uvicorn[standard]" = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
pillow = "*"
numpy = "<2,>=1.26"
sounddevice = "*"
portal = {file = ".", editable = true}
[dev-packages]
[scripts]
dev = "python src/main.py"
[requires]
python_version = "3.13"
python_version = "3.12"
Generated
+479 -236
View File
@@ -1,11 +1,11 @@
{
"_meta": {
"hash": {
"sha256": "0295b09710d67de544fc79d41d9aa8cc8f53ba2fd3309736bd7866608ca9979d"
"sha256": "c84bb6478e61568f638a00ca4da7955c7cf922926184fc8343b9a93d4483c325"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
"python_version": "3.12"
},
"sources": [
{
@@ -18,44 +18,150 @@
"default": {
"annotated-doc": {
"hashes": [
"sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
"sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"
"sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101",
"sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"
],
"markers": "python_version >= '3.8'",
"version": "==0.0.4"
"markers": "python_version >= '3.9'",
"version": "==0.0.5"
},
"annotated-types": {
"hashes": [
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
"sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7",
"sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"
],
"markers": "python_version >= '3.8'",
"version": "==0.7.0"
"markers": "python_version >= '3.10'",
"version": "==0.8.0"
},
"anyio": {
"hashes": [
"sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72",
"sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"
"sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101",
"sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94"
],
"markers": "python_version >= '3.10'",
"version": "==4.14.1"
"version": "==4.15.1"
},
"cffi": {
"hashes": [
"sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e",
"sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66",
"sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2",
"sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0",
"sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6",
"sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971",
"sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c",
"sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d",
"sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9",
"sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517",
"sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735",
"sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80",
"sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f",
"sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1",
"sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29",
"sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8",
"sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c",
"sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e",
"sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48",
"sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813",
"sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac",
"sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632",
"sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6",
"sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1",
"sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659",
"sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688",
"sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004",
"sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0",
"sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062",
"sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779",
"sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94",
"sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50",
"sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab",
"sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac",
"sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6",
"sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676",
"sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1",
"sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9",
"sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf",
"sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13",
"sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e",
"sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e",
"sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973",
"sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527",
"sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72",
"sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890",
"sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c",
"sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990",
"sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd",
"sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9",
"sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94",
"sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3",
"sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80",
"sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41",
"sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5",
"sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c",
"sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a",
"sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4",
"sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e",
"sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6",
"sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98",
"sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b",
"sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1",
"sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03",
"sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af",
"sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231",
"sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2",
"sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3",
"sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836",
"sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5",
"sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399",
"sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96",
"sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e",
"sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be",
"sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf",
"sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc",
"sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455",
"sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0",
"sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12",
"sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b",
"sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7",
"sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692",
"sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54",
"sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3",
"sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b",
"sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be",
"sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d",
"sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358",
"sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a",
"sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7",
"sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc",
"sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960",
"sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125",
"sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb",
"sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a",
"sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa",
"sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf",
"sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3",
"sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4",
"sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"
],
"markers": "python_version >= '3.10'",
"version": "==2.1.1"
},
"click": {
"hashes": [
"sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6",
"sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76"
"sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360",
"sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34"
],
"markers": "python_version >= '3.10'",
"version": "==8.4.2"
"version": "==8.5.0"
},
"fastapi": {
"hashes": [
"sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8",
"sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615"
"sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3",
"sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.138.2"
"version": "==0.141.1"
},
"h11": {
"hashes": [
@@ -123,11 +229,54 @@
},
"idna": {
"hashes": [
"sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
"sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
"sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15",
"sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"
],
"markers": "python_version >= '3.9'",
"version": "==3.18"
"version": "==3.19"
},
"numpy": {
"hashes": [
"sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b",
"sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818",
"sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20",
"sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0",
"sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010",
"sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a",
"sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea",
"sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c",
"sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71",
"sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110",
"sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be",
"sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a",
"sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a",
"sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5",
"sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed",
"sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd",
"sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c",
"sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e",
"sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0",
"sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c",
"sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a",
"sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b",
"sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0",
"sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6",
"sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2",
"sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a",
"sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30",
"sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218",
"sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5",
"sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07",
"sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2",
"sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4",
"sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764",
"sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef",
"sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3",
"sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.26.4"
},
"pillow": {
"hashes": [
@@ -225,149 +374,157 @@
},
"portal": {
"editable": true,
"path": "."
"file": "."
},
"pycparser": {
"hashes": [
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
],
"markers": "implementation_name != 'PyPy'",
"version": "==3.0"
},
"pydantic": {
"hashes": [
"sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
"sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"
"sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73",
"sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08"
],
"markers": "python_version >= '3.9'",
"version": "==2.13.4"
"version": "==2.13.5"
},
"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"
"sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942",
"sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821",
"sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5",
"sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c",
"sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184",
"sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2",
"sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc",
"sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5",
"sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0",
"sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931",
"sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e",
"sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25",
"sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d",
"sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6",
"sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47",
"sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038",
"sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8",
"sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b",
"sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a",
"sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e",
"sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074",
"sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0",
"sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433",
"sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f",
"sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034",
"sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21",
"sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736",
"sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0",
"sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7",
"sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c",
"sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4",
"sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c",
"sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c",
"sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069",
"sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb",
"sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061",
"sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29",
"sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3",
"sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa",
"sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e",
"sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38",
"sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f",
"sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b",
"sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8",
"sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e",
"sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c",
"sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be",
"sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6",
"sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793",
"sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1",
"sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b",
"sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64",
"sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f",
"sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761",
"sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d",
"sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168",
"sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869",
"sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111",
"sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5",
"sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b",
"sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7",
"sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129",
"sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e",
"sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655",
"sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c",
"sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec",
"sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689",
"sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f",
"sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf",
"sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea",
"sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9",
"sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464",
"sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519",
"sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b",
"sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f",
"sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee",
"sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a",
"sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e",
"sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6",
"sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2",
"sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f",
"sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a",
"sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f",
"sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a",
"sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47",
"sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda",
"sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0",
"sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4",
"sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d",
"sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3",
"sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2",
"sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355",
"sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461",
"sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed",
"sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f",
"sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5",
"sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2",
"sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829",
"sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0",
"sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266",
"sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575",
"sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290",
"sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f",
"sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62",
"sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f",
"sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a",
"sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7",
"sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed",
"sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f",
"sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1",
"sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615",
"sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8",
"sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3",
"sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a",
"sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff",
"sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084",
"sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0",
"sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3",
"sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13",
"sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9"
],
"markers": "python_version >= '3.9'",
"version": "==2.46.4"
"version": "==2.46.5"
},
"python-dotenv": {
"hashes": [
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
"sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9",
"sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35"
],
"markers": "python_version >= '3.10'",
"version": "==1.2.2"
"version": "==1.2.3"
},
"pyyaml": {
"hashes": [
@@ -448,54 +605,53 @@
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
},
"rpi-ws281x": {
"sounddevice": {
"hashes": [
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
"sha256:7f4162f514f007b0bf25a3ccfed3f1705bc2ec311888a90232729eec4f57a4f4",
"sha256:8ec9fbfde2e32f020b167e348f3ab3bac6625a5f15af524d790108ac7147a410",
"sha256:b36b807eb02abd257198bf84b2af05e4fea199a9d2f0019014169c7136d45e9c",
"sha256:c8ae19173e5f27f8c12d4b5eee2dbfe542cee125d591e663e0fb4dfb75246d45",
"sha256:de099612311ad81e55d31ccbd83f43ea6bf4d87b48f9b6ea55a1fbcde0eee4e0",
"sha256:e3aef00ad8b1d1740eb66d9a7671eab88a4d2b8fa4ab33498d742e63b65c309c"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"spidev": {
"hashes": [
"sha256:07d0da112ca944df41f080811bb123b910df63bb38b66712289bc4fd9d682185",
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
],
"version": "==3.8"
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.5.6"
},
"starlette": {
"hashes": [
"sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0",
"sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"
"sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c",
"sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b"
],
"markers": "python_version >= '3.10'",
"version": "==1.3.1"
"version": "==1.6.0"
},
"typing-extensions": {
"hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
"sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8",
"sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"
],
"markers": "python_version >= '3.9'",
"version": "==4.15.0"
"version": "==4.16.0"
},
"typing-inspection": {
"hashes": [
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
"sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47",
"sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"
],
"markers": "python_version >= '3.9'",
"version": "==0.4.2"
"markers": "python_version >= '3.10'",
"version": "==0.4.4"
},
"uvicorn": {
"extras": [
"standard"
],
"hashes": [
"sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f",
"sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"
"sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86",
"sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1"
],
"markers": "python_version >= '3.10'",
"version": "==0.49.0"
"version": "==0.52.4"
},
"uvloop": {
"hashes": [
@@ -667,70 +823,157 @@
},
"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"
"sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d",
"sha256:00679b7468b4c2b12b0757118174e8eabac56bb2f579a928a104d9554a56e098",
"sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3",
"sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a",
"sha256:020e271205f8ab3406d7a59cd00de6dec722315924411c421bd00642f18bad86",
"sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f",
"sha256:054c28db2dcec0e857e3b705d8c28012613e555b38c765d6a4f75340a4fc06a0",
"sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d",
"sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f",
"sha256:0c84bdef916556cbe1d5a43b423398be4dd3cba6522b463e53d848578b920695",
"sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6",
"sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e",
"sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391",
"sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927",
"sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb",
"sha256:10ecb38ffc05e1841b619d99c725307a223ef9ad58e7b1ed33311d472dc43918",
"sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d",
"sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394",
"sha256:17aa424ab61620aad21b36b2240efc87b500cc496e7d0e999a5c2ae99395e886",
"sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2",
"sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13",
"sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c",
"sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c",
"sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e",
"sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3",
"sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906",
"sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e",
"sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5",
"sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608",
"sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68",
"sha256:2d72879819f5145a342d0030c418702496c65a4b913ef81f5ae944dd91dd50f6",
"sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256",
"sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335",
"sha256:34879e19bb0a3c44f9317679435aea5327fac993933a704cbf353bf1234b10c7",
"sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e",
"sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c",
"sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad",
"sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb",
"sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51",
"sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780",
"sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f",
"sha256:469355ab1af100b9380f1afb09985019f4a4b94fa1dd0e9396db4361626d7ab8",
"sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82",
"sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80",
"sha256:47a62d6045c6eaa0d8f97bc2fb68b8cf90077a0cbfd4e83d6f2d2145611ee134",
"sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc",
"sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d",
"sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b",
"sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642",
"sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98",
"sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441",
"sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11",
"sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c",
"sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da",
"sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641",
"sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a",
"sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec",
"sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090",
"sha256:65be6bda2b537fefa4b3a5ccd6ab386533ce39dd8fe62433ec90901fdc81752d",
"sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555",
"sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8",
"sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7",
"sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a",
"sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5",
"sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9",
"sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b",
"sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774",
"sha256:764cf7bfa149365f32b7a0fd9fed32debdac29dd06295d5635cde1745b446cd8",
"sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3",
"sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a",
"sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2",
"sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb",
"sha256:8196d217eeca52b9235ee1f8a684a09885a5f953d5a31e80ef915bf2c5c94f9d",
"sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc",
"sha256:84f25e099fdfe3b09f953d84698f729a1f7d1e99101b2787d7a28ed77b323750",
"sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab",
"sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24",
"sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74",
"sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f",
"sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c",
"sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2",
"sha256:8d1b108bd8f5f6a8b90801f6db3b3858d5deca889acfdb8ac497bbb24e4b0edf",
"sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51",
"sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db",
"sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6",
"sha256:98a5b2589a56a4b4f098b0a958099a4356ab904a7844f1da3841efca469af7e9",
"sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8",
"sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729",
"sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce",
"sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6",
"sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c",
"sha256:a62d8c424383c9dc769ff3672018df822603117e32686e567d452ed035b6fb2e",
"sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab",
"sha256:a9fe648abd1d9b89aebfa30407bfdd08a0271ec5dc7d44a4c6ccd1ce22cf562a",
"sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a",
"sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110",
"sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257",
"sha256:b1bc819c6db90e8f91a38250a1ab4c058261871aa52d2fe36382eddedf146dee",
"sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe",
"sha256:b3b451fd2723ad3191a209afe6f3f4bc86c83e9a85bdc255353b91803ee6aa66",
"sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566",
"sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8",
"sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a",
"sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51",
"sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78",
"sha256:c89406fa3dcd4aa8662c6406cc5c0de1790e9614d2c3aaf03ca53a8a8ccf3405",
"sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60",
"sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435",
"sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d",
"sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6",
"sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03",
"sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5",
"sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603",
"sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08",
"sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d",
"sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42",
"sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0",
"sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800",
"sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19",
"sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922",
"sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151",
"sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1",
"sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b",
"sha256:dcb8d5f7edef7a399d322cf28d4c4e6f98dab64d301c8f50581a1080e5198142",
"sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067",
"sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81",
"sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c",
"sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6",
"sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e",
"sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2",
"sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2",
"sha256:edadce7a22052056fd4384543019856b34850363c9d387929f677ae01d79709c",
"sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837",
"sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23",
"sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623",
"sha256:f47aafd92aa28b941180e6da8a42b0f711851b14b81a5b6bb28dbbb1fa35152c",
"sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4",
"sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc",
"sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd",
"sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a",
"sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4",
"sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a",
"sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3",
"sha256:f8e822efd54137d8cc8310eb64635ab827a4a6c72ff08691f38aa624776d8ecb",
"sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce",
"sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81",
"sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80",
"sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7"
],
"markers": "python_version >= '3.10'",
"version": "==16.0"
"version": "==17.1"
}
},
"develop": {}
+69 -43
View File
@@ -14,7 +14,13 @@ Reference: [photo of the physical portal](https://technical.kiwi/images/portal/I
floor
```
WS2812 control for Raspberry Pi 5 (orchestrator) and Pico panel adapters.
WS2812 control for Raspberry Pi 5 (orchestrator) and Pico panel firmware.
| Path | What |
|------|------|
| [`src/`](src/) | Python app, web UI, patterns |
| [`test/`](test/) | Unit tests and hardware panel scripts |
| [`firmware/`](firmware/) | Pico SDK panel firmware |
## Panel IPs
@@ -22,7 +28,7 @@ Five Pico panels on `10.1.1.10`–`10.1.1.14`. Examples drive **all 5** by defau
## 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 [`src/leds/array_config.py`](src/leds/array_config.py):
| Panel | Face | IP | Size | LEDs | Firmware |
|-------|------|-----|------|------|----------|
@@ -32,99 +38,119 @@ Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` i
| 3 | top-right | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3` |
| 4 | bottom-right | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4` |
Panels **0→1→2→3→4** run clockwise from bottom-left (`PORTAL_PANEL_ORDER_CLOCKWISE` in `leds/array_config.py`).
Panels **0→1→2→3→4** run clockwise from bottom-left (`PORTAL_PANEL_ORDER_CLOCKWISE` in `src/leds/array_config.py`).
Panel 4 may be **38 or 39** wide — find the exact width:
```bash
make deploy PANEL_ID=4
pipenv run python examples/panel_sync_test.py --test width --panel-index 4
pipenv run python test/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** (342 LEDs for 38, 351 for 39). Firmware takes pixel count from each UDP frame — no re-flash for width changes.
Last column that lights → set `PANEL_WIDTH_BY_INDEX[4]` in `src/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.
## Patterns
Each visual lives in its own file under [`src/patterns/`](src/patterns/). Drop a new `.py` file there and the web UI picks it up without restarting (hot-load). Every file is also a standalone CLI:
```bash
pipenv run python src/patterns/scanner.py
pipenv run python src/patterns/scanner.py --brightness 0.3 --speed 2 --beam ff0000
pipenv run python src/patterns/rainbow.py --panel-index 1
```
A pattern subclass declares optional named colours (shown as colour pickers in the UI) plus brightness and speed (time scale, independent of the 15 fps UDP cap):
```python
from leds.pattern import Color, Pattern, run_cli
class Scanner(Pattern):
id = "scanner"
label = "Scanner"
fps = 35
colors = {"beam": Color("#ff0000")}
def draw(self, surface, t, params):
...
if __name__ == "__main__":
run_cli(Scanner)
```
`t` is elapsed seconds × speed. Hardware brightness is applied when frames are sent, not inside `draw`.
## Examples
### Portal web simulator
### Portal web controller
Preview all five panels in the browser (3D interior view + flat layout map). Uses the same Python animations as the hardware.
Drive all five Pico panels from the browser. Starts black until you select a preset.
```bash
pipenv install fastapi "uvicorn[standard]"
pipenv run python examples/portal_simulator.py
pipenv run dev
# 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`.
Play by tapping **preset buttons**. Switch to **Edit mode** to add or edit presets — each preset is based on a pattern (colours, brightness, speed). **Try** previews without saving; **Save** persists to `db/presets.json`. Saving a file in `src/patterns/` updates the pattern list without a full page reload.
`pipenv run dev` enables **uvicorn reload** for `src/leds/` + `src/static/` and **browser auto-reload** when `src/static/` files change. Pattern files are hot-loaded in-process (not via uvicorn). Disable with `pipenv run python src/main.py --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.
Open via the server 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/`:
Frontend uses **Web Components** and ES modules under `src/static/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 |
| `<portal-app>` | Root layout, status, hot-load events |
| `<portal-controls>` | Preset buttons, Run/Edit mode |
| `<portal-preset-editor>` | Create/edit preset from a pattern |
### 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
pipenv run python test/panel_color_test.py --panel-index 0 --pins 28:405,27:351
```
| Command | What it does |
|---------|----------------|
| `pipenv run python examples/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
| `pipenv run python examples/panel_color_test.py` | One-shot RGB + white test |
| `pipenv run python examples/panel_test.py` | Layout tests (corners, rows, chase) |
| `pipenv run python examples/panel_sync_test.py` | Positioning + sync across all panels |
| `pipenv run python examples/panel_animations.py` | All animations on all panels |
| `pipenv run python examples/panel_animations.py rolling` | One animation |
| `pipenv run python examples/panel_text.py` | Show your name |
| `pipenv run python examples/animations.py --panel` | Same as panel_animations |
| `pipenv run python test/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
| `pipenv run python test/panel_color_test.py` | One-shot RGB + white test |
| `pipenv run python test/panel_test.py` | Layout tests (corners, rows, chase) |
| `pipenv run python test/panel_sync_test.py` | Positioning + sync across all panels |
| `pipenv run python test/panel_animations.py` | All patterns on all panels |
| `pipenv run python test/panel_animations.py rolling` | One pattern (playlist runner) |
| `pipenv run python src/patterns/rolling.py` | One pattern, standalone |
| `pipenv run python test/panel_text.py` | Show your name |
Single panel only:
```bash
pipenv run python examples/panel_animations.py --panel-index 1 # 9×45 @ 10.1.1.11
pipenv run python test/panel_animations.py --panel-index 1 # 9×45 @ 10.1.1.11
```
### Pi SPI (bench / single matrix)
### Pi 5 overlays (bench / local strips)
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:
Optional local PIO tests on GPIO — not used for the five portal panels:
```bash
pipenv run python examples/setup_spi_bufsiz.py --install # sudo
pipenv run python test/setup_pi5_leds.py --install
```
| Command | What it does |
|---------|----------------|
| `pipenv run python examples/spi_rgb_test.py` | Red / green / blue over SPI |
| `pipenv run python examples/spi_panel_test.py` | Corners, rows, chase — verify all LEDs |
| `pipenv run python examples/animations.py` | All animations on local matrix |
| `pipenv run python examples/matrix_demo.py` | Rainbow only |
| `pipenv run python examples/led_demo.py` | Dual-strip chase + rainbow |
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.
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 preview stays logical RGB.
```python
# MicroPython on Pico — this is the reference:
np.fill((255, 0, 0)); np.write() # red
# Logical RGB — firmware converts to GRB on the wire:
# (255, 0, 0) is red
```
```bash
pipenv run python examples/panel_rgb_cycle.py # same colors over UDP
pipenv run python test/panel_rgb_cycle.py # same colors over UDP
```
### Firmware
-117
View File
@@ -1,117 +0,0 @@
import board
import busio
import digitalio
import time
from adafruit_wiznet5k.adafruit_wiznet5k import *
import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket
import neopixel
SPI1_SCK = board.GP10
SPI1_TX = board.GP11
SPI1_RX = board.GP12
SPI1_CSn = board.GP9
W5500_RSTn = board.GP13
pixel_pin = board.GP28
num_pixels = 2
print("Wiznet5k SimpleServer Test (DHCP)")
# Setup your network configuration below
# random MAC, later should change this value on your vendor ID
MY_MAC = (0x00, 0x01, 0x02, 0x03, 0x04, 0x05)
IP_ADDRESS = (192, 168, 2, 111)
SUBNET_MASK = (255, 255, 0, 0)
GATEWAY_ADDRESS = (192, 168, 2, 1)
DNS_SERVER = (8, 8, 8, 8)
RED = (255, 0, 0)
YELLOW = (255, 150, 0)
GREEN = (0, 255, 0)
CYAN = (0, 255, 255)
BLUE = (0, 0, 255)
PURPLE = (180, 0, 255)
led = digitalio.DigitalInOut(board.GP25)
led.direction = digitalio.Direction.OUTPUT
ethernetRst = digitalio.DigitalInOut(W5500_RSTn)
ethernetRst.direction = digitalio.Direction.OUTPUT
# For Adafruit Ethernet FeatherWing
cs = digitalio.DigitalInOut(SPI1_CSn)
# For Particle Ethernet FeatherWing
# cs = digitalio.DigitalInOut(board.D5)
spi_bus = busio.SPI(SPI1_SCK, MOSI=SPI1_TX, MISO=SPI1_RX)
# Reset W5500 first
ethernetRst.value = False
time.sleep(1)
ethernetRst.value = True
# # Initialize ethernet interface without DHCP
eth = WIZNET5K(spi_bus, cs, is_dhcp=False, mac=MY_MAC, debug=False)
# # Set network configuration
eth.ifconfig = (IP_ADDRESS, SUBNET_MASK, GATEWAY_ADDRESS, DNS_SERVER)
# Initialize ethernet interface with DHCP
eth = WIZNET5K(spi_bus, cs, is_dhcp=True, mac=MY_MAC, debug=False)
print("Chip Version:", eth.chip)
print("MAC Address:", [hex(i) for i in eth.mac_address])
print("My IP address is:", eth.pretty_ip(eth.ip_address))
pixels = neopixel.NeoPixel(pixel_pin, num_pixels, brightness=0.1, auto_write=False)
# Initialize a socket for our server
socket.set_interface(eth)
server = socket.socket() # Allocate socket for the server
server_ip = None # IP address of server
server_port = 50007 # Port to listen on
server.bind((server_ip, server_port)) # Bind to IP and Port
server.listen() # Begin listening for incoming clients
print("server listen")
conn = None
while True:
if conn is None:
conn, addr = server.accept() # Wait for a connection from a client.
print("socket connected")
print(conn, addr)
else :
if conn.status in (
SNSR_SOCK_FIN_WAIT,
SNSR_SOCK_CLOSE_WAIT,
):
print("socket closed")
conn.close()
conn = None
else :
# print("socket established", conn.status)
data = conn.recv()
if data:
print(data)
conn.send(data) # Echo message back to client
# expect data format RED example: 255,0,0
recvStr = data.decode('UTF-8')
print(recvStr)
color = recvStr.split(",", 2)
print(color)
if len(color) != 3 or not "".join(color).isdigit():
pass
try:
red = int(color[0])
green = int(color[1])
blue = int(color[2])
pixels.fill((red,green,blue))
pixels.show()
except:
pass
led.value = not led.value
time.sleep(1)
print("Done!")
@@ -1,957 +0,0 @@
# SPDX-FileCopyrightText: 2010 WIZnet
# SPDX-FileCopyrightText: 2010 Arduino LLC
# SPDX-FileCopyrightText: 2008 Bjoern Hartmann
# SPDX-FileCopyrightText: 2018 Paul Stoffregen
# SPDX-FileCopyrightText: 2020 Brent Rubell for Adafruit Industries
# SPDX-FileCopyrightText: 2021 Patrick Van Oosterwijck
# SPDX-FileCopyrightText: 2021 Adam Cummick
#
# SPDX-License-Identifier: MIT
"""
`adafruit_wiznet5k`
================================================================================
Pure-Python interface for WIZNET 5k ethernet modules.
* Author(s): WIZnet, Arduino LLC, Bjoern Hartmann, Paul Stoffregen, Brent Rubell,
Patrick Van Oosterwijck
Implementation Notes
--------------------
**Software and Dependencies:**
* Adafruit CircuitPython firmware for the supported boards:
https://github.com/adafruit/circuitpython/releases
* Adafruit's Bus Device library: https://github.com/adafruit/Adafruit_CircuitPython_BusDevice
"""
from random import randint
import time
from micropython import const
from adafruit_bus_device.spi_device import SPIDevice
import adafruit_wiznet5k.adafruit_wiznet5k_dhcp as dhcp
import adafruit_wiznet5k.adafruit_wiznet5k_dns as dns
__version__ = "0.0.0-auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Wiznet5k.git"
# Wiznet5k Registers
REG_MR = const(0x0000) # Mode
REG_GAR = const(0x0001) # Gateway IP Address
REG_SUBR = const(0x0005) # Subnet Mask Address
REG_VERSIONR_W5500 = const(0x0039) # W5500 Silicon Version
REG_VERSIONR_W5100S = const(0x0080) # W5100S Silicon Version
REG_SHAR = const(0x0009) # Source Hardware Address
REG_SIPR = const(0x000F) # Source IP Address
REG_PHYCFGR = const(0x002E) # W5500 PHY Configuration
REG_PHYCFGR_W5100S = const(0x003C) # W5100S PHY Configuration
# Wiznet5k Socket Registers
REG_SNMR = const(0x0000) # Socket n Mode
REG_SNCR = const(0x0001) # Socket n Command
REG_SNIR = const(0x0002) # Socket n Interrupt
REG_SNSR = const(0x0003) # Socket n Status
REG_SNPORT = const(0x0004) # Socket n Source Port
REG_SNDIPR = const(0x000C) # Destination IP Address
REG_SNDPORT = const(0x0010) # Destination Port
REG_SNRX_RSR = const(0x0026) # RX Free Size
REG_SNRX_RD = const(0x0028) # Read Size Pointer
REG_SNTX_FSR = const(0x0020) # Socket n TX Free Size
REG_SNTX_WR = const(0x0024) # TX Write Pointer
# SNSR Commands
SNSR_SOCK_CLOSED = const(0x00)
SNSR_SOCK_INIT = const(0x13)
SNSR_SOCK_LISTEN = const(0x14)
SNSR_SOCK_SYNSENT = const(0x15)
SNSR_SOCK_SYNRECV = const(0x16)
SNSR_SOCK_ESTABLISHED = const(0x17)
SNSR_SOCK_FIN_WAIT = const(0x18)
SNSR_SOCK_CLOSING = const(0x1A)
SNSR_SOCK_TIME_WAIT = const(0x1B)
SNSR_SOCK_CLOSE_WAIT = const(0x1C)
SNSR_SOCK_LAST_ACK = const(0x1D)
SNSR_SOCK_UDP = const(0x22)
SNSR_SOCK_IPRAW = const(0x32)
SNSR_SOCK_MACRAW = const(0x42)
SNSR_SOCK_PPPOE = const(0x5F)
# Sock Commands (CMD)
CMD_SOCK_OPEN = const(0x01)
CMD_SOCK_LISTEN = const(0x02)
CMD_SOCK_CONNECT = const(0x04)
CMD_SOCK_DISCON = const(0x08)
CMD_SOCK_CLOSE = const(0x10)
CMD_SOCK_SEND = const(0x20)
CMD_SOCK_SEND_MAC = const(0x21)
CMD_SOCK_SEND_KEEP = const(0x22)
CMD_SOCK_RECV = const(0x40)
# Socket n Interrupt Register
SNIR_SEND_OK = const(0x10)
SNIR_TIMEOUT = const(0x08)
SNIR_RECV = const(0x04)
SNIR_DISCON = const(0x02)
SNIR_CON = const(0x01)
CH_SIZE = const(0x100)
SOCK_SIZE = const(0x800) # MAX W5k socket size
SOCK_MASK = const(0x7FF)
# Register commands
MR_RST = const(0x80) # Mode Register RST
# Socket mode register
SNMR_CLOSE = const(0x00)
SNMR_TCP = const(0x21)
SNMR_UDP = const(0x02)
SNMR_IPRAW = const(0x03)
SNMR_MACRAW = const(0x04)
SNMR_PPPOE = const(0x05)
MAX_PACKET = const(4000)
LOCAL_PORT = const(0x400)
# Default hardware MAC address
DEFAULT_MAC = (0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED)
# Maximum number of sockets to support, differs between chip versions.
W5200_W5500_MAX_SOCK_NUM = const(0x08)
W5100_MAX_SOCK_NUM = const(0x04)
SOCKET_INVALID = const(255)
# UDP socket struct.
UDP_SOCK = {"bytes_remaining": 0, "remote_ip": 0, "remote_port": 0}
# Source ports in use
SRC_PORTS = [0] * W5200_W5500_MAX_SOCK_NUM
class WIZNET5K: # pylint: disable=too-many-public-methods
"""Interface for WIZNET5K module.
:param ~busio.SPI spi_bus: The SPI bus the Wiznet module is connected to.
:param ~digitalio.DigitalInOut cs: Chip select pin.
:param ~digitalio.DigitalInOut rst: Optional reset pin.
:param bool is_dhcp: Whether to start DHCP automatically or not.
:param list mac: The Wiznet's MAC Address.
:param str hostname: The desired hostname, with optional {} to fill in MAC.
:param int dhcp_timeout: Timeout in seconds for DHCP response.
:param bool debug: Enable debugging output.
"""
TCP_MODE = const(0x21)
UDP_MODE = const(0x02)
TLS_MODE = const(0x03) # This is NOT currently implemented
# pylint: disable=too-many-arguments
def __init__(
self,
spi_bus,
cs,
reset=None,
is_dhcp=True,
mac=DEFAULT_MAC,
hostname=None,
dhcp_timeout=30,
debug=False,
):
self._debug = debug
self._chip_type = None
self._device = SPIDevice(spi_bus, cs, baudrate=8000000, polarity=0, phase=0)
# init c.s.
self._cs = cs
# reset wiznet module prior to initialization
if reset:
reset.value = True
time.sleep(0.1)
reset.value = False
time.sleep(0.1)
# Buffer for reading params from module
self._pbuff = bytearray(8)
self._rxbuf = bytearray(MAX_PACKET)
# attempt to initialize the module
self._ch_base_msb = 0
assert self._w5100_init() == 1, "Failed to initialize WIZnet module."
# Set MAC address
self.mac_address = mac
self.src_port = 0
self._dns = 0
# First, wait link status is on
# to avoid the code during DHCP, socket listen, connect ... - assert self.link_status, "Ethernet cable disconnected!"
start_time = time.monotonic()
while True:
if self.link_status or ((time.monotonic() - start_time) > 5):
break
time.sleep(1)
if self._debug:
print("My Link is:", self.link_status)
self._dhcp_client = None
# Set DHCP
if is_dhcp:
ret = self.set_dhcp(hostname, dhcp_timeout)
if ret != 0:
self._dhcp_client = None
assert ret == 0, "Failed to configure DHCP Server!"
def set_dhcp(self, hostname=None, response_timeout=30):
"""Initializes the DHCP client and attempts to retrieve
and set network configuration from the DHCP server.
Returns 0 if DHCP configured, -1 otherwise.
:param str hostname: The desired hostname, with optional {} to fill in MAC.
:param int response_timeout: Time to wait for server to return packet, in seconds.
"""
if self._debug:
print("* Initializing DHCP")
# Return IP assigned by DHCP
self._dhcp_client = dhcp.DHCP(
self, self.mac_address, hostname, response_timeout, debug=self._debug
)
ret = self._dhcp_client.request_dhcp_lease()
if ret == 1:
if self._debug:
_ifconfig = self.ifconfig
print("* Found DHCP Server:")
print(
"IP: {}\nSubnet Mask: {}\nGW Addr: {}\nDNS Server: {}".format(
*_ifconfig
)
)
return 0
return -1
def maintain_dhcp_lease(self):
"""Maintain DHCP lease"""
if self._dhcp_client is not None:
self._dhcp_client.maintain_dhcp_lease()
def get_host_by_name(self, hostname):
"""Convert a hostname to a packed 4-byte IP Address.
Returns a 4 bytearray.
"""
if self._debug:
print("* Get host by name")
if isinstance(hostname, str):
hostname = bytes(hostname, "utf-8")
# Return IP assigned by DHCP
_dns_client = dns.DNS(self, self._dns, debug=self._debug)
ret = _dns_client.gethostbyname(hostname)
if self._debug:
print("* Resolved IP: ", ret)
assert ret != -1, "Failed to resolve hostname!"
return ret
@property
def max_sockets(self):
"""Returns max number of sockets supported by chip."""
if self._chip_type == "w5500":
return W5200_W5500_MAX_SOCK_NUM
elif self._chip_type == "w5100s":
return W5100_MAX_SOCK_NUM
return -1
@property
def chip(self):
"""Returns the chip type."""
return self._chip_type
@property
def ip_address(self):
"""Returns the configured IP address."""
return self.read(REG_SIPR, 0x00, 4)
def pretty_ip(self, ip): # pylint: disable=no-self-use, invalid-name
"""Converts a bytearray IP address to a
dotted-quad string for printing
"""
return "%d.%d.%d.%d" % (ip[0], ip[1], ip[2], ip[3])
def unpretty_ip(self, ip): # pylint: disable=no-self-use, invalid-name
"""Converts a dotted-quad string to a bytearray IP address"""
octets = [int(x) for x in ip.split(".")]
return bytes(octets)
@property
def mac_address(self):
"""Returns the hardware's MAC address."""
return self.read(REG_SHAR, 0x00, 6)
@mac_address.setter
def mac_address(self, address):
"""Sets the hardware MAC address.
:param tuple address: Hardware MAC address.
"""
self.write(REG_SHAR, 0x04, address)
def pretty_mac(self, mac): # pylint: disable=no-self-use, invalid-name
"""Converts a bytearray MAC address to a
dotted-quad string for printing
"""
return "%s:%s:%s:%s:%s:%s" % (
hex(mac[0]),
hex(mac[1]),
hex(mac[2]),
hex(mac[3]),
hex(mac[4]),
hex(mac[5]),
)
def remote_ip(self, socket_num):
"""Returns the IP address of the host who sent the current incoming packet.
:param int socket num: Desired socket.
"""
if socket_num >= self.max_sockets:
return self._pbuff
for octet in range(0, 4):
self._pbuff[octet] = self._read_socket(socket_num, REG_SNDIPR + octet)[0]
return self.pretty_ip(self._pbuff)
@property
def link_status(self):
""" "Returns if the PHY is connected."""
if self._chip_type == "w5500":
data = self.read(REG_PHYCFGR, 0x00)
return data[0] & 0x01
elif self._chip_type == "w5100s":
data = self.read(REG_PHYCFGR_W5100S, 0x00)
return data[0] & 0x01
return 0
def remote_port(self, socket_num):
"""Returns the port of the host who sent the current incoming packet."""
if socket_num >= self.max_sockets:
return self._pbuff
for octet in range(0, 2):
self._pbuff[octet] = self._read_socket(socket_num, REG_SNDPORT + octet)[0]
return int((self._pbuff[0] << 8) | self._pbuff[0])
@property
def ifconfig(self):
"""Returns the network configuration as a tuple."""
return (
self.ip_address,
self.read(REG_SUBR, 0x00, 4),
self.read(REG_GAR, 0x00, 4),
self._dns,
)
@ifconfig.setter
def ifconfig(self, params):
"""Sets network configuration to provided tuple in format:
(ip_address, subnet_mask, gateway_address, dns_server).
"""
ip_address, subnet_mask, gateway_address, dns_server = params
self.write(REG_SIPR, 0x04, ip_address)
self.write(REG_SUBR, 0x04, subnet_mask)
self.write(REG_GAR, 0x04, gateway_address)
self._dns = dns_server
def _w5100_init(self):
"""Initializes and detects a wiznet5k module."""
time.sleep(1)
self._cs.switch_to_output()
self._cs.value = 1
# Detect if chip is Wiznet W5500
if self.detect_w5500() == 1:
# perform w5500 initialization
for i in range(0, W5200_W5500_MAX_SOCK_NUM):
ctrl_byte = 0x0C + (i << 5)
self.write(0x1E, ctrl_byte, 2)
self.write(0x1F, ctrl_byte, 2)
else:
# Detect if chip is Wiznet W5100S
if self.detect_w5100s() == 1:
pass
else:
return 0
return 1
def detect_w5500(self):
"""Detects W5500 chip."""
self._chip_type = "w5500"
assert self.sw_reset() == 0, "Chip not reset properly!"
self._write_mr(0x08)
# assert self._read_mr()[0] == 0x08, "Expected 0x08."
if self._read_mr()[0] != 0x08 : return -1
self._write_mr(0x10)
# assert self._read_mr()[0] == 0x10, "Expected 0x10."
if self._read_mr()[0] != 0x10 : return -1
self._write_mr(0x00)
# assert self._read_mr()[0] == 0x00, "Expected 0x00."
if self._read_mr()[0] != 0x00 : return -1
if self.read(REG_VERSIONR_W5500, 0x00)[0] != 0x04:
return -1
# self._chip_type = "w5500"
# self._ch_base_msb = 0x10
return 1
def detect_w5100s(self):
"""Detects W5100S chip."""
self._chip_type = "w5100s"
# sw reset
assert self.sw_reset() == 0, "Chip not reset properly!"
if self.read(REG_VERSIONR_W5100S, 0x00)[0] != 0x51:
return -1
self._ch_base_msb = 0x0400
return 1
def sw_reset(self):
"""Performs a soft-reset on a Wiznet chip
by writing to its MR register reset bit.
"""
mode_reg = self._read_mr()
self._write_mr(0x80)
mode_reg = self._read_mr()
# W5100S case => 0x03
if (mode_reg[0] != 0x00) and (mode_reg[0] != 0x03):
return -1
return 0
def _read_mr(self):
"""Reads from the Mode Register (MR)."""
res = self.read(REG_MR, 0x00)
return res
def _write_mr(self, data):
"""Writes to the mode register (MR).
:param int data: Data to write to the mode register.
"""
self.write(REG_MR, 0x04, data)
def read(self, addr, callback, length=1, buffer=None):
"""Reads data from a register address.
:param int addr: Register address.
"""
with self._device as bus_device:
if self._chip_type == "w5500":
bus_device.write(bytes([addr >> 8])) # pylint: disable=no-member
bus_device.write(bytes([addr & 0xFF])) # pylint: disable=no-member
bus_device.write(bytes([callback])) # pylint: disable=no-member
else :
#if self._chip_type == "w5100s":
bus_device.write(bytes([0x0F])) # pylint: disable=no-member
bus_device.write(bytes([addr >> 8])) # pylint: disable=no-member
bus_device.write(bytes([addr & 0xFF])) # pylint: disable=no-member
if buffer is None:
self._rxbuf = bytearray(length)
bus_device.readinto(self._rxbuf) # pylint: disable=no-member
return self._rxbuf
bus_device.readinto(buffer, end=length) # pylint: disable=no-member
return buffer
def write(self, addr, callback, data):
"""Write data to a register address.
:param int addr: Destination address.
:param int callback: Callback reference.
:param int data: Data to write, as an integer.
:param bytearray data: Data to write, as a bytearray.
"""
with self._device as bus_device:
if self._chip_type == "w5500":
bus_device.write(bytes([addr >> 8])) # pylint: disable=no-member
bus_device.write(bytes([addr & 0xFF])) # pylint: disable=no-member
bus_device.write(bytes([callback])) # pylint: disable=no-member
else :
#if self._chip_type == "w5100s":
bus_device.write(bytes([0xF0])) # pylint: disable=no-member
bus_device.write(bytes([addr >> 8])) # pylint: disable=no-member
bus_device.write(bytes([addr & 0xFF])) # pylint: disable=no-member
if hasattr(data, "from_bytes"):
bus_device.write(bytes([data])) # pylint: disable=no-member
else:
for i, _ in enumerate(data):
bus_device.write(bytes([data[i]])) # pylint: disable=no-member
# Socket-Register API
def udp_remaining(self):
"""Returns amount of bytes remaining in a udp socket."""
if self._debug:
print("* UDP Bytes Remaining: ", UDP_SOCK["bytes_remaining"])
return UDP_SOCK["bytes_remaining"]
def socket_available(self, socket_num, sock_type=SNMR_TCP):
"""Returns the amount of bytes to be read from the socket.
:param int socket_num: Desired socket to return bytes from.
:param int sock_type: Socket type, defaults to TCP.
"""
if self._debug:
print(
"* socket_available called on socket {}, protocol {}".format(
socket_num, sock_type
)
)
assert socket_num <= self.max_sockets, "Provided socket exceeds max_sockets."
res = self._get_rx_rcv_size(socket_num)
if sock_type == SNMR_TCP:
return res
if res > 0:
if UDP_SOCK["bytes_remaining"]:
return UDP_SOCK["bytes_remaining"]
# parse the udp rx packet
# read the first 8 header bytes
ret, self._pbuff = self.socket_read(socket_num, 8)
if ret > 0:
UDP_SOCK["remote_ip"] = self._pbuff[:4]
UDP_SOCK["remote_port"] = (self._pbuff[4] << 8) + self._pbuff[5]
UDP_SOCK["bytes_remaining"] = (self._pbuff[6] << 8) + self._pbuff[7]
ret = UDP_SOCK["bytes_remaining"]
return ret
return 0
def socket_status(self, socket_num):
"""Returns the socket connection status. Can be: SNSR_SOCK_CLOSED,
SNSR_SOCK_INIT, SNSR_SOCK_LISTEN, SNSR_SOCK_SYNSENT, SNSR_SOCK_SYNRECV,
SNSR_SYN_SOCK_ESTABLISHED, SNSR_SOCK_FIN_WAIT, SNSR_SOCK_CLOSING,
SNSR_SOCK_TIME_WAIT, SNSR_SOCK_CLOSE_WAIT, SNSR_LAST_ACK,
SNSR_SOCK_UDP, SNSR_SOCK_IPRAW, SNSR_SOCK_MACRAW, SNSR_SOCK_PPOE.
"""
return self._read_snsr(socket_num)
def socket_connect(self, socket_num, dest, port, conn_mode=SNMR_TCP):
"""Open and verify we've connected a socket to a dest IP address
or hostname. By default, we use 'conn_mode'= SNMR_TCP but we
may also use SNMR_UDP.
"""
assert self.link_status, "Ethernet cable disconnected!"
if self._debug:
print(
"* w5k socket connect, protocol={}, port={}, ip={}".format(
conn_mode, port, self.pretty_ip(dest)
)
)
# initialize a socket and set the mode
res = self.socket_open(socket_num, conn_mode=conn_mode)
if res == 1:
raise RuntimeError("Failed to initalize a connection with the socket.")
# set socket destination IP and port
self._write_sndipr(socket_num, dest)
self._write_sndport(socket_num, port)
self._send_socket_cmd(socket_num, CMD_SOCK_CONNECT)
if conn_mode == SNMR_TCP:
# wait for tcp connection establishment
while self.socket_status(socket_num)[0] != SNSR_SOCK_ESTABLISHED:
time.sleep(0.001)
if self._debug:
print("SN_SR:", self.socket_status(socket_num)[0])
if self.socket_status(socket_num)[0] == SNSR_SOCK_CLOSED:
raise RuntimeError("Failed to establish connection.")
elif conn_mode == SNMR_UDP:
UDP_SOCK["bytes_remaining"] = 0
return 1
def _send_socket_cmd(self, socket, cmd):
self._write_sncr(socket, cmd)
while self._read_sncr(socket) != b"\x00":
if self._debug:
print("waiting for sncr to clear...")
def get_socket(self):
"""Requests, allocates and returns a socket from the W5k
chip. Returned socket number may not exceed max_sockets.
"""
if self._debug:
print("*** Get socket")
sock = SOCKET_INVALID
for _sock in range(self.max_sockets):
status = self.socket_status(_sock)[0]
if status == SNSR_SOCK_CLOSED:
sock = _sock
break
if self._debug:
print("Allocated socket #{}".format(sock))
return sock
def socket_listen(self, socket_num, port, conn_mode=SNMR_TCP):
"""Start listening on a socket (default TCP mode).
:parm int socket_num: socket number
:parm int port: port to listen on
:parm int conn_mode: connection mode SNMR_TCP (default) or SNMR_UDP
"""
assert self.link_status, "Ethernet cable disconnected!"
if self._debug:
print(
"* Listening on port={}, ip={}".format(
port, self.pretty_ip(self.ip_address)
)
)
# Initialize a socket and set the mode
self.src_port = port
res = self.socket_open(socket_num, conn_mode=conn_mode)
self.src_port = 0
if res == 1:
raise RuntimeError("Failed to initalize the socket.")
# Send listen command
self._send_socket_cmd(socket_num, CMD_SOCK_LISTEN)
# Wait until ready
status = [SNSR_SOCK_CLOSED]
while status[0] not in (SNSR_SOCK_LISTEN, SNSR_SOCK_ESTABLISHED, SNSR_SOCK_UDP):
status = self._read_snsr(socket_num)
if status[0] == SNSR_SOCK_CLOSED:
raise RuntimeError("Listening socket closed.")
def socket_accept(self, socket_num):
"""Gets the dest IP and port from an incoming connection.
Returns the next socket number so listening can continue
:parm int socket_num: socket number
"""
dest_ip = self.remote_ip(socket_num)
dest_port = self.remote_port(socket_num)
next_socknum = self.get_socket()
if self._debug:
print(
"* Dest is ({}, {}), Next listen socknum is #{}".format(
dest_ip, dest_port, next_socknum
)
)
return next_socknum, (dest_ip, dest_port)
def socket_open(self, socket_num, conn_mode=SNMR_TCP):
"""Opens a TCP or UDP socket. By default, we use
'conn_mode'=SNMR_TCP but we may also use SNMR_UDP.
"""
assert self.link_status, "Ethernet cable disconnected!"
if self._debug:
print("*** Opening socket %d" % socket_num)
status = self._read_snsr(socket_num)[0]
if status in (
SNSR_SOCK_CLOSED,
SNSR_SOCK_TIME_WAIT,
SNSR_SOCK_FIN_WAIT,
SNSR_SOCK_CLOSE_WAIT,
SNSR_SOCK_CLOSING,
SNSR_SOCK_UDP,
):
if self._debug:
print("* Opening W5k Socket, protocol={}".format(conn_mode))
time.sleep(0.00025)
self._write_snmr(socket_num, conn_mode)
self._write_snir(socket_num, 0xFF)
if self.src_port > 0:
# write to socket source port
self._write_sock_port(socket_num, self.src_port)
else:
s_port = randint(49152, 65535)
while s_port in SRC_PORTS:
s_port = randint(49152, 65535)
self._write_sock_port(socket_num, s_port)
SRC_PORTS[socket_num] = s_port
# open socket
self._write_sncr(socket_num, CMD_SOCK_OPEN)
self._read_sncr(socket_num)
assert (
self._read_snsr((socket_num))[0] == 0x13
or self._read_snsr((socket_num))[0] == 0x22
), "Could not open socket in TCP or UDP mode."
return 0
return 1
def socket_close(self, socket_num):
"""Closes a socket."""
if self._debug:
print("*** Closing socket #%d" % socket_num)
self._write_sncr(socket_num, CMD_SOCK_CLOSE)
self._read_sncr(socket_num)
def socket_disconnect(self, socket_num):
"""Disconnect a TCP connection."""
if self._debug:
print("*** Disconnecting socket #%d" % socket_num)
self._write_sncr(socket_num, CMD_SOCK_DISCON)
self._read_sncr(socket_num)
def socket_read(self, socket_num, length):
"""Reads data from a socket into a buffer.
Returns buffer.
"""
assert self.link_status, "Ethernet cable disconnected!"
assert socket_num <= self.max_sockets, "Provided socket exceeds max_sockets."
# Check if there is data available on the socket
ret = self._get_rx_rcv_size(socket_num)
if self._debug:
print("Bytes avail. on sock: ", ret)
if ret == 0:
# no data on socket?
status = self._read_snmr(socket_num)
if status in (SNSR_SOCK_LISTEN, SNSR_SOCK_CLOSED, SNSR_SOCK_CLOSE_WAIT):
# remote end closed its side of the connection, EOF state
ret = 0
resp = 0
else:
# connection is alive, no data waiting to be read
ret = -1
resp = -1
elif ret > length:
# set ret to the length of buffer
ret = length
if ret > 0:
if self._debug:
print("\t * Processing {} bytes of data".format(ret))
# Read the starting save address of the received data
ptr = self._read_snrx_rd(socket_num)
if self._chip_type == "w5500":
# Read data from the starting address of snrx_rd
ctrl_byte = 0x18 + (socket_num << 5)
resp = self.read(ptr, ctrl_byte, ret)
else :
#if self._chip_type == "w5100s":
offset = ptr & SOCK_MASK
src_addr = offset + (socket_num * SOCK_SIZE + 0x6000)
if (offset + ret > SOCK_SIZE) :
size = SOCK_SIZE - offset
resp1 = self.read(src_addr, 0x00, size)
size = ret - size
src_addr = (socket_num * SOCK_SIZE + 0x6000)
resp2 = self.read(src_addr, 0x00, size)
resp = resp1 + resp2
else :
resp = self.read(src_addr, 0x00, ret)
# After reading the received data, update Sn_RX_RD to the increased
# value as many as the reading size.
ptr = (ptr + ret) & 0xFFFF
self._write_snrx_rd(socket_num, ptr)
# Notify the W5k of the updated Sn_Rx_RD
self._write_sncr(socket_num, CMD_SOCK_RECV)
self._read_sncr(socket_num)
return ret, resp
def read_udp(self, socket_num, length):
"""Read UDP socket's remaining bytes."""
if UDP_SOCK["bytes_remaining"] > 0:
if UDP_SOCK["bytes_remaining"] <= length:
ret, resp = self.socket_read(socket_num, UDP_SOCK["bytes_remaining"])
else:
ret, resp = self.socket_read(socket_num, length)
if ret > 0:
UDP_SOCK["bytes_remaining"] -= ret
return ret, resp
return -1
def socket_write(self, socket_num, buffer, timeout=0):
"""Writes a bytearray to a provided socket."""
assert self.link_status, "Ethernet cable disconnected!"
assert socket_num <= self.max_sockets, "Provided socket exceeds max_sockets."
status = 0
ret = 0
free_size = 0
if len(buffer) > SOCK_SIZE:
ret = SOCK_SIZE
else:
ret = len(buffer)
stamp = time.monotonic()
# if buffer is available, start the transfer
free_size = self._get_tx_free_size(socket_num)
while free_size < ret:
free_size = self._get_tx_free_size(socket_num)
status = self.socket_status(socket_num)[0]
if status not in (SNSR_SOCK_ESTABLISHED, SNSR_SOCK_CLOSE_WAIT) or (
timeout and time.monotonic() - stamp > timeout
):
ret = 0
break
# Read the starting address for saving the transmitting data.
ptr = self._read_sntx_wr(socket_num)
offset = ptr & SOCK_MASK
if self._chip_type == "w5500":
dst_addr = offset + (socket_num * SOCK_SIZE + 0x8000)
txbuf = buffer[:ret]
cntl_byte = 0x14 + (socket_num << 5)
self.write(dst_addr, cntl_byte, txbuf)
else :
#if self._chip_type == "w5100s":
dst_addr = offset + (socket_num * SOCK_SIZE + 0x4000)
if (offset + ret > SOCK_SIZE) :
size = SOCK_SIZE - offset
txbuf = buffer[0:size]
self.write(dst_addr, 0x00, txbuf)
txbuf = buffer[size:ret]
size = ret - size
dst_addr = (socket_num * SOCK_SIZE + 0x4000)
self.write(dst_addr, 0x00, txbuf)
else :
txbuf = buffer[:ret]
self.write(dst_addr, 0x00, buffer[:ret])
# update sn_tx_wr to the value + data size
ptr = (ptr + ret) & 0xFFFF
self._write_sntx_wr(socket_num, ptr)
self._write_sncr(socket_num, CMD_SOCK_SEND)
self._read_sncr(socket_num)
# check data was transferred correctly
while (
self._read_socket(socket_num, REG_SNIR)[0] & SNIR_SEND_OK
) != SNIR_SEND_OK:
if (
self.socket_status(socket_num)[0]
in (
SNSR_SOCK_CLOSED,
SNSR_SOCK_TIME_WAIT,
SNSR_SOCK_FIN_WAIT,
SNSR_SOCK_CLOSE_WAIT,
SNSR_SOCK_CLOSING,
)
or (timeout and time.monotonic() - stamp > timeout)
):
# self.socket_close(socket_num)
return 0
time.sleep(0.01)
self._write_snir(socket_num, SNIR_SEND_OK)
return ret
# Socket-Register Methods
def _get_rx_rcv_size(self, sock):
"""Get size of recieved and saved in socket buffer."""
val = 0
val_1 = self._read_snrx_rsr(sock)
while val != val_1:
val_1 = self._read_snrx_rsr(sock)
if val_1 != 0:
val = self._read_snrx_rsr(sock)
return int.from_bytes(val, "b")
def _get_tx_free_size(self, sock):
"""Get free size of sock's tx buffer block."""
val = 0
val_1 = self._read_sntx_fsr(sock)
while val != val_1:
val_1 = self._read_sntx_fsr(sock)
if val_1 != 0:
val = self._read_sntx_fsr(sock)
return int.from_bytes(val, "b")
def _read_snrx_rd(self, sock):
self._pbuff[0] = self._read_socket(sock, REG_SNRX_RD)[0]
self._pbuff[1] = self._read_socket(sock, REG_SNRX_RD + 1)[0]
return self._pbuff[0] << 8 | self._pbuff[1]
def _write_snrx_rd(self, sock, data):
self._write_socket(sock, REG_SNRX_RD, data >> 8 & 0xFF)
self._write_socket(sock, REG_SNRX_RD + 1, data & 0xFF)
def _write_sntx_wr(self, sock, data):
self._write_socket(sock, REG_SNTX_WR, data >> 8 & 0xFF)
self._write_socket(sock, REG_SNTX_WR + 1, data & 0xFF)
def _read_sntx_wr(self, sock):
self._pbuff[0] = self._read_socket(sock, 0x0024)[0]
self._pbuff[1] = self._read_socket(sock, 0x0024 + 1)[0]
return self._pbuff[0] << 8 | self._pbuff[1]
def _read_sntx_fsr(self, sock):
data = self._read_socket(sock, REG_SNTX_FSR)
data += self._read_socket(sock, REG_SNTX_FSR + 1)
return data
def _read_snrx_rsr(self, sock):
data = self._read_socket(sock, REG_SNRX_RSR)
data += self._read_socket(sock, REG_SNRX_RSR + 1)
return data
def _write_sndipr(self, sock, ip_addr):
"""Writes to socket destination IP Address."""
for octet in range(0, 4):
self._write_socket(sock, REG_SNDIPR + octet, ip_addr[octet])
def _write_sndport(self, sock, port):
"""Writes to socket destination port."""
self._write_socket(sock, REG_SNDPORT, port >> 8)
self._write_socket(sock, REG_SNDPORT + 1, port & 0xFF)
def _read_snsr(self, sock):
"""Reads Socket n Status Register."""
return self._read_socket(sock, REG_SNSR)
def _write_snmr(self, sock, protocol):
"""Write to Socket n Mode Register."""
self._write_socket(sock, REG_SNMR, protocol)
def _write_snir(self, sock, data):
"""Write to Socket n Interrupt Register."""
self._write_socket(sock, REG_SNIR, data)
def _write_sock_port(self, sock, port):
"""Write to the socket port number."""
self._write_socket(sock, REG_SNPORT, port >> 8)
self._write_socket(sock, REG_SNPORT + 1, port & 0xFF)
def _write_sncr(self, sock, data):
self._write_socket(sock, REG_SNCR, data)
def _read_sncr(self, sock):
return self._read_socket(sock, REG_SNCR)
def _read_snmr(self, sock):
return self._read_socket(sock, REG_SNMR)
def _write_socket(self, sock, address, data):
"""Write to a W5k socket register."""
if self._chip_type == "w5500":
cntl_byte = (sock << 5) + 0x0C
return self.write(address, cntl_byte, data)
else :
#if self._chip_type == "w5100s":
cntl_byte = 0
return self.write(self._ch_base_msb + sock * CH_SIZE + address, cntl_byte, data)
def _read_socket(self, sock, address):
"""Read a W5k socket register."""
if self._chip_type == "w5500":
cntl_byte = (sock << 5) + 0x08
return self.read(address, cntl_byte)
else :
#if self._chip_type == "w5100s":
cntl_byte = 0
return self.read(self._ch_base_msb + sock * CH_SIZE + address, cntl_byte)
@@ -1,497 +0,0 @@
# SPDX-FileCopyrightText: 2009 Jordan Terell (blog.jordanterrell.com)
# SPDX-FileCopyrightText: 2020 Brent Rubell for Adafruit Industries
# SPDX-FileCopyrightText: 2021 Patrick Van Oosterwijck @ Silicognition LLC
#
# SPDX-License-Identifier: MIT
"""
`adafruit_wiznet5k_dhcp`
================================================================================
Pure-Python implementation of Jordan Terrell's DHCP library v0.3
* Author(s): Jordan Terrell, Brent Rubell
"""
import gc
import time
from random import randint
from micropython import const
import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket
from adafruit_wiznet5k.adafruit_wiznet5k_socket import htonl, htons
# DHCP State Machine
STATE_DHCP_START = const(0x00)
STATE_DHCP_DISCOVER = const(0x01)
STATE_DHCP_REQUEST = const(0x02)
STATE_DHCP_LEASED = const(0x03)
STATE_DHCP_REREQUEST = const(0x04)
STATE_DHCP_RELEASE = const(0x05)
STATE_DHCP_WAIT = const(0x06)
STATE_DHCP_DISCONN = const(0x07)
# DHCP wait time between attempts
DHCP_WAIT_TIME = const(60)
# DHCP Message Types
DHCP_DISCOVER = const(1)
DHCP_OFFER = const(2)
DHCP_REQUEST = const(3)
DHCP_DECLINE = const(4)
DHCP_ACK = const(5)
DHCP_NAK = const(6)
DHCP_RELEASE = const(7)
DHCP_INFORM = const(8)
# DHCP Message OP Codes
DHCP_BOOT_REQUEST = const(0x01)
DHCP_BOOT_REPLY = const(0x02)
DHCP_HTYPE10MB = const(0x01)
DHCP_HTYPE100MB = const(0x02)
DHCP_HLENETHERNET = const(0x06)
DHCP_HOPS = const(0x00)
MAGIC_COOKIE = const(0x63825363)
MAX_DHCP_OPT = const(0x10)
# Default DHCP Server port
DHCP_SERVER_PORT = const(67)
# DHCP Lease Time, in seconds
DEFAULT_LEASE_TIME = const(900)
BROADCAST_SERVER_ADDR = (255, 255, 255, 255)
# DHCP Response Options
MSG_TYPE = 53
SUBNET_MASK = 1
ROUTERS_ON_SUBNET = 3
DNS_SERVERS = 6
DHCP_SERVER_ID = 54
T1_VAL = 58
T2_VAL = 59
LEASE_TIME = 51
OPT_END = 255
# Packet buffer
_BUFF = bytearray(318)
class DHCP:
"""W5k DHCP Client implementation.
:param eth: Wiznet 5k object
:param list mac_address: Hardware MAC.
:param str hostname: The desired hostname, with optional {} to fill in MAC.
:param int response_timeout: DHCP Response timeout.
:param bool debug: Enable debugging output.
"""
# pylint: disable=too-many-arguments, too-many-instance-attributes, invalid-name
def __init__(
self, eth, mac_address, hostname=None, response_timeout=30, debug=False
):
self._debug = debug
self._response_timeout = response_timeout
self._mac_address = mac_address
# Set socket interface
socket.set_interface(eth)
self._eth = eth
self._sock = None
# DHCP state machine
self._dhcp_state = STATE_DHCP_START
self._initial_xid = 0
self._transaction_id = 0
self._start_time = 0
# DHCP server configuration
self.dhcp_server_ip = BROADCAST_SERVER_ADDR
self.local_ip = 0
self.gateway_ip = 0
self.subnet_mask = 0
self.dns_server_ip = 0
# Lease configuration
self._lease_time = 0
self._last_lease_time = 0
self._renew_in_sec = 0
self._rebind_in_sec = 0
self._t1 = 0
self._t2 = 0
# Select an initial transaction id
self._transaction_id = randint(1, 0x7FFFFFFF)
# Host name
mac_string = "".join("{:02X}".format(o) for o in mac_address)
self._hostname = bytes(
(hostname or "WIZnet{}").split(".")[0].format(mac_string)[:42], "utf-8"
)
# pylint: disable=too-many-statements
def send_dhcp_message(self, state, time_elapsed, renew=False):
"""Assemble and send a DHCP message packet to a socket.
:param int state: DHCP Message state.
:param float time_elapsed: Number of seconds elapsed since DHCP process started
:param bool renew: Set True for renew and rebind
"""
_BUFF[:] = b"\x00" * len(_BUFF)
# OP
_BUFF[0] = DHCP_BOOT_REQUEST
# HTYPE
_BUFF[1] = DHCP_HTYPE10MB
# HLEN
_BUFF[2] = DHCP_HLENETHERNET
# HOPS
_BUFF[3] = DHCP_HOPS
# Transaction ID (xid)
self._initial_xid = htonl(self._transaction_id)
self._initial_xid = self._initial_xid.to_bytes(4, "l")
_BUFF[4:7] = self._initial_xid
# seconds elapsed
_BUFF[8] = (int(time_elapsed) & 0xFF00) >> 8
_BUFF[9] = int(time_elapsed) & 0x00FF
# flags
flags = htons(0x8000)
flags = flags.to_bytes(2, "b")
_BUFF[10] = flags[1]
_BUFF[11] = flags[0]
# NOTE: Skipping ciaddr/yiaddr/siaddr/giaddr
# as they're already set to 0.0.0.0
# Except when renewing, then fill in ciaddr
if renew:
_BUFF[12:15] = bytes(self.local_ip)
# chaddr
_BUFF[28:34] = self._mac_address
# NOTE: 192 octets of 0's, BOOTP legacy
# Magic Cookie
_BUFF[236] = (MAGIC_COOKIE >> 24) & 0xFF
_BUFF[237] = (MAGIC_COOKIE >> 16) & 0xFF
_BUFF[238] = (MAGIC_COOKIE >> 8) & 0xFF
_BUFF[239] = MAGIC_COOKIE & 0xFF
# Option - DHCP Message Type
_BUFF[240] = 53
_BUFF[241] = 0x01
_BUFF[242] = state
# Option - Client Identifier
_BUFF[243] = 61
# Length
_BUFF[244] = 0x07
# HW Type - ETH
_BUFF[245] = 0x01
# Client MAC Address
for mac in range(0, len(self._mac_address)):
_BUFF[246 + mac] = self._mac_address[mac]
# Option - Host Name
_BUFF[252] = 12
hostname_len = len(self._hostname)
after_hostname = 254 + hostname_len
_BUFF[253] = hostname_len
_BUFF[254:after_hostname] = self._hostname
if state == DHCP_REQUEST and not renew:
# Set the parsed local IP addr
_BUFF[after_hostname] = 50
_BUFF[after_hostname + 1] = 0x04
_BUFF[after_hostname + 2 : after_hostname + 6] = bytes(self.local_ip)
# Set the parsed dhcp server ip addr
_BUFF[after_hostname + 6] = 54
_BUFF[after_hostname + 7] = 0x04
_BUFF[after_hostname + 8 : after_hostname + 12] = bytes(self.dhcp_server_ip)
_BUFF[after_hostname + 12] = 55
_BUFF[after_hostname + 13] = 0x06
# subnet mask
_BUFF[after_hostname + 14] = 1
# routers on subnet
_BUFF[after_hostname + 15] = 3
# DNS
_BUFF[after_hostname + 16] = 6
# domain name
_BUFF[after_hostname + 17] = 15
# renewal (T1) value
_BUFF[after_hostname + 18] = 58
# rebinding (T2) value
_BUFF[after_hostname + 19] = 59
_BUFF[after_hostname + 20] = 255
# Send DHCP packet
self._sock.send(_BUFF)
# pylint: disable=too-many-branches, too-many-statements
def parse_dhcp_response(self):
"""Parse DHCP response from DHCP server.
Returns DHCP packet type.
"""
# store packet in buffer
_BUFF = self._sock.recv()
if self._debug:
print("DHCP Response: ", _BUFF)
# -- Parse Packet, FIXED -- #
# Validate OP
assert (
_BUFF[0] == DHCP_BOOT_REPLY
), "Malformed Packet - \
DHCP message OP is not expected BOOT Reply."
xid = _BUFF[4:8]
if bytes(xid) < self._initial_xid:
print("f")
return 0, 0
self.local_ip = tuple(_BUFF[16:20])
if _BUFF[28:34] == 0:
return 0, 0
if int.from_bytes(_BUFF[235:240], "l") != MAGIC_COOKIE:
return 0, 0
# -- Parse Packet, VARIABLE -- #
ptr = 240
while _BUFF[ptr] != OPT_END:
if _BUFF[ptr] == MSG_TYPE:
ptr += 1
opt_len = _BUFF[ptr]
ptr += opt_len
msg_type = _BUFF[ptr]
ptr += 1
elif _BUFF[ptr] == SUBNET_MASK:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self.subnet_mask = tuple(_BUFF[ptr : ptr + opt_len])
ptr += opt_len
elif _BUFF[ptr] == DHCP_SERVER_ID:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self.dhcp_server_ip = tuple(_BUFF[ptr : ptr + opt_len])
ptr += opt_len
elif _BUFF[ptr] == LEASE_TIME:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self._lease_time = int.from_bytes(_BUFF[ptr : ptr + opt_len], "l")
ptr += opt_len
elif _BUFF[ptr] == ROUTERS_ON_SUBNET:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self.gateway_ip = tuple(_BUFF[ptr : ptr + opt_len])
ptr += opt_len
elif _BUFF[ptr] == DNS_SERVERS:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self.dns_server_ip = tuple(_BUFF[ptr : ptr + 4])
ptr += opt_len # still increment even though we only read 1 addr.
elif _BUFF[ptr] == T1_VAL:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self._t1 = int.from_bytes(_BUFF[ptr : ptr + opt_len], "l")
ptr += opt_len
elif _BUFF[ptr] == T2_VAL:
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
self._t2 = int.from_bytes(_BUFF[ptr : ptr + opt_len], "l")
ptr += opt_len
elif _BUFF[ptr] == 0:
break
else:
# We're not interested in this option
ptr += 1
opt_len = _BUFF[ptr]
ptr += 1
# no-op
ptr += opt_len
if self._debug:
print(
"Msg Type: {}\nSubnet Mask: {}\nDHCP Server IP: {}\nDNS Server IP: {}\
\nGateway IP: {}\nLocal IP: {}\nT1: {}\nT2: {}\nLease Time: {}".format(
msg_type,
self.subnet_mask,
self.dhcp_server_ip,
self.dns_server_ip,
self.gateway_ip,
self.local_ip,
self._t1,
self._t2,
self._lease_time,
)
)
gc.collect()
return msg_type, xid
# pylint: disable=too-many-branches, too-many-statements
def _dhcp_state_machine(self):
"""DHCP state machine without wait loops to enable cooperative multi tasking
This state machine is used both by the initial blocking lease request and
the non-blocking DHCP maintenance function"""
if self._eth.link_status:
if self._dhcp_state == STATE_DHCP_DISCONN:
self._dhcp_state = STATE_DHCP_START
else:
if self._dhcp_state != STATE_DHCP_DISCONN:
self._dhcp_state = STATE_DHCP_DISCONN
self.dhcp_server_ip = BROADCAST_SERVER_ADDR
self._last_lease_time = 0
reset_ip = (0, 0, 0, 0)
self._eth.ifconfig = (reset_ip, reset_ip, reset_ip, reset_ip)
if self._sock is not None:
self._sock.close()
self._sock = None
if self._dhcp_state == STATE_DHCP_START:
self._start_time = time.monotonic()
self._transaction_id = (self._transaction_id + 1) & 0x7FFFFFFF
try:
self._sock = socket.socket(type=socket.SOCK_DGRAM)
except RuntimeError:
if self._debug:
print("* DHCP: Failed to allocate socket")
self._dhcp_state = STATE_DHCP_WAIT
else:
self._sock.settimeout(self._response_timeout)
self._sock.bind((None, 68))
self._sock.connect((self.dhcp_server_ip, DHCP_SERVER_PORT))
if self._last_lease_time == 0 or time.monotonic() > (
self._last_lease_time + self._lease_time
):
if self._debug:
print("* DHCP: Send discover to {}".format(self.dhcp_server_ip))
self.send_dhcp_message(
STATE_DHCP_DISCOVER, (time.monotonic() - self._start_time)
)
self._dhcp_state = STATE_DHCP_DISCOVER
else:
if self._debug:
print("* DHCP: Send request to {}".format(self.dhcp_server_ip))
self.send_dhcp_message(
DHCP_REQUEST, (time.monotonic() - self._start_time), True
)
self._dhcp_state = STATE_DHCP_REQUEST
elif self._dhcp_state == STATE_DHCP_DISCOVER:
if self._sock.available():
if self._debug:
print("* DHCP: Parsing OFFER")
msg_type, xid = self.parse_dhcp_response()
if msg_type == DHCP_OFFER:
# Check if transaction ID matches, otherwise it may be an offer
# for another device
if htonl(self._transaction_id) == int.from_bytes(xid, "l"):
if self._debug:
print(
"* DHCP: Send request to {}".format(self.dhcp_server_ip)
)
self._transaction_id = (self._transaction_id + 1) & 0x7FFFFFFF
self.send_dhcp_message(
DHCP_REQUEST, (time.monotonic() - self._start_time)
)
self._dhcp_state = STATE_DHCP_REQUEST
else:
if self._debug:
print("* DHCP: Received OFFER with non-matching xid")
else:
if self._debug:
print("* DHCP: Received DHCP Message is not OFFER")
elif self._dhcp_state == STATE_DHCP_REQUEST:
if self._sock.available():
if self._debug:
print("* DHCP: Parsing ACK")
msg_type, xid = self.parse_dhcp_response()
# Check if transaction ID matches, otherwise it may be
# for another device
if htonl(self._transaction_id) == int.from_bytes(xid, "l"):
if msg_type == DHCP_ACK:
if self._debug:
print("* DHCP: Successful lease")
self._sock.close()
self._sock = None
self._dhcp_state = STATE_DHCP_LEASED
self._last_lease_time = self._start_time
if self._lease_time == 0:
self._lease_time = DEFAULT_LEASE_TIME
if self._t1 == 0:
# T1 is 50% of _lease_time
self._t1 = self._lease_time >> 1
if self._t2 == 0:
# T2 is 87.5% of _lease_time
self._t2 = self._lease_time - (self._lease_time >> 3)
self._renew_in_sec = self._t1
self._rebind_in_sec = self._t2
self._eth.ifconfig = (
self.local_ip,
self.subnet_mask,
self.gateway_ip,
self.dns_server_ip,
)
gc.collect()
else:
if self._debug:
print("* DHCP: Received DHCP Message is not ACK")
else:
if self._debug:
print("* DHCP: Received non-matching xid")
elif self._dhcp_state == STATE_DHCP_WAIT:
if time.monotonic() > (self._start_time + DHCP_WAIT_TIME):
if self._debug:
print("* DHCP: Begin retry")
self._dhcp_state = STATE_DHCP_START
if time.monotonic() > (self._last_lease_time + self._rebind_in_sec):
self.dhcp_server_ip = BROADCAST_SERVER_ADDR
if time.monotonic() > (self._last_lease_time + self._lease_time):
reset_ip = (0, 0, 0, 0)
self._eth.ifconfig = (reset_ip, reset_ip, reset_ip, reset_ip)
elif self._dhcp_state == STATE_DHCP_LEASED:
if time.monotonic() > (self._last_lease_time + self._renew_in_sec):
self._dhcp_state = STATE_DHCP_START
if self._debug:
print("* DHCP: Time to renew lease")
if (
self._dhcp_state == STATE_DHCP_DISCOVER
or self._dhcp_state == STATE_DHCP_REQUEST
) and time.monotonic() > (self._start_time + self._response_timeout):
self._dhcp_state = STATE_DHCP_WAIT
if self._sock is not None:
self._sock.close()
self._sock = None
def request_dhcp_lease(self):
"""Request to renew or acquire a DHCP lease."""
if self._dhcp_state == STATE_DHCP_LEASED or self._dhcp_state == STATE_DHCP_WAIT:
self._dhcp_state = STATE_DHCP_START
while (
self._dhcp_state != STATE_DHCP_LEASED
and self._dhcp_state != STATE_DHCP_WAIT
):
self._dhcp_state_machine()
return self._dhcp_state == STATE_DHCP_LEASED
def maintain_dhcp_lease(self):
"""Maintain DHCP lease"""
self._dhcp_state_machine()
@@ -1,254 +0,0 @@
# SPDX-FileCopyrightText: 2009-2010 MCQN Ltd
# SPDX-FileCopyrightText: Brent Rubell for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
`adafruit_wiznet5k_dns`
================================================================================
Pure-Python implementation of the Arduino DNS client for WIZnet 5k-based
ethernet modules.
* Author(s): MCQN Ltd, Brent Rubell
"""
import time
from random import getrandbits
from micropython import const
import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket
from adafruit_wiznet5k.adafruit_wiznet5k_socket import htons
QUERY_FLAG = const(0x00)
OPCODE_STANDARD_QUERY = const(0x00)
RECURSION_DESIRED_FLAG = 1 << 8
TYPE_A = const(0x0001)
CLASS_IN = const(0x0001)
DATA_LEN = const(0x0004)
# Return codes for gethostbyname
SUCCESS = const(1)
TIMED_OUT = const(-1)
INVALID_SERVER = const(-2)
TRUNCATED = const(-3)
INVALID_RESPONSE = const(-4)
DNS_PORT = const(0x35) # port used for DNS request
class DNS:
"""W5K DNS implementation.
:param iface: Network interface
"""
def __init__(self, iface, dns_address, debug=False):
self._debug = debug
self._iface = iface
socket.set_interface(iface)
self._sock = socket.socket(type=socket.SOCK_DGRAM)
self._sock.settimeout(1)
self._dns_server = dns_address
self._host = 0
self._request_id = 0 # request identifier
self._pkt_buf = bytearray()
def gethostbyname(self, hostname):
"""Translate a host name to IPv4 address format.
:param str hostname: Desired host name to connect to.
Returns the IPv4 address as a bytearray if successful, -1 otherwise.
"""
if self._dns_server is None:
return INVALID_SERVER
self._host = hostname
# build DNS request packet
self._build_dns_header()
self._build_dns_question()
# Send DNS request packet
self._sock.bind((None, DNS_PORT))
self._sock.connect((self._dns_server, DNS_PORT))
if self._debug:
print("* DNS: Sending request packet...")
self._sock.send(self._pkt_buf)
# wait and retry 3 times for a response
retries = 0
addr = -1
while (retries < 5) and (addr == -1):
addr = self._parse_dns_response()
if addr == -1 and self._debug:
print("* DNS ERROR: Failed to resolve DNS response, retrying...")
retries += 1
self._sock.close()
return addr
def _parse_dns_response(
self,
): # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements, too-many-locals
"""Receives and parses DNS query response.
Returns desired hostname address if obtained, -1 otherwise.
"""
# wait for a response
start_time = time.monotonic()
packet_sz = self._sock.available()
while packet_sz <= 0:
packet_sz = self._sock.available()
if (time.monotonic() - start_time) > 1.0:
if self._debug:
print("* DNS ERROR: Did not receive DNS response!")
return -1
time.sleep(0.05)
# recv packet into buf
self._pkt_buf = self._sock.recv()
if self._debug:
print("DNS Packet Received: ", self._pkt_buf)
# Validate request identifier
xid = int.from_bytes(self._pkt_buf[0:2], "l")
if not xid == self._request_id:
if self._debug:
print(
"* DNS ERROR: Received request identifer {} \
does not match expected {}".format(
xid, self._request_id
)
)
return -1
# Validate flags
flags = int.from_bytes(self._pkt_buf[2:4], "l")
if not flags in (0x8180, 0x8580):
if self._debug:
print("* DNS ERROR: Invalid flags, ", flags)
return -1
# Number of questions
qr_count = int.from_bytes(self._pkt_buf[4:6], "l")
if not qr_count >= 1:
if self._debug:
print("* DNS ERROR: Question count >=1, ", qr_count)
return -1
# Number of answers
an_count = int.from_bytes(self._pkt_buf[6:8], "l")
if self._debug:
print("* DNS Answer Count: ", an_count)
if not an_count >= 1:
return -1
# Parse query
ptr = 12
name_len = 1
while name_len > 0:
# read the length of the name
name_len = self._pkt_buf[ptr]
if name_len == 0x00:
# we reached the end of this name
ptr += 1 # inc. pointer by 0x00
break
# advance pointer
ptr += name_len + 1
# Validate Query is Type A
q_type = int.from_bytes(self._pkt_buf[ptr : ptr + 2], "l")
if not q_type == TYPE_A:
if self._debug:
print("* DNS ERROR: Incorrect Query Type: ", q_type)
return -1
ptr += 2
# Validate Query is Type A
q_class = int.from_bytes(self._pkt_buf[ptr : ptr + 2], "l")
if not q_class == TYPE_A:
if self._debug:
print("* DNS ERROR: Incorrect Query Class: ", q_class)
return -1
ptr += 2
# Let's take the first type-a answer
if self._pkt_buf[ptr] != 0xC0:
return -1
ptr += 1
if self._pkt_buf[ptr] != 0xC:
return -1
ptr += 1
# Validate Answer Type A
ans_type = int.from_bytes(self._pkt_buf[ptr : ptr + 2], "l")
if not ans_type == TYPE_A:
if self._debug:
print("* DNS ERROR: Incorrect Answer Type: ", ans_type)
return -1
ptr += 2
# Validate Answer Class IN
ans_class = int.from_bytes(self._pkt_buf[ptr : ptr + 2], "l")
if not ans_class == TYPE_A:
if self._debug:
print("* DNS ERROR: Incorrect Answer Class: ", ans_class)
return -1
ptr += 2
# skip over TTL
ptr += 4
# Validate addr is IPv4
data_len = int.from_bytes(self._pkt_buf[ptr : ptr + 2], "l")
if not data_len == DATA_LEN:
if self._debug:
print("* DNS ERROR: Unexpected Data Length: ", data_len)
return -1
ptr += 2
# Return address
return self._pkt_buf[ptr : ptr + 4]
def _build_dns_header(self):
"""Builds DNS header."""
# generate a random, 16-bit, request identifier
self._request_id = getrandbits(16)
# ID, 16-bit identifier
self._pkt_buf.append(self._request_id >> 8)
self._pkt_buf.append(self._request_id & 0xFF)
# Flags (0x0100)
self._pkt_buf.append(0x01)
self._pkt_buf.append(0x00)
# QDCOUNT
self._pkt_buf.append(0x00)
self._pkt_buf.append(0x01)
# ANCOUNT
self._pkt_buf.append(0x00)
self._pkt_buf.append(0x00)
# NSCOUNT
self._pkt_buf.append(0x00)
self._pkt_buf.append(0x00)
# ARCOUNT
self._pkt_buf.append(0x00)
self._pkt_buf.append(0x00)
def _build_dns_question(self):
"""Build DNS question"""
host = self._host.decode("utf-8")
host = host.split(".")
# write out each section of host
for i, _ in enumerate(host):
# append the sz of the section
self._pkt_buf.append(len(host[i]))
# append the section data
self._pkt_buf += host[i]
# end of the name
self._pkt_buf.append(0x00)
# Type A record
self._pkt_buf.append(htons(TYPE_A) & 0xFF)
self._pkt_buf.append(htons(TYPE_A) >> 8)
# Class IN
self._pkt_buf.append(htons(CLASS_IN) & 0xFF)
self._pkt_buf.append(htons(CLASS_IN) >> 8)
@@ -1,442 +0,0 @@
# SPDX-FileCopyrightText: 2019 ladyada for Adafruit Industries
# SPDX-FileCopyrightText: 2020 Brent Rubell for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
`adafruit_wiznet5k_socket`
================================================================================
A socket compatible interface with the Wiznet5k module.
* Author(s): ladyada, Brent Rubell, Patrick Van Oosterwijck, Adam Cummick
"""
import gc
import time
from micropython import const
import adafruit_wiznet5k as wiznet5k
_the_interface = None # pylint: disable=invalid-name
def set_interface(iface):
"""Helper to set the global internet interface."""
global _the_interface # pylint: disable=global-statement, invalid-name
_the_interface = iface
def htonl(x):
"""Convert 32-bit positive integers from host to network byte order."""
return (
((x) << 24 & 0xFF000000)
| ((x) << 8 & 0x00FF0000)
| ((x) >> 8 & 0x0000FF00)
| ((x) >> 24 & 0x000000FF)
)
def htons(x):
"""Convert 16-bit positive integers from host to network byte order."""
return (((x) << 8) & 0xFF00) | (((x) >> 8) & 0xFF)
SOCK_STREAM = const(0x21) # TCP
TCP_MODE = 80
SOCK_DGRAM = const(0x02) # UDP
AF_INET = const(3)
SOCKET_INVALID = const(255)
# pylint: disable=too-many-arguments, unused-argument
def getaddrinfo(host, port, family=0, socktype=0, proto=0, flags=0):
"""Translate the host/port argument into a sequence of 5-tuples that
contain all the necessary arguments for creating a socket connected to that service.
"""
if not isinstance(port, int):
raise RuntimeError("Port must be an integer")
if is_ipv4(host):
return [(AF_INET, socktype, proto, "", (host, port))]
return [(AF_INET, socktype, proto, "", (gethostbyname(host), port))]
def gethostbyname(hostname):
"""Translate a host name to IPv4 address format. The IPv4 address
is returned as a string.
:param str hostname: Desired hostname.
"""
addr = _the_interface.get_host_by_name(hostname)
addr = "{}.{}.{}.{}".format(addr[0], addr[1], addr[2], addr[3])
return addr
def is_ipv4(host):
"""Checks if a host string is an IPv4 address.
:param str host: host's name or ip
"""
octets = host.split(".", 3)
if len(octets) != 4 or not "".join(octets).isdigit():
return False
for octet in octets:
if int(octet) > 255:
return False
return True
# pylint: disable=invalid-name, too-many-public-methods
class socket:
"""A simplified implementation of the Python 'socket' class
for connecting to a Wiznet5k module.
:param int family: Socket address (and protocol) family.
:param int type: Socket type.
"""
# pylint: disable=redefined-builtin,unused-argument
def __init__(
self, family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None, socknum=None
):
if family != AF_INET:
raise RuntimeError("Only AF_INET family supported by W5K modules.")
self._sock_type = type
self._buffer = b""
self._timeout = 0
self._listen_port = None
self._socknum = _the_interface.get_socket()
if self._socknum == SOCKET_INVALID:
raise RuntimeError("Failed to allocate socket.")
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self._sock_type == SOCK_STREAM:
self.disconnect()
stamp = time.monotonic()
while self.status == wiznet5k.adafruit_wiznet5k.SNSR_SOCK_FIN_WAIT:
if time.monotonic() - stamp > 1000:
raise RuntimeError("Failed to disconnect socket")
self.close()
stamp = time.monotonic()
while self.status != wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSED:
if time.monotonic() - stamp > 1000:
raise RuntimeError("Failed to close socket")
@property
def socknum(self):
"""Returns the socket object's socket number."""
return self._socknum
@property
def status(self):
"""Returns the status of the socket"""
return _the_interface.socket_status(self.socknum)[0]
@property
def connected(self):
"""Returns whether or not we are connected to the socket."""
if self.socknum >= _the_interface.max_sockets:
return False
status = _the_interface.socket_status(self.socknum)[0]
if (
status == wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSE_WAIT
and self.available() == 0
):
result = False
else:
result = status not in (
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSED,
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_LISTEN,
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_TIME_WAIT,
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_FIN_WAIT,
)
if not result and status != wiznet5k.adafruit_wiznet5k.SNSR_SOCK_LISTEN:
self.close()
return result
def getpeername(self):
"""Return the remote address to which the socket is connected."""
return _the_interface.remote_ip(self.socknum)
def inet_aton(self, ip_string):
"""Convert an IPv4 address from dotted-quad string format.
:param str ip_string: IP Address, as a dotted-quad string.
"""
self._buffer = b""
self._buffer = [int(item) for item in ip_string.split(".")]
self._buffer = bytearray(self._buffer)
return self._buffer
def bind(self, address):
"""Bind the socket to the listen port, if host is specified the interface
will be reconfigured to that IP.
:param tuple address: local socket as a (host, port) tuple.
"""
if address[0] is not None:
ip_address = _the_interface.unpretty_ip(address[0])
current_ip, subnet_mask, gw_addr, dns = _the_interface.ifconfig
if ip_address != current_ip:
_the_interface.ifconfig = (ip_address, subnet_mask, gw_addr, dns)
self._listen_port = address[1]
# For UDP servers we need to open the socket here because we won't call
# listen
if self._sock_type == SOCK_DGRAM:
_the_interface.socket_listen(
self.socknum, self._listen_port, wiznet5k.adafruit_wiznet5k.SNMR_UDP
)
self._buffer = b""
def listen(self, backlog=None):
"""Listen on the port specified by bind.
:param backlog: For compatibility but ignored.
"""
assert self._listen_port is not None, "Use bind to set the port before listen!"
_the_interface.socket_listen(self.socknum, self._listen_port)
self._buffer = b""
def accept(self):
"""Accept a connection. The socket must be bound to an address and listening for
connections. The return value is a pair (conn, address) where conn is a new
socket object usable to send and receive data on the connection, and address is
the address bound to the socket on the other end of the connection.
"""
stamp = time.monotonic()
while self.status not in (
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_SYNRECV,
wiznet5k.adafruit_wiznet5k.SNSR_SOCK_ESTABLISHED,
):
if self._timeout > 0 and time.monotonic() - stamp > self._timeout:
return None
if self.status == wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSED:
self.close()
self.listen()
new_listen_socknum, addr = _the_interface.socket_accept(self.socknum)
current_socknum = self.socknum
# Create a new socket object and swap socket nums so we can continue listening
client_sock = socket()
client_sock._socknum = current_socknum # pylint: disable=protected-access
self._socknum = new_listen_socknum # pylint: disable=protected-access
self.bind((None, self._listen_port))
self.listen()
while self.status != wiznet5k.adafruit_wiznet5k.SNSR_SOCK_LISTEN:
raise RuntimeError("Failed to open new listening socket")
return client_sock, addr
def connect(self, address, conntype=None):
"""Connect to a remote socket at address.
:param tuple address: Remote socket as a (host, port) tuple.
"""
assert (
conntype != 0x03
), "Error: SSL/TLS is not currently supported by CircuitPython."
host, port = address
if hasattr(host, "split"):
try:
host = tuple(map(int, host.split(".")))
except ValueError:
host = _the_interface.get_host_by_name(host)
if self._listen_port is not None:
_the_interface.src_port = self._listen_port
result = _the_interface.socket_connect(
self.socknum, host, port, conn_mode=self._sock_type
)
_the_interface.src_port = 0
if not result:
raise RuntimeError("Failed to connect to host", host)
self._buffer = b""
def send(self, data):
"""Send data to the socket. The socket must be connected to
a remote socket.
:param bytearray data: Desired data to send to the socket.
"""
_the_interface.socket_write(self.socknum, data, self._timeout)
gc.collect()
def sendto(self, data, address):
"""Send data to the socket. The socket must be connected to
a remote socket.
:param bytearray data: Desired data to send to the socket.
:param tuple address: Remote socket as a (host, port) tuple.
"""
self.connect(address)
return self.send(data)
def recv(self, bufsize=0, flags=0): # pylint: disable=too-many-branches
"""Reads some bytes from the connected remote address.
:param int bufsize: Maximum number of bytes to receive.
:param int flags: ignored, present for compatibility.
"""
if self.status == wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSED:
return b""
if bufsize == 0:
# read everything on the socket
while True:
avail = self.available()
if avail:
if self._sock_type == SOCK_STREAM:
self._buffer += _the_interface.socket_read(self.socknum, avail)[
1
]
elif self._sock_type == SOCK_DGRAM:
self._buffer += _the_interface.read_udp(self.socknum, avail)[1]
else:
break
gc.collect()
ret = self._buffer
self._buffer = b""
gc.collect()
return ret
stamp = time.monotonic()
to_read = bufsize - len(self._buffer)
received = []
while to_read > 0:
avail = self.available()
if avail:
stamp = time.monotonic()
if self._sock_type == SOCK_STREAM:
recv = _the_interface.socket_read(
self.socknum, min(to_read, avail)
)[1]
elif self._sock_type == SOCK_DGRAM:
recv = _the_interface.read_udp(self.socknum, min(to_read, avail))[1]
recv = bytes(recv)
received.append(recv)
to_read -= len(recv)
gc.collect()
if self._timeout > 0 and time.monotonic() - stamp > self._timeout:
break
self._buffer += b"".join(received)
ret = None
if len(self._buffer) == bufsize:
ret = self._buffer
self._buffer = b""
else:
ret = self._buffer[:bufsize]
self._buffer = self._buffer[bufsize:]
gc.collect()
return ret
def embed_recv(self, bufsize=0, flags=0): # pylint: disable=too-many-branches
"""Reads some bytes from the connected remote address and then return recv().
:param int bufsize: Maximum number of bytes to receive.
:param int flags: ignored, present for compatibility.
"""
# print("Socket read", bufsize)
ret = None
avail = self.available()
if avail:
if self._sock_type == SOCK_STREAM:
self._buffer += _the_interface.socket_read(self.socknum, avail)[1]
elif self._sock_type == SOCK_DGRAM:
self._buffer += _the_interface.read_udp(self.socknum, avail)[1]
gc.collect()
ret = self._buffer
# print("RET ptr:", id(ret), id(self._buffer))
self._buffer = b""
gc.collect()
return ret
def recvfrom(self, bufsize=0, flags=0):
"""Reads some bytes from the connected remote address.
:param int bufsize: Maximum number of bytes to receive.
:param int flags: ignored, present for compatibility.
:returns: a tuple (bytes, address) where address is a tuple (ip, port)
"""
return (
self.recv(bufsize),
(
_the_interface.remote_ip(self.socknum),
_the_interface.remote_port(self.socknum),
),
)
def recv_into(self, buf, nbytes=0, flags=0):
"""Reads some bytes from the connected remote address info the provided buffer.
:param bytearray buf: Data buffer
:param nbytes: Maximum number of bytes to receive
:param int flags: ignored, present for compatibility.
:returns: the number of bytes received
"""
if nbytes == 0:
nbytes = len(buf)
ret = self.recv(nbytes)
nbytes = len(ret)
buf[:nbytes] = ret
return nbytes
def recvfrom_into(self, buf, nbytes=0, flags=0):
"""Reads some bytes from the connected remote address info the provided buffer.
:param bytearray buf: Data buffer
:param nbytes: Maximum number of bytes to receive
:param int flags: ignored, present for compatibility.
:returns a tuple (nbytes, address) where address is a tuple (ip, port)
"""
return (
self.recv_into(buf, nbytes),
(
_the_interface.remote_ip(self.socknum),
_the_interface.remote_port(self.socknum),
),
)
def readline(self):
"""Attempt to return as many bytes as we can up to \
but not including '\r\n'.
"""
stamp = time.monotonic()
while b"\r\n" not in self._buffer:
avail = self.available()
if avail:
if self._sock_type == SOCK_STREAM:
self._buffer += _the_interface.socket_read(self.socknum, avail)[1]
elif self._sock_type == SOCK_DGRAM:
self._buffer += _the_interface.read_udp(self.socknum, avail)[1]
if (
not avail
and self._timeout > 0
and time.monotonic() - stamp > self._timeout
):
self.close()
raise RuntimeError("Didn't receive response, failing out...")
firstline, self._buffer = self._buffer.split(b"\r\n", 1)
gc.collect()
return firstline
def disconnect(self):
"""Disconnects a TCP socket."""
assert self._sock_type == SOCK_STREAM, "Socket must be a TCP socket."
_the_interface.socket_disconnect(self.socknum)
def close(self):
"""Closes the socket."""
_the_interface.socket_close(self.socknum)
def available(self):
"""Returns how many bytes of data are available to be read from the socket."""
return _the_interface.socket_available(self.socknum, self._sock_type)
def settimeout(self, value):
"""Sets socket read timeout.
:param int value: Socket read timeout, in seconds.
"""
if value < 0:
raise Exception("Timeout period should be non-negative.")
self._timeout = value
def gettimeout(self):
"""Return the timeout in seconds (float) associated
with socket operations, or None if no timeout is set.
"""
return self._timeout
@@ -1,203 +0,0 @@
# Based on ESP32 code Copyright (c) 2019 Matt Costi for Adafruit Industries
# SPDX-FileCopyrightText: Copyright (c) 2020 Patrick Van Oosterwijck
#
# SPDX-License-Identifier: MIT
"""
`adafruit_wiznet5k_wsgiserver`
================================================================================
A simple WSGI (Web Server Gateway Interface) server that interfaces with the W5500.
Opens a listening port on the W5500 to listen for incoming HTTP Requests and
Accepts an Application object that must be callable, which gets called
whenever a new HTTP Request has been received.
The Application MUST accept 2 ordered parameters:
1. environ object (incoming request data)
2. start_response function. Must be called before the Application
callable returns, in order to set the response status and headers.
The Application MUST return strings in a list, which is the response data
Requires update_poll being called in the applications main event loop.
For more details about Python WSGI see:
https://www.python.org/dev/peps/pep-0333/
* Author(s): Matt Costi, Patrick Van Oosterwijck
"""
# pylint: disable=no-name-in-module
import io
import gc
from micropython import const
import adafruit_wiznet5k as wiznet5k
import adafruit_wiznet5k.adafruit_wiznet5k_socket as socket
_the_interface = None # pylint: disable=invalid-name
def set_interface(iface):
"""Helper to set the global internet interface"""
global _the_interface # pylint: disable=global-statement, invalid-name
_the_interface = iface
socket.set_interface(iface)
# # Maximum number of sockets for the web server (number of connections we can hold)
# MAX_SOCK_NUM = const(6)
# pylint: disable=invalid-name
class WSGIServer:
"""
A simple server that implements the WSGI interface
"""
def __init__(self, port=80, debug=False, application=None):
self.application = application
self.port = port
self._timeout = 20
self._client_sock = []
self._debug = debug
self._response_status = None
self._response_headers = []
if _the_interface.chip == "w5100s" :
self.MAX_SOCK_NUM = const(2)
print("MAX_SOCK_NUM is 2")
else:
self.MAX_SOCK_NUM = const(6)
print("MAX_SOCK_NUM is 6")
def start(self):
"""
Starts the server and begins listening for incoming connections.
Call update_poll in the main loop for the application callable to be
invoked on receiving an incoming request.
"""
for _ in range(self.MAX_SOCK_NUM):
new_sock = socket.socket()
new_sock.settimeout(self._timeout)
new_sock.bind((None, self.port))
new_sock.listen()
self._client_sock.append(new_sock)
if self._debug:
ip = _the_interface.pretty_ip(_the_interface.ip_address)
print("Server available at {0}:{1}".format(ip, self.port))
def update_poll(self):
"""
Call this method inside your main event loop to get the server
check for new incoming client requests. When a request comes in,
the application callable will be invoked.
"""
for sock in self._client_sock:
if sock.available():
environ = self._get_environ(sock)
result = self.application(environ, self._start_response)
self.finish_response(result, sock)
self._client_sock.remove(sock)
break
for sock in self._client_sock:
if sock.status == wiznet5k.adafruit_wiznet5k.SNSR_SOCK_CLOSED:
self._client_sock.remove(sock)
for _ in range(len(self._client_sock), self.MAX_SOCK_NUM):
try:
new_sock = socket.socket()
new_sock.settimeout(self._timeout)
new_sock.bind((None, self.port))
new_sock.listen()
self._client_sock.append(new_sock)
except RuntimeError:
pass
def finish_response(self, result, client):
"""
Called after the application callable returns result data to respond with.
Creates the HTTP Response payload from the response_headers and results data,
and sends it back to client.
:param string result: the data string to send back in the response to the client.
:param Socket client: the socket to send the response to.
"""
try:
response = "HTTP/1.1 {0}\r\n".format(self._response_status)
for header in self._response_headers:
response += "{0}: {1}\r\n".format(*header)
response += "\r\n"
client.send(response.encode("utf-8"))
for data in result:
if isinstance(data, bytes):
client.send(data)
else:
client.send(data.encode("utf-8"))
gc.collect()
finally:
client.disconnect()
client.close()
def _start_response(self, status, response_headers):
"""
The application callable will be given this method as the second param
This is to be called before the application callable returns, to signify
the response can be started with the given status and headers.
:param string status: a status string including the code and reason. ex: "200 OK"
:param list response_headers: a list of tuples to represent the headers.
ex ("header-name", "header value")
"""
self._response_status = status
self._response_headers = [("Server", "w5kWSGIServer")] + response_headers
def _get_environ(self, client):
"""
The application callable will be given the resulting environ dictionary.
It contains metadata about the incoming request and the request body ("wsgi.input")
:param Socket client: socket to read the request from
"""
env = {}
line = str(client.readline(), "utf-8")
(method, path, ver) = line.rstrip("\r\n").split(None, 2)
env["wsgi.version"] = (1, 0)
env["wsgi.url_scheme"] = "http"
env["wsgi.multithread"] = False
env["wsgi.multiprocess"] = False
env["wsgi.run_once"] = False
env["REQUEST_METHOD"] = method
env["SCRIPT_NAME"] = ""
env["SERVER_NAME"] = _the_interface.pretty_ip(_the_interface.ip_address)
env["SERVER_PROTOCOL"] = ver
env["SERVER_PORT"] = self.port
if path.find("?") >= 0:
env["PATH_INFO"] = path.split("?")[0]
env["QUERY_STRING"] = path.split("?")[1]
else:
env["PATH_INFO"] = path
headers = {}
while True:
header = str(client.readline(), "utf-8")
if header == "":
break
title, content = header.split(": ", 1)
headers[title.lower()] = content
if "content-type" in headers:
env["CONTENT_TYPE"] = headers.get("content-type")
if "content-length" in headers:
env["CONTENT_LENGTH"] = headers.get("content-length")
body = client.recv(int(env["CONTENT_LENGTH"]))
env["wsgi.input"] = io.StringIO(body)
else:
body = client.recv()
env["wsgi.input"] = io.StringIO(body)
for name, value in headers.items():
key = "HTTP_" + name.replace("-", "_").upper()
if key in env:
value = "{0},{1}".format(env[key], value)
env[key] = value
return env
-167
View File
@@ -1,167 +0,0 @@
# SPDX-FileCopyrightText: 2016 Damien P. George
# SPDX-FileCopyrightText: 2017 Scott Shawcroft for Adafruit Industries
# SPDX-FileCopyrightText: 2019 Carter Nelson
# SPDX-FileCopyrightText: 2019 Roy Hooper
#
# SPDX-License-Identifier: MIT
"""
`neopixel` - NeoPixel strip driver
====================================================
* Author(s): Damien P. George, Scott Shawcroft, Carter Nelson, Rose Hooper
"""
# pylint: disable=ungrouped-imports
import sys
import board
import digitalio
from neopixel_write import neopixel_write
try:
import adafruit_pixelbuf
except ImportError:
try:
import _pixelbuf as adafruit_pixelbuf
except ImportError:
import adafruit_pypixelbuf as adafruit_pixelbuf
__version__ = "0.0.0-auto.0"
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_NeoPixel.git"
# Pixel color order constants
RGB = "RGB"
"""Red Green Blue"""
GRB = "GRB"
"""Green Red Blue"""
RGBW = "RGBW"
"""Red Green Blue White"""
GRBW = "GRBW"
"""Green Red Blue White"""
class NeoPixel(adafruit_pixelbuf.PixelBuf):
"""
A sequence of neopixels.
:param ~microcontroller.Pin pin: The pin to output neopixel data on.
:param int n: The number of neopixels in the chain
:param int bpp: Bytes per pixel. 3 for RGB and 4 for RGBW pixels.
:param float brightness: Brightness of the pixels between 0.0 and 1.0 where 1.0 is full
brightness
:param bool auto_write: True if the neopixels should immediately change when set. If False,
`show` must be called explicitly.
:param str pixel_order: Set the pixel color channel order. GRBW is set by default.
Example for Circuit Playground Express:
.. code-block:: python
import neopixel
from board import *
RED = 0x100000 # (0x10, 0, 0) also works
pixels = neopixel.NeoPixel(NEOPIXEL, 10)
for i in range(len(pixels)):
pixels[i] = RED
Example for Circuit Playground Express setting every other pixel red using a slice:
.. code-block:: python
import neopixel
from board import *
import time
RED = 0x100000 # (0x10, 0, 0) also works
# Using ``with`` ensures pixels are cleared after we're done.
with neopixel.NeoPixel(NEOPIXEL, 10) as pixels:
pixels[::2] = [RED] * (len(pixels) // 2)
time.sleep(2)
.. py:method:: NeoPixel.show()
Shows the new colors on the pixels themselves if they haven't already
been autowritten.
The colors may or may not be showing after this function returns because
it may be done asynchronously.
.. py:method:: NeoPixel.fill(color)
Colors all pixels the given ***color***.
.. py:attribute:: brightness
Overall brightness of the pixel (0 to 1.0)
"""
def __init__(
self, pin, n, *, bpp=3, brightness=1.0, auto_write=True, pixel_order=None
):
if not pixel_order:
pixel_order = GRB if bpp == 3 else GRBW
elif isinstance(pixel_order, tuple):
order_list = [RGBW[order] for order in pixel_order]
pixel_order = "".join(order_list)
self._power = None
if (
sys.implementation.version[0] >= 7
and getattr(board, "NEOPIXEL", None) == pin
):
power = getattr(board, "NEOPIXEL_POWER_INVERTED", None)
polarity = power is None
if not power:
power = getattr(board, "NEOPIXEL_POWER", None)
if power:
try:
self._power = digitalio.DigitalInOut(power)
self._power.switch_to_output(value=polarity)
except ValueError:
pass
super().__init__(
n, brightness=brightness, byteorder=pixel_order, auto_write=auto_write
)
self.pin = digitalio.DigitalInOut(pin)
self.pin.direction = digitalio.Direction.OUTPUT
def deinit(self):
"""Blank out the NeoPixels and release the pin."""
self.fill(0)
self.show()
self.pin.deinit()
if self._power:
self._power.deinit()
def __enter__(self):
return self
def __exit__(self, exception_type, exception_value, traceback):
self.deinit()
def __repr__(self):
return "[" + ", ".join([str(x) for x in self]) + "]"
@property
def n(self):
"""
The number of neopixels in the chain (read-only)
"""
return len(self)
def write(self):
""".. deprecated: 1.0.0
Use ``show`` instead. It matches Micro:Bit and Arduino APIs."""
self.show()
def _transmit(self, buffer):
neopixel_write(self.pin, buffer)
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""Run LED matrix animations locally (SPI/PIO) or on Pico panel (UDP)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds import LedMatrix, LedStrip
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_BRIGHTNESS,
MATRIX_HEIGHT,
MATRIX_SPI_BUS,
MATRIX_SPI_DEVICE,
MATRIX_WIDTH,
)
from leds.config import spi_strip_config
from leds.panel_udp import (
add_panel_network_args,
animation_playlist,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
run_panel_playlist,
)
def _flash_test(surface, brightness: float) -> None:
for color in ((255, 0, 0), (0, 255, 0), (0, 0, 255)):
surface.fill(color)
surface.show()
time.sleep(0.35)
surface.fill((0, 0, 0))
surface.show()
def _run_local_playlist(surface, playlist: list[str], args: argparse.Namespace) -> None:
while True:
for name in playlist:
draw = ANIMATIONS[name]
fps = args.fps or DEFAULT_FPS.get(name, 30)
delay = 1.0 / fps
frames = max(int(args.duration * fps), 1)
print(f" {name} ({fps:.0f} fps)")
for frame in range(frames):
draw(surface, frame)
surface.show()
time.sleep(delay)
def main() -> int:
names = list(ANIMATIONS.keys())
parser = argparse.ArgumentParser(description="LED matrix animations")
parser.add_argument(
"animation",
nargs="?",
choices=names + ["all"],
default="all",
)
parser.add_argument("--width", type=int, default=MATRIX_WIDTH)
parser.add_argument("--height", type=int, default=MATRIX_HEIGHT)
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("--brightness", type=float, default=MATRIX_BRIGHTNESS)
parser.add_argument("--duration", type=float, default=12.0)
parser.add_argument("--fps", type=float, default=None)
parser.add_argument("--linear", action="store_true")
parser.add_argument(
"--serpentine",
action="store_true",
help="zigzag odd rows (default: all rows left → right)",
)
parser.add_argument("--no-test", action="store_true")
parser.add_argument(
"--panel",
action="store_true",
help="send to Pico panel(s) over UDP instead of local SPI/PIO",
)
add_panel_network_args(parser)
parser.add_argument("--once", action="store_true", help="panel: play playlist once")
args = parser.parse_args()
playlist = animation_playlist(args.animation)
if args.panel:
targets = panel_targets_from_args(args)
print(f"Panel animations -> {format_panel_targets(targets, args.port)}")
print(f"Playlist: {', '.join(playlist)}")
try:
if len(playlist) == 1 and args.animation != "all":
run_panel_animation_loop(
targets,
args.port,
playlist[0],
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
else:
run_panel_playlist(
targets,
args.port,
playlist,
duration=args.duration,
fps_override=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
count = args.width * args.height
serpentine = "rows" if args.serpentine else "none"
device = f"/dev/spidev{args.spi_bus}.{args.spi_device}"
mode = f"linear {count}" if args.linear else f"{args.width}×{args.height}"
print(f"Matrix {mode} ({count} LEDs) → {device}")
strip_cfg = spi_strip_config(
count,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
)
try:
if args.linear:
with LedStrip(
count,
backend=strip_cfg.backend,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
) as surface:
if not args.no_test:
_flash_test(surface, args.brightness)
_run_local_playlist(surface, playlist, args)
else:
with LedMatrix(
args.width,
args.height,
serpentine=serpentine,
config0=strip_cfg,
) as surface:
if not args.no_test:
_flash_test(surface, args.brightness)
_run_local_playlist(surface, playlist, args)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
print("Try: --spi-bus 1 or --panel --host <pico-ip>", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env python3
"""Ensure dtoverlay=spi1-3cs is under [all] for Pi 5 Model B."""
from __future__ import annotations
import sys
from pathlib import Path
CONFIG = Path("/boot/firmware/config.txt")
OVERLAY = "dtoverlay=spi1-3cs"
def main() -> int:
if not CONFIG.exists():
print(f"Error: {CONFIG} not found", file=sys.stderr)
return 1
lines = CONFIG.read_text().splitlines()
out: list[str] = []
in_cm5 = False
has_overlay = False
inserted = False
for line in lines:
stripped = line.strip()
if stripped == "[cm5]":
in_cm5 = True
elif stripped.startswith("[") and stripped.endswith("]"):
if in_cm5 and not inserted and not has_overlay:
out.append(OVERLAY)
inserted = True
in_cm5 = False
if stripped == "dtparam=spi1=on":
continue
if stripped == OVERLAY:
has_overlay = True
if in_cm5:
continue
out.append(line)
continue
out.append(line)
if not has_overlay and not inserted:
if out and out[-1].strip():
out.append("")
out.append(OVERLAY)
new_text = "\n".join(out) + "\n"
if new_text == CONFIG.read_text():
print("config.txt already correct.")
else:
CONFIG.write_text(new_text)
print("Updated /boot/firmware/config.txt:")
print(f" - removed dtparam=spi1=on (ignored on Pi 5)")
print(f" - moved {OVERLAY} out of [cm5] into [all]")
print("Reboot for the change to take effect on boot.")
print(
"\nWiring:\n"
" strip 0 DIN → GPIO 10 (SPI0 MOSI) /dev/spidev0.0\n"
" strip 1 DIN → GPIO 20 (SPI1 MOSI) /dev/spidev1.0"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env python3
"""Two strips showing different patterns at the same time."""
import argparse
import sys
import time
from leds import LedStrips, board_family, board_model, default_dual_strip_configs, pi5_setup_hint
def _describe(cfg) -> str:
if cfg.backend == "pio":
return f"GPIO {cfg.pin} → {cfg.device}"
if cfg.backend == "ws281x":
return f"GPIO {cfg.pin} (channel {cfg.channel})"
return f"/dev/spidev{cfg.spi_bus}.{cfg.spi_device}"
def _wheel(pos: int) -> tuple[int, int, int]:
pos = pos % 256
if pos < 85:
return (255 - pos * 3, pos * 3, 0)
if pos < 170:
pos -= 85
return (0, 255 - pos * 3, pos * 3)
pos -= 170
return (pos * 3, 0, 255 - pos * 3)
def chase(strip, color: tuple[int, int, int], step: int) -> None:
count = len(strip)
strip.fill((0, 0, 0))
strip[step % count] = color
def rainbow(strip, offset: int) -> None:
for i in range(len(strip)):
strip[i] = _wheel(offset + i * 256 // max(len(strip), 1))
def main() -> int:
parser = argparse.ArgumentParser(description="WS2812 dual-strip demo")
parser.add_argument(
"-n",
"--count",
type=int,
nargs=2,
default=[200, 200],
metavar=("STRIP0", "STRIP1"),
help="LED count per strip (default: 10 10)",
)
parser.add_argument(
"--brightness",
type=float,
default=0.25,
help="brightness 0.0-1.0 (default: 0.25)",
)
args = parser.parse_args()
print(f"Board: {board_model()}")
cfg0, cfg1 = default_dual_strip_configs(
args.count[0], args.count[1], brightness=args.brightness
)
print(f"Strip 0: {cfg0.backend} → {_describe(cfg0)} (red chase)")
print(f"Strip 1: {cfg1.backend} → {_describe(cfg1)} (rainbow)")
step = 0
try:
with LedStrips(args.count[0], args.count[1], brightness=args.brightness) as strips:
while True:
# strip 0: red dot chasing along the strip
chase(strips[0], (255, 0, 0), step)
# strip 1: rainbow scroll — completely independent
rainbow(strips[1], step * 4)
strips.show()
step += 1
time.sleep(0.05)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
if board_family() == "pi5":
print("\n" + pi5_setup_hint(args.count[0], args.count[1]), file=sys.stderr)
return 1
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
if board_family() == "pi5" and "No such file" in str(exc):
print("\n" + pi5_setup_hint(args.count[0], args.count[1]), file=sys.stderr)
return 1
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""Rainbow scroll on the LED matrix (local SPI or Pico panel)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds import ANIMATIONS, LedMatrix
from leds.array_config import MATRIX_BRIGHTNESS, MATRIX_HEIGHT, MATRIX_SPI_BUS, MATRIX_SPI_DEVICE, MATRIX_WIDTH
from leds.config import spi_strip_config
from leds.panel_udp import (
add_panel_network_args,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
)
def main() -> int:
parser = argparse.ArgumentParser(description="LED matrix rainbow demo")
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("--panel", action="store_true", help="send to Pico panel(s) over UDP")
add_panel_network_args(parser)
args = parser.parse_args()
print(f"Matrix: {MATRIX_WIDTH}×{MATRIX_HEIGHT} ({MATRIX_WIDTH * MATRIX_HEIGHT} LEDs)")
if args.panel:
targets = panel_targets_from_args(args)
print(f"Panel -> {format_panel_targets(targets, args.port)}")
try:
run_panel_animation_loop(
targets,
args.port,
"rainbow",
brightness=args.brightness,
panel_id=args.panel_id,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
draw = ANIMATIONS["rainbow"]
cfg = spi_strip_config(
MATRIX_WIDTH * MATRIX_HEIGHT,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
)
try:
with LedMatrix(config0=cfg) as matrix:
frame = 0
while True:
draw(matrix, frame)
matrix.show()
frame += 1
time.sleep(1 / 30)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
print("Try: --panel --host <pico-ip>", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
-9
View File
@@ -1,9 +0,0 @@
#!/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())
-67
View File
@@ -1,67 +0,0 @@
#!/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
View File
@@ -1,69 +0,0 @@
#!/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
View File
@@ -1,153 +0,0 @@
#!/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
View File
@@ -1,114 +0,0 @@
#!/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 -1
View File
@@ -90,7 +90,7 @@ deploy: build
@picotool version 2>&1 | grep -q 'without USB support' && \
(echo "Error: picotool was built without USB support" && exit 1) || true
@echo "==> Uploading $(UF2) over USB"
@picotool load -x -f "$(UF2)"
@picotool load -x "$(UF2)" -f
flash: deploy
upload: deploy
+1 -2
View File
@@ -1,7 +1,6 @@
# 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
@@ -60,7 +59,7 @@ make deploy PANEL_ID=0 WS2812_PIN=26 WS2812_PIN1=28
```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
pipenv run python test/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`).
-330
View File
@@ -1,330 +0,0 @@
"""Animations for LedMatrix and LedStrip."""
from __future__ import annotations
import math
import random
from typing import Callable, Union
from leds.colors import dim, heat_color, hsv_to_rgb, wheel
from leds.matrix import LedMatrix
from leds.strip import LedStrip
Surface = Union[LedMatrix, LedStrip]
DrawFn = Callable[[Surface, int], None]
def _is_matrix(surface: Surface) -> bool:
return hasattr(surface, "width") and hasattr(surface, "height")
def _size(surface: Surface) -> tuple[int, int]:
if _is_matrix(surface):
return surface.width, surface.height
n = len(surface)
return n, 1
def _set_index(surface: Surface, x: int, y: int, color: tuple[int, int, int]) -> None:
if isinstance(surface, LedStrip):
w, _ = _size(surface)
surface[y * w + x] = color
else:
surface[x, y] = color
def _each_pixel(surface: Surface):
if _is_matrix(surface):
w, h = surface.width, surface.height
for y in range(h):
for x in range(w):
yield x, y
else:
for i in range(len(surface)):
yield i, 0
def rainbow_scroll(surface: Surface, frame: int) -> None:
if isinstance(surface, LedStrip):
n = len(surface)
for i in range(n):
surface[i] = wheel(frame * 2 + i * 256 // max(n, 1))
return
w, h = surface.width, surface.height
strip = getattr(surface, "strip", None)
if strip is not None and hasattr(surface, "index_at"):
for y in range(h):
for x in range(w):
strip[surface.index_at(x, y)] = wheel(x * 3 + y * 8 + frame * 2)
else:
for y in range(h):
for x in range(w):
surface[x, y] = wheel(x * 3 + y * 8 + frame * 2)
def plasma(surface: Surface, frame: int) -> None:
w, h = _size(surface)
t = frame * 0.08
for y in range(h):
for x in range(w):
v = (
math.sin(x * 0.15 + t)
+ math.sin(y * 0.2 - t * 1.3)
+ math.sin((x + y) * 0.1 + t * 0.7)
) / 3.0
_set_index(surface, x, y, hsv_to_rgb((v + 1) / 2, 1.0, 1.0))
def sine_wave(surface: Surface, frame: int) -> None:
w, h = _size(surface)
for y in range(h):
for x in range(w):
wave = math.sin(x * 0.25 + frame * 0.15) * math.cos(y * 0.35 - frame * 0.1)
_set_index(surface, x, y, dim((0, 80, 200), (wave + 1) / 2))
def scanner(surface: Surface, frame: int) -> None:
w, h = _size(surface)
pos = frame % max(w * 2 - 2, 1)
if pos >= w:
pos = w * 2 - 2 - pos
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
for dx in range(-3, 4):
x = pos + dx
if 0 <= x < w:
_set_index(surface, x, y, dim((255, 0, 0), 1.0 - abs(dx) / 4.0))
def comet(surface: Surface, frame: int) -> None:
w, h = _size(surface)
head = frame % (w + 12)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
for i in range(12):
x = head - i
if 0 <= x < w:
_set_index(surface, x, y, dim(wheel(frame * 3 + i * 10), (12 - i) / 12))
def pulse(surface: Surface, frame: int) -> None:
level = (math.sin(frame * 0.12) + 1) / 2
color = dim((120, 0, 180), 0.15 + level * 0.85)
if _is_matrix(surface):
surface.fill(color)
else:
surface.fill(color)
def sparkle(surface: Surface, frame: int) -> None:
for x, y in _each_pixel(surface):
if _is_matrix(surface):
r, g, b = surface[x, y]
else:
r, g, b = surface[x]
c = (r >> 2, g >> 2, b >> 2)
_set_index(surface, x, y, c)
w, h = _size(surface)
for _ in range(6):
_set_index(surface, random.randint(0, w - 1), random.randint(0, h - 1), (220, 220, 255))
def fire(surface: Surface, frame: int) -> None:
w, h = _size(surface)
size = w * h
if not hasattr(fire, "_heat") or len(fire._heat) != size:
fire._heat = [0.0] * size
heat: list[float] = fire._heat
for x in range(w):
heat[(h - 1) * w + x] = random.random() * 0.5 + 0.5
for y in range(h - 2, -1, -1):
for x in range(w):
below = (y + 1) * w + x
left = below - 1 if x > 0 else below
right = below + 1 if x < w - 1 else below
decay = random.uniform(0.0, 0.18)
heat[y * w + x] = max(0.0, (heat[left] + heat[below] + heat[right]) / 3 - decay)
for y in range(h):
for x in range(w):
_set_index(surface, x, y, heat_color(heat[y * w + x]))
def rain(surface: Surface, frame: int) -> None:
w, h = _size(surface)
if not hasattr(rain, "_drops"):
rain._drops = [
{"x": random.randint(0, w - 1), "y": random.randint(-h, 0), "speed": random.randint(1, 3)}
for _ in range(max(w // 2, 1))
]
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for drop in rain._drops:
drop["y"] += drop["speed"]
if drop["y"] >= h:
drop["y"] = random.randint(-3, -1)
drop["x"] = random.randint(0, w - 1)
drop["speed"] = random.randint(1, 3)
x, y = drop["x"], int(drop["y"])
if 0 <= y < h:
_set_index(surface, x, y, (0, 220, 80))
if y > 0:
_set_index(surface, x, y - 1, dim((0, 220, 80), 0.4))
def stripes(surface: Surface, frame: int) -> None:
w, h = _size(surface)
offset = frame % max(w, 1)
for y in range(h):
for x in range(w):
band = (x + offset) // 3 % 3
if band == 0:
_set_index(surface, x, y, (200, 0, 0))
elif band == 1:
_set_index(surface, x, y, (0, 200, 0))
else:
_set_index(surface, x, y, (0, 0, 200))
def bounce(surface: Surface, frame: int) -> None:
w, h = _size(surface)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
t = frame * 0.2
x = int((math.sin(t) + 1) / 2 * (w - 1))
y = int((math.cos(t * 1.3) + 1) / 2 * (h - 1))
for dy in range(-1, 2):
for dx in range(-1, 2):
px, py = x + dx, y + dy
if 0 <= px < w and 0 <= py < h:
_set_index(surface, px, py, wheel(frame * 5))
def _iter_line(x0: int, y0: int, x1: int, y1: int):
"""Yield pixel coordinates along a Bresenham line."""
dx = abs(x1 - x0)
dy = -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
x, y = x0, y0
while True:
yield x, y
if x == x1 and y == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
def rolling(surface: Surface, frame: int) -> None:
"""Full-height band rolling left to right across the panel."""
w, h = _size(surface)
tail = 10
head = frame % (w + tail)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
hue_shift = y * 18 + frame * 3
for i in range(tail):
x = head - i
if 0 <= x < w:
bright = (tail - i) / tail
_set_index(surface, x, y, dim(wheel(hue_shift + i * 8), bright))
def spin_line(surface: Surface, frame: int) -> None:
"""Line from panel center spinning like a radar sweep."""
w, h = _size(surface)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
if not _is_matrix(surface):
n = len(surface)
pos = int((frame * 3) % max(n, 1))
for i in range(8):
idx = (pos - i) % n
surface[idx] = dim(wheel(frame * 5 + i * 20), (8 - i) / 8)
return
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
angle = frame * 0.14
reach = max(w, h) * 1.2
x1 = int(cx + math.cos(angle) * reach)
y1 = int(cy + math.sin(angle) * reach)
color = wheel(frame * 4)
for x, y in _iter_line(int(cx), int(cy), x1, y1):
if 0 <= x < w and 0 <= y < h:
_set_index(surface, x, y, color)
hub_x, hub_y = int(cx), int(cy)
if 0 <= hub_x < w and 0 <= hub_y < h:
_set_index(surface, hub_x, hub_y, (255, 255, 255))
def solid(surface: Surface, frame: int) -> None:
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
if _is_matrix(surface):
surface.fill(colors[frame % 3])
else:
surface.fill(colors[frame % 3])
ANIMATIONS: dict[str, DrawFn] = {
"solid": solid,
"rainbow": rainbow_scroll,
"plasma": plasma,
"wave": sine_wave,
"scanner": scanner,
"comet": comet,
"pulse": pulse,
"sparkle": sparkle,
"fire": fire,
"rain": rain,
"stripes": stripes,
"bounce": bounce,
"rolling": rolling,
"spin_line": spin_line,
}
DEFAULT_FPS: dict[str, float] = {
"solid": 1,
"rainbow": 30,
"plasma": 25,
"wave": 25,
"scanner": 35,
"comet": 30,
"pulse": 30,
"sparkle": 40,
"fire": 35,
"rain": 30,
"stripes": 25,
"bounce": 35,
"rolling": 30,
"spin_line": 28,
}
-145
View File
@@ -1,145 +0,0 @@
"""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()
+12 -5
View File
@@ -8,9 +8,8 @@ version = "0.1.0"
description = "WS2812 LED control for Raspberry Pi"
requires-python = ">=3.9"
dependencies = [
"spidev>=3.6",
"rpi-ws281x>=5.0.0",
"rpi5-ws2812>=0.1.2",
"numpy>=1.26,<2",
"sounddevice>=0.5",
]
[project.optional-dependencies]
@@ -18,7 +17,15 @@ sim = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
]
audio = [
"aubio>=0.4.9",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["leds*"]
where = ["src"]
include = ["leds*", "patterns*"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["test"]
python_files = ["test_*.py"]
+15
View File
@@ -0,0 +1,15 @@
"""Legacy animation registry — backed by `patterns/` plugins."""
from __future__ import annotations
from typing import Callable, Union
from leds.matrix import LedMatrix
from leds.pattern import _AnimationMap, _FpsMap
from leds.strip import LedStrip
Surface = Union[LedMatrix, LedStrip]
DrawFn = Callable[[Surface, int], None]
ANIMATIONS: _AnimationMap = _AnimationMap()
DEFAULT_FPS: _FpsMap = _FpsMap()
@@ -58,7 +58,7 @@ 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.
# Optional: Pi UDP→SPI bridge panels. Empty = all Pico.
PI_BRIDGE_PANEL_INDICES: frozenset[int] = frozenset()
# Optional: two panels on one Pico (GP27 + GP28).
+537
View File
@@ -0,0 +1,537 @@
"""Microphone beat detection — capture loop runs in ``asyncio.to_thread``."""
from __future__ import annotations
import argparse
import asyncio
import queue
import threading
import time
from typing import Any, Awaitable, Callable, Optional
from leds.bpm_limits import clamp_bpm, clamp_bpm_optional, max_beat_min_ioi_ms
NotifyFn = Callable[[dict[str, Any]], Awaitable[None] | None]
BeatFn = Callable[[dict[str, Any]], None]
_SILENCE_GAP_S = 4.0
_HOLDOVER_MAX_S = 300.0
class BeatDetector:
def __init__(
self,
*,
on_beat: BeatFn | None = None,
notify: NotifyFn | None = None,
) -> None:
self._lock = threading.Lock()
self._stop = threading.Event()
self._task: asyncio.Task | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ready = asyncio.Event()
self._stream = None
self._runtime = None
self._pending_reset = False
self._last_real_beat_ts: float | None = None
self._holdover_started: float | None = None
self._on_beat = on_beat
self._notify = notify
self._status: dict[str, Any] = {
"running": False,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": None,
"input_level": 0.0,
"mode": "aubio",
"kick": 0.0,
"snare": 0.0,
"hat": 0.0,
"bass": 0.0,
"energy": 0.0,
"flux": 0.0,
}
def list_input_devices(self) -> list[dict[str, Any]]:
try:
from leds.pulse_audio import list_pulse_matched_input_devices
pulse = list_pulse_matched_input_devices()
if pulse:
return pulse
except Exception as exc:
print(f"[audio] pulse device list skipped: {exc!r}")
return self._list_sounddevice_input_devices()
@staticmethod
def _skip_sounddevice_virtual(name: str, hostapi_name: str) -> bool:
n = name.strip().lower()
if n in ("pipewire", "pulse", "default", "sysdefault"):
return True
ha = hostapi_name.strip().lower()
if ha in ("pulse", "pipewire") and n in ("default", "pipewire", "pulse"):
return True
return False
def _list_sounddevice_input_devices(self) -> list[dict[str, Any]]:
try:
import sounddevice as sd
except ImportError:
return []
devices = sd.query_devices()
hostapis = sd.query_hostapis()
try:
default_input_idx = int(sd.default.device[0])
except Exception:
default_input_idx = None
out: list[dict[str, Any]] = []
for idx, dev in enumerate(devices):
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
if self._skip_sounddevice_virtual(name, hostapi_name):
continue
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
is_default = default_input_idx is not None and idx == default_input_idx
display_name = f"{name} (default)" if is_default else name
label = f"[{idx}] {name} ({chans}ch @ {sr}Hz, {hostapi_name})"
out.append(
{
"id": idx,
"name": name,
"display_name": display_name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
"is_default": is_default,
"hostapi": hostapi_name,
}
)
return out
def status(self) -> dict[str, Any]:
with self._lock:
st = dict(self._status)
last_real = self._last_real_beat_ts
last = st.get("last_beat_ts")
if st.get("running") and last is not None and last_real is not None:
try:
if (time.time() - float(last_real)) > _SILENCE_GAP_S and st.get(
"beat_type"
) != "holdover":
if (time.time() - float(last)) > 4.0:
st["bpm"] = None
except (TypeError, ValueError):
pass
if st.get("bpm") is not None:
st["bpm"] = clamp_bpm_optional(st["bpm"])
return st
async def start(self, device: Any = None) -> dict[str, Any]:
await self.stop()
self._loop = asyncio.get_running_loop()
self._stop.clear()
self._ready = asyncio.Event()
with self._lock:
self._last_real_beat_ts = None
self._holdover_started = None
self._status.update(
{
"running": True,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": device,
"input_level": 0.0,
"kick": 0.0,
"snare": 0.0,
"hat": 0.0,
"bass": 0.0,
"energy": 0.0,
"flux": 0.0,
}
)
self._task = asyncio.create_task(self._run_in_thread(device), name="beat-detect")
try:
await asyncio.wait_for(self._ready.wait(), timeout=8.0)
except asyncio.TimeoutError:
await self.stop()
raise RuntimeError("audio start timed out")
status = self.status()
if status.get("error"):
await self.stop()
raise RuntimeError(str(status["error"]))
await self._emit_status()
return status
async def stop(self) -> dict[str, Any]:
self._stop.set()
stream = None
with self._lock:
stream = self._stream
if stream is not None:
for method in ("abort", "stop", "close"):
try:
getattr(stream, method)()
except Exception:
pass
task = self._task
self._task = None
if task is not None:
try:
await asyncio.wait_for(asyncio.shield(task), timeout=4.0)
except (asyncio.TimeoutError, asyncio.CancelledError):
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
with self._lock:
self._stream = None
self._runtime = None
self._status["running"] = False
self._status["input_level"] = 0.0
await self._emit_status()
return self.status()
def reset_tracking(self) -> bool:
with self._lock:
if self._runtime is None or not self._status.get("running"):
return False
self._pending_reset = True
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
self._status.update(
{
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"is_downbeat": True,
"phase_confidence": 0.0,
"bar_phase_readout": f"1/{bpb}",
}
)
return True
async def _run_in_thread(self, device: Any) -> None:
try:
await asyncio.to_thread(self._run_loop, device)
except Exception as exc:
self._set_error(f"detector failed: {exc}")
finally:
with self._lock:
self._status["running"] = False
self._runtime = None
self._stream = None
await self._emit_status()
def _signal_ready(self) -> None:
loop = self._loop
if loop is None:
return
try:
loop.call_soon_threadsafe(self._ready.set)
except RuntimeError:
pass
def _update_input_level(self, mono) -> None:
import numpy as np
arr = np.asarray(mono, dtype=np.float32)
if arr.size == 0:
inst = 0.0
else:
peak = float(np.max(np.abs(arr)))
rms = float(np.sqrt(np.mean(arr * arr)))
inst = min(1.0, max(peak, rms * 2.0))
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
if inst >= prev:
self._status["input_level"] = inst
else:
self._status["input_level"] = max(inst, prev * 0.82)
def _decay_input_level(self) -> None:
with self._lock:
prev = float(self._status.get("input_level") or 0.0)
self._status["input_level"] = prev * 0.82
def _publish_beat(self, payload: dict[str, Any]) -> None:
loop = self._loop
if loop is None:
return
try:
loop.call_soon_threadsafe(self._on_loop_beat, payload)
except RuntimeError:
pass
def _on_loop_beat(self, payload: dict[str, Any]) -> None:
if self._on_beat is not None:
try:
self._on_beat(payload)
except Exception as exc:
print(f"[audio] on_beat: {exc}")
if self._notify is not None:
asyncio.create_task(self._safe_notify({"type": "audio", "status": payload}))
async def _emit_status(self) -> None:
if self._notify is None:
return
await self._safe_notify({"type": "audio", "status": self.status()})
async def _safe_notify(self, payload: dict[str, Any]) -> None:
if self._notify is None:
return
try:
result = self._notify(payload)
if asyncio.iscoroutine(result) or isinstance(result, Awaitable):
await result
except Exception as exc:
print(f"[audio] notify: {exc}")
def _advance_bar_locked(self) -> None:
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
prev = int(self._status.get("bar_beat") or 1)
bar_beat = (prev % bpb) + 1
self._status["bar_beat"] = bar_beat
self._status["is_downbeat"] = bar_beat == 1
self._status["bar_phase_readout"] = f"{bar_beat}/{bpb}"
def _update_fft(self, analysis: dict[str, Any] | None) -> None:
if not analysis:
return
with self._lock:
self._status["kick"] = float(analysis.get("kick") or 0.0)
self._status["snare"] = float(analysis.get("snare") or 0.0)
self._status["hat"] = float(analysis.get("hat") or 0.0)
self._status["bass"] = float(analysis.get("bass") or 0.0)
self._status["energy"] = float(analysis.get("energy") or 0.0)
self._status["flux"] = float(analysis.get("flux") or 0.0)
def _record_beat(self, event: dict[str, Any], *, holdover: bool = False) -> None:
now = time.time()
bpm = clamp_bpm_optional(event.get("bpm"))
with self._lock:
if not holdover:
self._last_real_beat_ts = now
self._holdover_started = None
if bpm is None:
bpm = clamp_bpm_optional(self._status.get("bpm"))
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = "holdover" if holdover else event.get("beat_type", "unknown")
self._status["beat_type_confidence"] = float(event.get("beat_type_confidence") or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
if event.get("strength") is not None:
self._status["strength"] = float(event["strength"])
for key in ("kick", "snare", "hat", "bass", "energy", "flux"):
if event.get(key) is not None:
self._status[key] = float(event[key])
if event.get("bar_beat") is not None:
self._status["bar_beat"] = int(event["bar_beat"])
if event.get("beats_per_bar") is not None:
self._status["beats_per_bar"] = int(event["beats_per_bar"])
if event.get("is_downbeat") is not None:
self._status["is_downbeat"] = bool(event["is_downbeat"])
if event.get("phase_confidence") is not None:
self._status["phase_confidence"] = float(event["phase_confidence"])
if event.get("bar_phase_readout"):
self._status["bar_phase_readout"] = str(event["bar_phase_readout"])
snapshot = dict(self._status)
self._publish_beat(snapshot)
def _maybe_holdover(self) -> None:
now = time.time()
with self._lock:
running = bool(self._status.get("running"))
last_real = self._last_real_beat_ts
bpm = clamp_bpm_optional(self._status.get("bpm"))
holdover_started = self._holdover_started
last_beat = self._status.get("last_beat_ts")
if not running or last_real is None:
return
if bpm is None:
bpm = clamp_bpm(120)
try:
gap = now - float(last_real)
except (TypeError, ValueError):
return
if gap < _SILENCE_GAP_S:
return
if holdover_started is None:
with self._lock:
self._holdover_started = now
holdover_started = now
if (now - float(holdover_started)) > _HOLDOVER_MAX_S:
return
interval = 60.0 / float(bpm)
try:
due = last_beat is None or (now - float(last_beat)) >= interval * 0.95
except (TypeError, ValueError):
due = True
if not due:
return
with self._lock:
self._advance_bar_locked()
event = {
"bpm": bpm,
"beat_type": "holdover",
"bar_beat": self._status["bar_beat"],
"beats_per_bar": self._status["beats_per_bar"],
"is_downbeat": self._status["is_downbeat"],
"bar_phase_readout": self._status["bar_phase_readout"],
}
self._record_beat(event, holdover=True)
def _set_error(self, msg: str) -> None:
print(f"[audio] {msg}")
with self._lock:
self._status["error"] = msg
self._status["running"] = False
self._signal_ready()
def _run_loop(self, device: Any) -> None:
try:
import numpy as np
import sounddevice as sd
except Exception as exc:
self._set_error(f"audio deps unavailable: {exc}")
return
try:
from leds.pulse_audio import resolve_capture_device
device = resolve_capture_device(device)
except Exception as exc:
self._set_error(str(exc))
return
if device is None:
try:
device = int(sd.default.device[0])
except Exception:
device = -1
if device is None or device < 0:
self._set_error("no default input device; pick an input, then Start")
return
if not isinstance(device, int):
self._set_error(f"unresolved capture device {device!r}")
return
try:
from leds.beat_detect import BeatDetectRuntime
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
args = argparse.Namespace(
mode="aubio",
device=device,
sample_rate=sample_rate,
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=max_beat_min_ioi_ms(),
bpm_window=8,
aubio_method="default",
aubio_threshold=0.14,
beats_per_bar=4,
)
runtime = BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
with self._lock:
self._runtime = runtime
self._status["mode"] = args.mode
self._status["device"] = device
hop_size = runtime.frame_size
audio_q: queue.Queue = queue.Queue(maxsize=64)
def callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"[audio] status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
stream = sd.InputStream(
device=device,
channels=1,
samplerate=sample_rate,
blocksize=hop_size,
callback=callback,
)
with self._lock:
self._stream = stream
stream.start()
self._signal_ready()
try:
while not self._stop.is_set():
with self._lock:
pending = self._pending_reset
if pending:
self._pending_reset = False
if pending:
runtime.reset_state()
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
self._decay_input_level()
self._maybe_holdover()
continue
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
from leds import settings as settings_store
gain = float(settings_store.get_audio_gain())
if gain != 1.0:
frame = frame * np.float32(gain)
self._update_input_level(frame)
event = runtime.process_frame(frame, now_s=time.time())
self._update_fft(getattr(runtime, "last_analysis", None))
if event is None:
self._maybe_holdover()
continue
self._record_beat(event)
finally:
for method in ("stop", "close"):
try:
getattr(stream, method)()
except Exception:
pass
with self._lock:
if self._stream is stream:
self._stream = None
except Exception as exc:
self._set_error(f"detector failed: {exc}")
@@ -13,7 +13,7 @@ class PioBackend:
if not self._path.exists():
raise FileNotFoundError(
f"{device} not found — enable ws2812-pio in /boot/firmware/config.txt "
"and reboot. Run: pipenv run python examples/setup_pi5_leds.py"
"and reboot. Run: pipenv run python test/setup_pi5_leds.py"
)
@property
@@ -2,11 +2,18 @@
from __future__ import annotations
import numpy as np
from rpi5_ws2812.ws2812 import WS2812SpiDriver
from leds.backends.spi import spidev_bufsize_hint
try:
import numpy as np
from rpi5_ws2812.ws2812 import WS2812SpiDriver
except ImportError as exc: # pragma: no cover - import guard
np = None
WS2812SpiDriver = None
_RPI5_IMPORT_ERROR = exc
else:
_RPI5_IMPORT_ERROR = None
class Rpi5Ws2812Backend:
def __init__(
@@ -17,6 +24,11 @@ class Rpi5Ws2812Backend:
spi_device: int = 0,
max_speed_hz: int = 4_200_000,
) -> None:
if WS2812SpiDriver is None:
raise ImportError(
"rpi5-ws2812 is required for the rpi5_ws2812 backend"
) from _RPI5_IMPORT_ERROR
hint = spidev_bufsize_hint(led_count)
if hint:
raise OSError(hint)
@@ -50,7 +50,7 @@ def spidev_bufsize_hint(led_count: int) -> str:
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"
f" echo 'options spidev bufsiz={suggested}' | sudo tee /etc/modprobe.d/spidev-bufsiz.conf"
)
@@ -72,7 +72,7 @@ class SpiBackend:
) -> None:
if SpiDev is None:
raise ImportError(
"spidev is required for the SPI backend; install portal requirements"
"spidev is required for the SPI backend"
) from _SPI_IMPORT_ERROR
self._led_count = led_count
@@ -25,7 +25,7 @@ class Ws281xBackend:
) -> None:
if PixelStrip is None:
raise ImportError(
"rpi-ws281x is required for the ws281x backend; install portal requirements"
"rpi-ws281x is required for the ws281x backend"
) from _WS281X_IMPORT_ERROR
self._led_count = led_count
+416
View File
@@ -0,0 +1,416 @@
"""Live beat detection runtime (aubio + custom energy), from LED controller."""
from __future__ import annotations
import collections
import sys
import time
from typing import Deque
from leds.bpm_limits import clamp_bpm_optional, max_beat_interval_s, min_beat_interval_s
try:
import numpy as np
except ImportError as exc: # pragma: no cover
raise ImportError("numpy is required for beat detection") from exc
def _estimate_bpm(beat_times: Deque[float]) -> float | None:
if len(beat_times) < 3:
return None
ioi_min = min_beat_interval_s()
ioi_max = max_beat_interval_s()
intervals = np.diff(np.array(beat_times, dtype=np.float64))
valid = intervals[(intervals >= ioi_min) & (intervals <= ioi_max)]
if valid.size == 0:
return None
return clamp_bpm_optional(60.0 / float(np.median(valid)))
def _is_plausible_ioi(
last_trigger_s: float,
beat_times: Deque[float],
now_s: float,
*,
min_ratio: float = 0.42,
max_ratio: float = 3.5,
) -> bool:
if last_trigger_s <= 0 or len(beat_times) < 2:
return True
ioi = now_s - last_trigger_s
if ioi <= 0:
return False
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
if intervals.size == 0:
return True
med = float(np.median(intervals))
if med < 0.05:
return True
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
class BarPhaseTracker:
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
self.beats_per_bar = max(1, int(beats_per_bar))
self.kick_conf_min = float(kick_conf_min)
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def reset(self) -> None:
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def anchor_downbeat(self, now_s: float) -> None:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self.confidence = max(self.confidence, 0.85)
def _bar_duration_s(self, bpm: float | None, median_ioi: float | None) -> float | None:
if bpm is not None and bpm > 0:
return (60.0 / float(bpm)) * self.beats_per_bar
if median_ioi is not None and median_ioi > 0:
return float(median_ioi) * self.beats_per_bar
return None
@staticmethod
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
if bar_dur <= 0 or elapsed <= 0:
return False
n = elapsed / bar_dur
nearest = max(1, round(n))
return abs(n - nearest) <= tol
def on_beat(
self,
now_s: float,
beat_type: str,
beat_type_conf: float,
*,
bpm: float | None = None,
median_ioi: float | None = None,
) -> dict[str, int | float | bool | str]:
self._total_beats += 1
bar_dur = self._bar_duration_s(bpm, median_ioi)
is_kick = (
str(beat_type or "").lower() == "kick"
and float(beat_type_conf or 0.0) >= self.kick_conf_min
)
downbeat_locked = False
if is_kick:
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
downbeat_locked = True
elif bar_dur and self._near_whole_bars(now_s - self._last_downbeat_s, bar_dur):
downbeat_locked = True
elif self.bar_beat >= max(2, self.beats_per_bar - 1):
downbeat_locked = True
prev_bar_beat = int(self.bar_beat)
if downbeat_locked:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self._aligned_kicks += 1
elif self._total_beats <= 1:
self.bar_beat = 1
self.is_downbeat = True
else:
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
self.is_downbeat = self.bar_beat == 1
if self._total_beats >= self.beats_per_bar:
bars_seen = max(1, self._total_beats // self.beats_per_bar)
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
return {
"bar_beat": int(self.bar_beat),
"beats_per_bar": int(self.beats_per_bar),
"is_downbeat": bool(self.is_downbeat),
"phase_confidence": round(float(self.confidence), 3),
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
}
def _resolve_bpm(beat_times: Deque[float], aubio_bpm: float | None) -> float | None:
estimated = _estimate_bpm(beat_times)
if estimated is None:
return clamp_bpm_optional(aubio_bpm)
if aubio_bpm is None or aubio_bpm <= 0:
return estimated
ratio = float(aubio_bpm) / estimated
if ratio > 1.75 or ratio < 0.57:
return estimated
return estimated
def _purge_aubio_modules() -> None:
for name in list(sys.modules):
if name == "aubio" or name.startswith("aubio."):
del sys.modules[name]
def _probe_aubio(aubio_mod) -> None:
"""Raise if aubio cannot process float32 frames with the active numpy."""
tempo = aubio_mod.tempo("default", 1024, 512, 44100)
buf = np.zeros(512, dtype=np.float32)
tempo(buf)
def load_aubio():
"""Import aubio (required). Distro builds need NumPy 1.x (``numpy>=1.26,<2``)."""
import contextlib
import io
errors: list[str] = []
def _try_import() -> object:
sink = io.StringIO()
with contextlib.redirect_stderr(sink):
import aubio # noqa: PLC0415
stderr = sink.getvalue().strip()
try:
_probe_aubio(aubio)
except Exception as exc:
_purge_aubio_modules()
detail = f"{exc}"
if stderr:
detail = f"{detail}; {stderr}"
raise RuntimeError(detail) from exc
return aubio
try:
return _try_import()
except Exception as exc:
_purge_aubio_modules()
errors.append(str(exc))
dist_packages = "/usr/lib/python3/dist-packages"
if dist_packages not in sys.path:
sys.path.append(dist_packages)
try:
return _try_import()
except Exception as exc:
_purge_aubio_modules()
errors.append(str(exc))
raise RuntimeError(
"aubio is required for beat detection but failed to load "
f"({' | '.join(errors)}). Install python3-aubio and keep numpy<2."
)
class BeatDetectRuntime:
def __init__(self, args):
self.args = args
args.mode = "aubio"
self.aubio = load_aubio()
self.sample_rate = 0
self.frame_size = 0
self.tempo = None
self.band_mask = None
self.freqs = None
self.window = None
self.prev_mag = None
self.kick_mask = None
self.snare_mask = None
self.hat_mask = None
self.baseline = 1e-6
self.beat_times: Deque[float] = collections.deque(maxlen=max(2, args.bpm_window + 1))
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
self.last_analysis: dict | None = None
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
self.frame_size = max(128, int(self.args.hop_size))
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
freqs = np.fft.rfftfreq(self.frame_size, d=1.0 / self.sample_rate)
self.freqs = freqs
self.band_mask = (freqs >= self.args.min_band_hz) & (freqs <= self.args.max_band_hz)
self.kick_mask = (freqs >= 40.0) & (freqs <= 140.0)
self.snare_mask = (freqs >= 140.0) & (freqs <= 3000.0)
self.hat_mask = (freqs >= 5000.0) & (freqs <= 12000.0)
if not np.any(self.band_mask):
raise ValueError("Invalid band range for current sample rate")
self.window = np.hanning(self.frame_size).astype(np.float32)
self.prev_mag = np.zeros(freqs.shape[0], dtype=np.float32)
self.baseline = 1e-6
self.last_trigger_s = 0.0
self.beat_times.clear()
self.tempo = None
self._init_aubio_tempo(win_size)
def _init_aubio_tempo(self, win_size: int):
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
if hasattr(self.tempo, "set_threshold"):
self.tempo.set_threshold(float(self.args.aubio_threshold))
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def reset_tempo_state(self) -> None:
self.baseline = 1e-6
if self.prev_mag is not None:
self.prev_mag[:] = 0.0
self.beat_times.clear()
self.last_trigger_s = 0.0
if self.sample_rate > 0:
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
self._init_aubio_tempo(win_size)
def reset_state(self):
self.reset_tempo_state()
self.bar_phase.reset()
def anchor_bar_phase(self, now_s: float | None = None) -> None:
if now_s is None:
now_s = time.time()
self.bar_phase.anchor_downbeat(now_s)
def _classify_hit(self, mag):
total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
snare = float(np.mean(mag[self.snare_mask])) / total if np.any(self.snare_mask) else 0.0
hat = float(np.mean(mag[self.hat_mask])) / total if np.any(self.hat_mask) else 0.0
scores = {"kick": kick, "snare": snare, "hat": hat}
label, value = max(scores.items(), key=lambda kv: kv[1])
if value < 1.15:
return "unknown", value
return label, value
def _band_levels(self, mag) -> dict[str, float]:
def mean_mask(mask) -> float:
if mask is None or not np.any(mask):
return 0.0
return float(np.mean(mag[mask]))
kick = mean_mask(self.kick_mask)
snare = mean_mask(self.snare_mask)
hat = mean_mask(self.hat_mask)
bass = mean_mask(self.band_mask)
peak = max(kick, snare, hat, bass, 1e-9)
return {
"kick": min(1.0, kick / peak),
"snare": min(1.0, snare / peak),
"hat": min(1.0, hat / peak),
"bass": min(1.0, bass / peak),
"kick_raw": kick,
"snare_raw": snare,
"hat_raw": hat,
"bass_raw": bass,
}
def analyze_frame(self, frame, now_s: float | None = None) -> dict:
"""Always-on FFT analysis for the current hop (also updates tempo state)."""
if self.window is None or self.band_mask is None:
raise RuntimeError("Runtime not setup")
if frame.shape[0] != self.frame_size:
if frame.shape[0] > self.frame_size:
frame = frame[: self.frame_size]
else:
frame = np.pad(frame, (0, self.frame_size - frame.shape[0]))
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
self.prev_mag[:] = mag
weight_sum = max(1e-6, self.args.energy_weight + self.args.flux_weight)
score = (
(self.args.energy_weight * band_energy) + (self.args.flux_weight * flux)
) / weight_sum
self.baseline = ((1.0 - self.args.ema_alpha) * self.baseline) + (
self.args.ema_alpha * score
)
threshold = self.baseline * self.args.threshold_multiplier
custom_hit = score > threshold
aubio_hit = False
aubio_bpm = None
if self.tempo is not None:
aubio_hit = bool(self.tempo(f32)[0])
val = float(self.tempo.get_bpm())
aubio_bpm = clamp_bpm_optional(val if val > 0 else None)
if now_s is None:
now_s = time.time()
bands = self._band_levels(mag)
energy = min(1.0, score / max(threshold, 1e-9))
analysis = {
"ts": now_s,
"db": db,
"rms": rms,
"score": score,
"threshold": threshold,
"energy": energy,
"flux": min(1.0, flux * 8.0),
"custom_hit": custom_hit,
"aubio_hit": aubio_hit,
"aubio_bpm": aubio_bpm,
"mag": mag,
**bands,
}
self.last_analysis = analysis
return analysis
def process_frame(self, frame, now_s: float | None = None):
analysis = self.analyze_frame(frame, now_s=now_s)
now_s = float(analysis["ts"])
if (now_s - self.last_trigger_s) < self.debounce_s:
return None
should_trigger = bool(analysis["aubio_hit"])
if should_trigger and not _is_plausible_ioi(self.last_trigger_s, self.beat_times, now_s):
should_trigger = False
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = _resolve_bpm(self.beat_times, analysis.get("aubio_bpm"))
strength = float(analysis["score"]) / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(analysis["mag"])
median_ioi = None
if len(self.beat_times) >= 2:
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
if intervals.size > 0:
median_ioi = float(np.median(intervals))
phase = self.bar_phase.on_beat(
now_s,
beat_type,
beat_type_conf,
bpm=bpm,
median_ioi=median_ioi,
)
return {
"ts": now_s,
"bpm": bpm,
"score": analysis["score"],
"threshold": analysis["threshold"],
"strength": strength,
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": analysis["db"],
"kick": analysis["kick"],
"snare": analysis["snare"],
"hat": analysis["hat"],
"bass": analysis["bass"],
"energy": analysis["energy"],
"flux": analysis["flux"],
**phase,
}
+299
View File
@@ -0,0 +1,299 @@
"""Shared BPM bounds and live-beat helpers for patterns."""
from __future__ import annotations
import math
import time
from typing import Any
from leds.pattern import Param
BPM_MIN = 60
BPM_MAX = 200
REACT_BANDS = ("none", "auto", "kick", "snare", "hat", "bass", "energy", "flux", "level")
# Injected onto every Pattern via Pattern.__init_subclass__ (per-preset audio).
# Global fallback BPM + audio gain live in settings / Audio UI instead.
AUDIO_PARAMS: dict[str, Param] = {
"beat_detect": Param(False, kind="bool", label="Beat detection"),
"audio_attack": Param(0.15, min=0.05, max=0.5, step=0.01, label="Beat attack"),
"react_band": Param(
"auto",
kind="choice",
choices=REACT_BANDS,
label="React band",
),
}
def clamp_bpm(value, *, default: float = 120.0) -> float:
try:
v = float(value)
except (TypeError, ValueError):
v = float(default)
return max(float(BPM_MIN), min(float(BPM_MAX), v))
def clamp_bpm_optional(value) -> float | None:
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
if v <= 0:
return None
return clamp_bpm(v)
def min_beat_interval_s() -> float:
return 60.0 / float(BPM_MAX)
def max_beat_interval_s() -> float:
return 60.0 / float(BPM_MIN)
def max_beat_min_ioi_ms() -> float:
return 60_000.0 / float(BPM_MAX)
def beat_phase(t: float, bpm: float) -> float:
"""Position within the current beat, 0..1 (synthetic clock)."""
interval = 60.0 / max(clamp_bpm(bpm), 1.0)
return (float(t) % interval) / interval
def beat_index(t: float, bpm: float) -> int:
interval = 60.0 / max(clamp_bpm(bpm), 1.0)
return int(float(t) // interval)
def beat_pulse(t: float, bpm: float, *, attack: float = 0.12) -> float:
"""Sharp attack envelope that peaks at each beat (1 → 0)."""
phase = beat_phase(t, bpm)
width = max(0.02, min(0.5, float(attack)))
if phase >= width:
return 0.0
return 1.0 - phase / width
def beat_wave(t: float, bpm: float) -> float:
"""Smooth 0..1 sine locked to BPM (peak on the beat)."""
phase = beat_phase(t, bpm)
return 0.5 + 0.5 * math.cos(phase * math.tau)
def live_beat(params: dict[str, Any] | None) -> dict[str, Any]:
beat = (params or {}).get("beat")
if not isinstance(beat, dict):
return {}
if not beat_detect_enabled(params or {}):
# Keep global settings, ignore live detector state.
out: dict[str, Any] = {"active": False}
for key in ("fallback_bpm", "audio_gain"):
if key in beat:
out[key] = beat[key]
return out
return beat
def _extras(params: dict[str, Any]) -> dict[str, Any]:
extras = params.get("extras")
return extras if isinstance(extras, dict) else {}
def beat_detect_enabled(params: dict[str, Any]) -> bool:
value = _extras(params).get("beat_detect", False)
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = str(value).strip().lower()
return text in ("1", "true", "yes", "on")
def audio_gain(params: dict[str, Any]) -> float:
"""Global mic gain from settings (injected on beat snapshot)."""
beat = live_beat(params)
try:
if beat.get("audio_gain") is not None:
return max(0.0, min(3.0, float(beat["audio_gain"])))
except (TypeError, ValueError):
pass
extras = _extras(params)
try:
if extras.get("audio_gain") is not None:
return max(0.0, min(3.0, float(extras["audio_gain"])))
except (TypeError, ValueError):
pass
return 1.0
def audio_attack(params: dict[str, Any], default: float = 0.15) -> float:
try:
return max(0.05, min(0.5, float(_extras(params).get("audio_attack", default))))
except (TypeError, ValueError):
return float(default)
def react_band_name(params: dict[str, Any]) -> str:
name = str(_extras(params).get("react_band") or "auto").strip().lower()
return name if name in REACT_BANDS else "auto"
def resolve_bpm(params: dict[str, Any], *, fallback: float = 120.0) -> float:
"""Prefer live detected BPM, else global fallback BPM from settings."""
beat = live_beat(params)
live = clamp_bpm_optional(beat.get("bpm")) if beat.get("active") else None
if live is not None:
return live
if beat.get("fallback_bpm") is not None:
return clamp_bpm(beat.get("fallback_bpm"), default=fallback)
return clamp_bpm(fallback, default=fallback)
def _raw_live_pulse(params: dict[str, Any], *, attack: float) -> float | None:
beat = live_beat(params)
if not beat.get("active"):
return None
pulse = beat.get("pulse")
if pulse is not None:
return max(0.0, min(1.0, float(pulse)))
last = beat.get("last_beat_ts")
bpm = clamp_bpm_optional(beat.get("bpm")) or 120.0
if last is None:
return 0.0
age = max(0.0, time.time() - float(last))
width = max(0.02, min(0.5, float(attack))) * (60.0 / max(clamp_bpm(bpm), 1.0))
if age >= width:
return 0.0
return 1.0 - age / width
def resolve_pulse(
params: dict[str, Any],
t: float,
*,
fallback_bpm: float = 120.0,
attack: float | None = None,
) -> float:
"""Live beat flash only — no synthetic fallback pulse when detection is off."""
_ = fallback_bpm
if not beat_detect_enabled(params):
return 0.0
atk = audio_attack(params, default=0.15 if attack is None else attack)
live = _raw_live_pulse(params, attack=atk)
if live is None:
return 0.0
return max(0.0, min(1.0, live * audio_gain(params)))
def resolve_beat_index(
params: dict[str, Any], t: float, *, fallback_bpm: float = 120.0
) -> int:
beat = live_beat(params)
if beat_detect_enabled(params) and beat.get("active") and beat.get("beat_seq") is not None:
return int(beat["beat_seq"])
# Free-run clock for scrolling motion only (not a flash).
return beat_index(t, resolve_bpm(params, fallback=fallback_bpm))
def resolve_beat_phase(
params: dict[str, Any], t: float, *, fallback_bpm: float = 120.0
) -> float:
beat = live_beat(params)
if beat_detect_enabled(params) and beat.get("active"):
bpm = clamp_bpm_optional(beat.get("bpm")) or fallback_bpm
last = beat.get("last_beat_ts")
if last is not None:
interval = 60.0 / max(clamp_bpm(bpm), 1.0)
return min(1.0, max(0.0, (time.time() - float(last)) / interval))
return 0.0
return beat_phase(t, resolve_bpm(params, fallback=fallback_bpm))
def resolve_motion(
params: dict[str, Any], t: float, *, fallback_bpm: float = 120.0
) -> float:
"""Beat position as a continuous value (index + phase) for movement."""
return float(resolve_beat_index(params, t, fallback_bpm=fallback_bpm)) + resolve_beat_phase(
params, t, fallback_bpm=fallback_bpm
)
def resolve_scroll(
params: dict[str, Any],
t: float,
*,
fallback_bpm: float = 120.0,
pixels_per_beat: float,
loop: int,
) -> int:
"""Scroll offset locked to live beats when detection is on; else free-run."""
loop_n = max(int(loop), 1)
ppb = max(float(pixels_per_beat), 0.25)
return int(resolve_motion(params, t, fallback_bpm=fallback_bpm) * ppb) % loop_n
def resolve_frame(
params: dict[str, Any],
t: float,
*,
fps: float,
fallback_bpm: float = 120.0,
) -> int:
"""Animation frame index driven by beats (or free-run tempo when detection is off)."""
bpm = resolve_bpm(params, fallback=fallback_bpm)
frames_per_beat = max(1.0, float(fps) * 60.0 / max(bpm, 1.0))
return int(resolve_motion(params, t, fallback_bpm=fallback_bpm) * frames_per_beat)
def resolve_beat_wave(
params: dict[str, Any], t: float, *, fallback_bpm: float = 120.0
) -> float:
"""Live beat sine only — flat when detection is off or no live audio."""
beat = live_beat(params)
if not beat_detect_enabled(params) or not beat.get("active"):
return 0.5
return 0.5 + 0.5 * math.cos(
resolve_beat_phase(params, t, fallback_bpm=fallback_bpm) * math.tau
)
def band(params: dict[str, Any], name: str, default: float = 0.0) -> float:
"""Read a live FFT band (kick/snare/hat/bass/energy/flux/level), scaled by gain."""
beat = live_beat(params)
if not beat.get("active"):
return 0.0
try:
raw = max(0.0, float(beat.get(name, default) or default))
except (TypeError, ValueError):
raw = float(default)
return max(0.0, min(1.0, raw * audio_gain(params)))
def react_level(params: dict[str, Any]) -> float:
"""FFT level for the preset's chosen react band (auto = max of kick/bass/energy)."""
choice = react_band_name(params)
if choice == "none":
return 0.0
if choice == "auto":
return max(
band(params, "kick"),
band(params, "bass"),
band(params, "energy"),
)
return band(params, choice)
def resolve_drive(
params: dict[str, Any],
t: float,
*,
fallback_bpm: float = 120.0,
) -> float:
"""Beat pulse combined with the chosen FFT band — main knob for reactive patterns."""
pulse = resolve_pulse(params, t, fallback_bpm=fallback_bpm)
return max(0.0, min(1.0, max(pulse, react_level(params))))
+251
View File
@@ -0,0 +1,251 @@
"""Angular rune and hieroglyph bitmaps for 9-row portal panels."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Sequence
if TYPE_CHECKING:
from leds.pattern import Param
Glyph = tuple[str, ...]
RGB = tuple[int, int, int]
LIT = frozenset("#Xx1")
# Elder Futhark, carved from straight strokes on 5×7.
FUTHARK: tuple[Glyph, ...] = (
("#...#", "#..#.", "###..", "#..#.", "#...#", "#....", "#...."), # fehu
("#...#", "#...#", "#...#", "#...#", "##..#", "#.#.#", "#..##"), # uruz
("#....", "#.##.", "##..#", "#.##.", "#....", "#....", "#...."), # thurisaz
("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"), # ansuz
("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"), # raidho
("#...#", ".#.#.", "..#..", ".#.#.", "#...#", ".....", "....."), # kenaz
("#...#", ".#.#.", "..#..", ".#.#.", "#...#", ".#.#.", "#...#"), # gebo
("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."), # wunjo
("#...#", "#.#.#", "#.#.#", "#####", "#.#.#", "#.#.#", "#...#"), # hagalaz
("#...#", ".#..#", "..#.#", "...##", "..#.#", ".#..#", "#...#"), # naudiz
("..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), # isa
("....#", "...#.", "#.#.#", ".#.#.", "#.#.#", ".#...", "#...."), # jera
("....#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...."), # eihwaz
(".####", "#....", "#....", "#....", "#....", "#....", ".####"), # pertho
("#...#", ".#.#.", "..#..", "..#..", "..#..", "..#..", "..#.."), # algiz
("...##", "..#..", ".#...", "#....", ".#...", "..#..", "...##"), # sowilo
("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), # tiwaz
("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."), # berkano
("#...#", "#...#", "#.#.#", "##.##", "#.#.#", "#...#", "#...#"), # ehwaz
("#...#", "##.##", "#.#.#", "#.#.#", "#.#.#", "##.##", "#...#"), # mannaz
("#....", "#....", "#....", "#....", "#..#.", "#...#", ".##.."), # laguz
("..#..", ".#.#.", "#...#", ".#.#.", "..#..", ".....", "....."), # ingwaz
("#...#", "##.##", "#.#.#", "#.#.#", "#.#.#", "##.##", "#...#"), # dagaz
("..#..", ".#.#.", "#...#", "#...#", ".#.#.", "#...#", "#...#"), # othala
)
# Egyptian-style pictograms, 7×7, with one pixel of vertical padding on 9 rows.
HIEROGLYPHS: tuple[Glyph, ...] = (
("..###..", "..#.#..", "..###..", "...#...", ".#####.", "...#...", "...#..."), # ankh
(".......", ".#####.", "#..#..#", "#.#####", "#..#..#", ".#####.", "......."), # eye
("#.....#", ".#...#.", ".#####.", "##.###.", ".#.#...", ".#..#..", "#......"), # falcon
("...#...", "..#.#..", "..#.#..", "...#...", "...#...", "...#...", "..###.."), # reed
(".#...#.", "##.#.##", ".#####.", "#.#.#.#", ".#####.", "..###..", ".#...#."), # owl
(".......", ".#..#..", "#.##.##", ".......", "..#..#.", ".##.##.", "......."), # water
("...#...", ".#.#.#.", "..###..", "#######", "..###..", ".#.#.#.", "...#..."), # sun
(".###...", "#...#..", "...#...", "..#....", ".#...#.", "#.....#", ".#####."), # snake
("...#...", "..#.#..", "..#.#..", ".#...#.", ".#...#.", "#######", "#.....#"), # pyramid
("#.....#", ".##.##.", "#.#####", "#######", "#.#####", ".##.##.", "#.....#"), # scarab
(".......", ".......", "#.....#", ".#...#.", "..###..", ".......", "......."), # basket
("...#...", "..###..", ".#.#.#.", "...#...", "...#...", "...#...", "..#.#.."), # staff
)
# Ogham-style stem with side notches (5×7).
OGHAM: tuple[Glyph, ...] = (
("..#..", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), # ailm
("#.#..", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
("#.#.#", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
(".#.#.", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#.#", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "#.#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "#.#.#", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", ".#.#.", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#.#", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#..", "#.#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#..", "#.#.#", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#..", ".#.#.", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#..", "..#.#", "..#..", "..#..", "..#..", "..#.."),
("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "#####", "..#..", "..#..", "..#..", "..#..", "..#.."),
("..#..", "..#..", "#####", "..#..", "..#..", "..#..", "..#.."),
("#...#", ".#.#.", "..#..", ".#.#.", "#...#", ".....", "....."),
(".....", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "....."),
(".###.", "#...#", "....#", "..##.", "....#", "#...#", ".###."),
("##.##", "#.#.#", "#####", "#.#.#", "##.##", ".....", "....."),
)
# Wedge / nailhead marks (6×7).
CUNEIFORM: tuple[Glyph, ...] = (
("#.....", "##....", "###...", "####..", "###...", "##....", "#....."),
(".....#", "....##", "...###", "..####", "...###", "....##", ".....#"),
("######", ".#....", "..#...", "...#..", "....#.", ".....#", "......"),
("######", "....#.", "...#..", "..#...", ".#....", "#.....", "......"),
("..##..", ".####.", "######", ".####.", "..##..", "...#..", "...#.."),
("#....#", ".#..#.", "..##..", "..##..", ".#..#.", "#....#", "......"),
("###...", "#.#...", "###...", "..#...", "..#...", "..###.", "......"),
("...###", "...#.#", "...###", "...#..", "...#..", ".###..", "......"),
(".####.", "#....#", "#.##.#", "#....#", "#....#", ".####.", "......"),
("#.#.#.", ".###..", "#.#.#.", ".###..", "#.#.#.", "......", "......"),
("##..##", "##..##", "..##..", "..##..", "##..##", "##..##", "......"),
(".#..#.", "#.##.#", ".#..#.", "#.##.#", ".#..#.", "......", "......"),
("#.....", "#.#...", "#.#.#.", "######", "#.#.#.", "#.#...", "#....."),
("#####.", "#...#.", "#...#.", "#####.", "#.#...", "#..#..", "#...#."),
("..#...", ".###..", "#.#.#.", "..#...", ".#.#..", "#...#.", "......"),
("#...#.", ".#.#..", "..#...", ".#.#..", "#...#.", ".#.#..", "..#..."),
)
# Alchemical / planetary marks (7×7).
ALCHEMY: tuple[Glyph, ...] = (
("...#...", "..###..", ".#...#.", "#.....#", ".#...#.", "..###..", "...#..."), # sun
(".......", ".#####.", "#.....#", "#.....#", "#.....#", ".#####.", "...#..."), # moon
("...#...", "..#.#..", ".#...#.", "#..#..#", ".#...#.", "..###..", "...#..."), # mercury
("#.....#", ".#...#.", "..#.#..", "...#...", "..#.#..", ".#...#.", "#.....#"), # antimony
("...#...", "..###..", ".#####.", "...#...", "...#...", "..###..", ".#####."), # sulfur
("#######", "#.....#", "#.###.#", "#.#.#.#", "#.###.#", "#.....#", "#######"), # salt
("..###..", ".#...#.", "#.....#", "#..#..#", ".#...#.", "..#.#..", "...#..."), # venus
("...#...", "..###..", ".#.#.#.", "#..#..#", "...#...", "...#...", "..###.."), # mars
(".......", ".#.#.#.", "#.#.#.#", ".#####.", "#.#.#.#", ".#.#.#.", "......."), # jupiter
("#.....#", ".#.#.#.", "..###..", ".#####.", "..###..", ".#.#.#.", "#.....#"), # saturn
("...#...", ".#####.", "#..#..#", "...#...", ".#####.", "...#...", "...#..."), # earth
("..###..", ".#...#.", "#.#.#.#", "#.....#", "#.#.#.#", ".#...#.", "..###.."), # spirit
(".#...#.", "##.#.##", ".#####.", "..###..", ".#####.", "##.#.##", ".#...#."), # fire
(".......", "#.....#", ".#...#.", "..###..", ".#...#.", "#.....#", "......."), # air
("#######", ".#...#.", "..###..", "...#...", "..###..", ".#...#.", "#######"), # water
("...#...", "..#.#..", ".#...#.", "#.....#", ".#####.", "...#...", "..###.."), # copper
)
# Geometric occult sigils (7×7).
SIGILS: tuple[Glyph, ...] = (
("...#...", "..#.#..", ".#...#.", "#.....#", ".#...#.", "..#.#..", "...#..."),
("#######", "#.....#", "#.#.#.#", "#.....#", "#.#.#.#", "#.....#", "#######"),
("#.....#", ".#.#.#.", "..#.#..", ".#####.", "..#.#..", ".#.#.#.", "#.....#"),
("..###..", ".#...#.", "#..#..#", "#.#.#.#", "#..#..#", ".#...#.", "..###.."),
("#.#.#.#", ".#.#.#.", "#.#.#.#", ".#.#.#.", "#.#.#.#", ".#.#.#.", "#.#.#.#"),
("...#...", ".#####.", "#.....#", ".#####.", "#.....#", ".#####.", "...#..."),
(".#####.", "#.....#", "#.#.#.#", "#.#.#.#", "#.#.#.#", "#.....#", ".#####."),
("#.....#", "##...##", "#.#.#.#", "#..#..#", "#.#.#.#", "##...##", "#.....#"),
("..#.#..", ".#.#.#.", "#.#.#.#", ".#####.", "#.#.#.#", ".#.#.#.", "..#.#.."),
("#####.#", "#...#.#", "#.###.#", "#.#...#", "#.#####", ".......", "......."),
(".#.###.", "#.#...#", ".#.##.#", "...#.#.", "###.#..", ".......", "......."),
("#..#..#", ".##.##.", "#..#..#", ".##.##.", "#..#..#", ".##.##.", "#..#..#"),
("...#...", "..###..", ".##.##.", "##...##", ".##.##.", "..###..", "...#..."),
("##...##", "#.#.#.#", ".##.##.", "..###..", ".##.##.", "#.#.#.#", "##...##"),
("#.#.#.#", ".......", "#.#.#.#", ".......", "#.#.#.#", ".......", "#.#.#.#"),
("#######", ".......", "#######", ".......", "#######", ".......", "#######"),
)
def _bitmap_rows_to_glyph(rows: tuple[int, ...], *, width: int = 5) -> Glyph:
"""Convert 5×7 bit rows (bit width-1 = left) into ``#``.`` strings."""
out: list[str] = []
for row in rows:
cells: list[str] = []
for col in range(width - 1, -1, -1):
cells.append("#" if (int(row) >> col) & 1 else ".")
out.append("".join(cells))
return tuple(out)
# Latin capitals from the shared 5×7 LED text font.
def _load_latin() -> tuple[Glyph, ...]:
from leds.text import _FONT
return tuple(_bitmap_rows_to_glyph(_FONT[ch]) for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
LATIN: tuple[Glyph, ...] = _load_latin()
FONT_NAMES: tuple[str, ...] = (
"hieroglyphs",
"runes",
"ogham",
"cuneiform",
"alchemy",
"sigils",
"latin",
"mixed",
"ancient",
)
FONT_SETS: dict[str, tuple[Glyph, ...]] = {
"hieroglyphs": HIEROGLYPHS,
"runes": FUTHARK,
"ogham": OGHAM,
"cuneiform": CUNEIFORM,
"alchemy": ALCHEMY,
"sigils": SIGILS,
"latin": LATIN,
"mixed": FUTHARK + HIEROGLYPHS,
"ancient": FUTHARK + HIEROGLYPHS + OGHAM + CUNEIFORM + ALCHEMY + SIGILS,
}
def font_param(default: str = "hieroglyphs") -> Param:
# Lazy import avoids circular import with leds.pattern.
from leds.pattern import Param
choice = default if default in FONT_NAMES else "hieroglyphs"
return Param(choice, kind="choice", choices=FONT_NAMES, label="Font")
def resolve_glyphs(params: dict[str, Any] | None, *, default: str = "hieroglyphs") -> tuple[Glyph, ...]:
"""Return the glyph set selected on the preset (shared Font control)."""
extras = (params or {}).get("extras")
extras = extras if isinstance(extras, dict) else {}
name = str(extras.get("font") or default).strip().lower()
if name not in FONT_SETS:
name = default if default in FONT_SETS else "hieroglyphs"
return FONT_SETS[name]
def glyph_width(glyph: Glyph) -> int:
return max((len(row) for row in glyph), default=0)
def sequence_width(glyphs: Sequence[Glyph], gap: int = 1) -> int:
if not glyphs:
return 0
return sum(glyph_width(g) for g in glyphs) + gap * (len(glyphs) - 1)
def vcenter(panel_h: int, glyph_h: int) -> int:
return max(0, (panel_h - glyph_h) // 2)
def blit_glyph(surface, origin_x: int, origin_y: int, glyph: Glyph, color: RGB) -> None:
from leds.pattern import set_pixel, size
w, h = size(surface)
for gy, row in enumerate(glyph):
y = origin_y + gy
if y < 0 or y >= h:
continue
for gx, ch in enumerate(row):
if ch not in LIT:
continue
x = origin_x + gx
if 0 <= x < w:
set_pixel(surface, x, y, color)
def blit_sequence(
surface,
origin_x: int,
origin_y: int,
glyphs: Sequence[Glyph],
color: RGB,
*,
gap: int = 1,
wrap: int | None = None,
) -> None:
cursor = origin_x
for glyph in glyphs:
blit_glyph(surface, cursor, origin_y, glyph, color)
if wrap is not None:
blit_glyph(surface, cursor + wrap, origin_y, glyph, color)
cursor += glyph_width(glyph) + gap
+84 -1
View File
@@ -27,6 +27,7 @@ from leds.array_config import (
panel_pixel_count,
)
from leds.colors import dim
from leds.portal_span import inject_portal
RGB = Tuple[int, int, int]
@@ -167,6 +168,9 @@ def host_is_reachable(host: str) -> bool:
def filter_reachable_targets(targets: Sequence[PanelTarget]) -> list[PanelTarget]:
"""Drop offline UDP panels so sendto() does not stall the animation loop on ARP."""
assume = os.environ.get("PORTAL_ASSUME_PANELS_ONLINE", "").strip().lower()
if assume in ("1", "true", "yes", "on"):
return list(targets)
reachable: list[PanelTarget] = []
for target in targets:
if target.local:
@@ -247,7 +251,13 @@ def add_panel_args(parser: argparse.ArgumentParser) -> None:
default=None,
help="LEDs per frame (default: panel width × height)",
)
parser.add_argument("--brightness", type=float, default=0.35)
parser.add_argument(
"--brightness",
type=float,
default=0.35,
metavar="0-1",
help="LED brightness scale (default 0.35)",
)
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
@@ -973,6 +983,79 @@ def run_panel_animation_loop(
)
async def run_pattern_loop_async(
targets: Sequence[PanelTarget],
port: int,
pattern_cls: type,
params: dict[str, Any],
*,
fps: float | None = None,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
inst = pattern_cls()
rate = min(fps or float(getattr(pattern_cls, "fps", 30) or 30), PANEL_MAX_FPS)
interval = 1.0 / rate
brightness = float(params.get("brightness", 0.25))
speed = float(params.get("speed", 1.0))
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
)
start = time.monotonic()
try:
setup_panel_pins(sock, targets, port, pin=pin, pins=pins)
while True:
t0 = time.monotonic()
t = (t0 - start) * speed
for target, matrix in bundle.matrices:
matrix.brightness = brightness
inject_portal(params, target.index)
inst.draw(matrix, t, params)
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
elapsed = time.monotonic() - t0
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
finally:
for _, matrix in bundle.matrices:
matrix.clear()
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
if sock is not None:
sock.close()
bundle.close()
def run_pattern_loop(
targets: Sequence[PanelTarget],
port: int,
pattern_cls: type,
params: dict[str, Any],
*,
fps: float | None = None,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
asyncio.run(
run_pattern_loop_async(
targets,
port,
pattern_cls,
params,
fps=fps,
panel_id=panel_id,
sync_send=sync_send,
pin=pin,
pins=pins,
)
)
def animation_playlist(animation: str) -> list[str]:
names = list(ANIMATIONS.keys())
if animation == "all":
+652
View File
@@ -0,0 +1,652 @@
"""Pattern plugin API: one Pattern subclass per file, standalone CLI, hot-load registry."""
from __future__ import annotations
import argparse
import importlib.util
import sys
import types
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Callable, ClassVar, Sequence, Union
from leds.array_config import MATRIX_BRIGHTNESS
from leds.matrix import LedMatrix
from leds.strip import LedStrip
RGB = tuple[int, int, int]
Surface = Union[LedMatrix, LedStrip]
PATTERNS_DIR = Path(__file__).resolve().parent.parent / "patterns"
SPEED_MIN = 0.1
SPEED_MAX = 4.0
PREFERRED_ORDER = (
"solid",
"rainbow",
"plasma",
"wave",
"scanner",
"comet",
"pulse",
"sparkle",
"fire",
"rain",
"stripes",
"bounce",
"rolling",
"spin_line",
"aurora",
"meteor",
"embers",
"tide",
"inscription",
"runes",
"glyphs",
"oracle",
"procession",
"rite",
"braziers",
"constellation",
"veil",
"vortex",
"serpent",
"lattice",
"eclipse",
)
def parse_color(value: str | Sequence[int] | RGB) -> RGB:
if isinstance(value, (tuple, list)) and len(value) == 3 and not isinstance(value, str):
return (int(value[0]), int(value[1]), int(value[2]))
text = str(value).strip()
if "," in text:
parts = [int(p.strip()) for p in text.split(",")]
if len(parts) != 3:
raise ValueError(f"invalid colour: {value!r}")
return (parts[0], parts[1], parts[2])
hex_s = text.removeprefix("#").removeprefix("0x")
if len(hex_s) == 3:
hex_s = "".join(ch * 2 for ch in hex_s)
if len(hex_s) != 6:
raise ValueError(f"invalid colour: {value!r}")
return (int(hex_s[0:2], 16), int(hex_s[2:4], 16), int(hex_s[4:6], 16))
def color_hex(rgb: RGB) -> str:
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
class Color:
"""Named default colour for a pattern (hex or RGB)."""
def __init__(self, value: str | RGB):
self._rgb = parse_color(value)
@property
def rgb(self) -> RGB:
return self._rgb
@property
def hex(self) -> str:
return color_hex(self._rgb)
class Param:
"""Optional extra control: ``float`` slider, ``text``, ``choice``, or ``bool`` checkbox."""
def __init__(
self,
default: float | str | bool = 0.0,
*,
min: float = 0.0,
max: float = 1.0,
step: float = 0.01,
label: str | None = None,
kind: str = "float",
max_length: int = 160,
placeholder: str | None = None,
choices: Sequence[str] | None = None,
) -> None:
self.label = label
if kind == "text":
self.kind = "text"
self.default = str(default)
length = int(max_length)
self.max_length = 1 if length < 1 else length
self.placeholder = placeholder or ""
self.min = 0.0
self.max = 0.0
self.step = 0.0
self.choices: tuple[str, ...] = ()
return
if kind == "choice":
opts = tuple(str(c) for c in (choices or ()) if str(c))
if not opts:
raise ValueError("choice params need a non-empty choices list")
self.kind = "choice"
picked = str(default)
self.default = picked if picked in opts else opts[0]
self.choices = opts
self.min = 0.0
self.max = 0.0
self.step = 0.0
self.max_length = 0
self.placeholder = ""
return
if kind == "bool":
self.kind = "bool"
self.default = bool(default)
self.choices = ()
self.min = 0.0
self.max = 0.0
self.step = 0.0
self.max_length = 0
self.placeholder = ""
return
self.kind = "float"
self.default = float(default)
self.min = float(min)
self.max = float(max)
self.step = float(step)
self.max_length = 0
self.placeholder = ""
self.choices = ()
def is_matrix(surface: Any) -> bool:
return hasattr(surface, "width") and hasattr(surface, "height")
def size(surface: Any) -> tuple[int, int]:
if is_matrix(surface):
return surface.width, surface.height
return len(surface), 1
def set_pixel(surface: Any, x: int, y: int, color: RGB) -> None:
if is_matrix(surface):
surface[x, y] = color
return
w, _ = size(surface)
surface[y * w + x] = color
def each_pixel(surface: Any):
if is_matrix(surface):
w, h = surface.width, surface.height
for y in range(h):
for x in range(w):
yield x, y
return
for i in range(len(surface)):
yield i, 0
def iter_line(x0: int, y0: int, x1: int, y1: int):
"""Yield pixel coordinates along a Bresenham line."""
dx = abs(x1 - x0)
dy = -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
x, y = x0, y0
while True:
yield x, y
if x == x1 and y == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
def _labelize(name: str) -> str:
return name.replace("_", " ").title()
class Pattern:
"""One visual pattern. Subclass in `src/patterns/<id>.py` and call `run_cli`."""
id: ClassVar[str]
label: ClassVar[str] = ""
fps: ClassVar[float] = 25.0
colors: ClassVar[dict[str, Color]] = {}
params: ClassVar[dict[str, Param]] = {}
def draw(self, surface: Any, t: float, params: dict[str, Any]) -> None:
raise NotImplementedError
@classmethod
def display_label(cls) -> str:
return cls.label or _labelize(cls.id)
def __init_subclass__(cls, **kwargs) -> None:
super().__init_subclass__(**kwargs)
if "colors" not in cls.__dict__:
cls.colors = {}
own = cls.__dict__.get("params")
base = dict(own) if isinstance(own, dict) else {}
# Shared extras on every pattern/preset (pattern keys win).
from leds.bpm_limits import AUDIO_PARAMS
from leds.font import font_param
merged = dict(AUDIO_PARAMS)
merged["font"] = font_param("hieroglyphs")
merged.update(base)
cls.params = merged
@classmethod
def default_colors(cls) -> dict[str, RGB]:
return {name: color.rgb for name, color in cls.colors.items()}
@classmethod
def default_extras(cls) -> dict[str, Any]:
return {name: param.default for name, param in cls.params.items()}
@classmethod
def default_params(cls) -> dict[str, Any]:
return {
"brightness": MATRIX_BRIGHTNESS,
"speed": 1.0,
"colors": cls.default_colors(),
"extras": cls.default_extras(),
}
@classmethod
def schema(cls) -> dict[str, Any]:
return {
"id": cls.id,
"label": cls.display_label(),
"fps": cls.fps,
"colors": [
{
"name": name,
"label": _labelize(name),
"default": color.hex,
}
for name, color in cls.colors.items()
],
"params": [_param_schema(name, param) for name, param in cls.params.items()],
}
def _param_schema(name: str, param: Param) -> dict[str, Any]:
entry: dict[str, Any] = {
"name": name,
"label": param.label or _labelize(name),
"type": param.kind,
"default": param.default,
}
if param.kind == "text":
entry["max_length"] = param.max_length
entry["placeholder"] = param.placeholder
return entry
if param.kind == "choice":
entry["choices"] = list(param.choices)
return entry
if param.kind == "bool":
return entry
entry["min"] = param.min
entry["max"] = param.max
entry["step"] = param.step
return entry
def _coerce_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = str(value).strip().lower()
if text in ("1", "true", "yes", "on"):
return True
if text in ("0", "false", "no", "off", ""):
return False
return bool(value)
def coerce_extra(spec: Param, value: Any) -> float | str | bool:
if spec.kind == "text":
text = str(value).replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
return text[: spec.max_length]
if spec.kind == "choice":
text = str(value).strip()
return text if text in spec.choices else str(spec.default)
if spec.kind == "bool":
return _coerce_bool(value)
return max(spec.min, min(spec.max, float(value)))
def clamp_brightness(value: float) -> float:
return max(0.0, min(1.0, float(value)))
def clamp_speed(value: float) -> float:
return max(SPEED_MIN, min(SPEED_MAX, float(value)))
def merge_params(cls: type[Pattern], incoming: dict[str, Any] | None = None) -> dict[str, Any]:
params = cls.default_params()
if not incoming:
return params
if "brightness" in incoming and incoming["brightness"] is not None:
params["brightness"] = clamp_brightness(incoming["brightness"])
if "speed" in incoming and incoming["speed"] is not None:
params["speed"] = clamp_speed(incoming["speed"])
if incoming.get("colors"):
merged = dict(params["colors"])
for name, value in incoming["colors"].items():
if name in cls.colors:
merged[name] = parse_color(value)
params["colors"] = merged
if incoming.get("extras"):
merged_ex = dict(params["extras"])
for name, value in incoming["extras"].items():
spec = cls.params.get(name)
if spec is None:
continue
merged_ex[name] = coerce_extra(spec, value)
params["extras"] = merged_ex
return params
def _pattern_class_in_module(mod: types.ModuleType) -> type[Pattern]:
found: list[type[Pattern]] = []
for obj in vars(mod).values():
if (
isinstance(obj, type)
and issubclass(obj, Pattern)
and obj is not Pattern
and obj.__module__ == mod.__name__
):
found.append(obj)
if len(found) != 1:
names = ", ".join(cls.__name__ for cls in found) or "none"
raise ValueError(f"{mod.__name__} must define exactly one Pattern subclass (found {names})")
cls = found[0]
if not getattr(cls, "id", None):
raise ValueError(f"{cls.__name__} is missing Pattern.id")
return cls
def _ensure_patterns_package() -> None:
if "patterns" in sys.modules:
return
pkg = types.ModuleType("patterns")
pkg.__path__ = [str(PATTERNS_DIR)]
sys.modules["patterns"] = pkg
def _iter_pattern_files(directory: Path) -> list[Path]:
if not directory.is_dir():
return []
return sorted(
path
for path in directory.glob("*.py")
if path.name != "__init__.py" and not path.name.startswith("_")
)
class PatternRegistry:
def __init__(self, directory: Path = PATTERNS_DIR) -> None:
self.directory = directory
self._classes: dict[str, type[Pattern]] = {}
self._modules: dict[str, types.ModuleType] = {}
self._stem_to_id: dict[str, str] = {}
self.errors: dict[str, str] = {}
def __contains__(self, pattern_id: str) -> bool:
return pattern_id in self._classes
def __getitem__(self, pattern_id: str) -> type[Pattern]:
try:
return self._classes[pattern_id]
except KeyError as exc:
raise KeyError(f"unknown pattern: {pattern_id}") from exc
def get(self, pattern_id: str) -> type[Pattern]:
return self[pattern_id]
def ids(self) -> list[str]:
known = [name for name in PREFERRED_ORDER if name in self._classes]
extra = sorted(name for name in self._classes if name not in PREFERRED_ORDER)
return known + extra
def classes(self) -> list[type[Pattern]]:
return [self._classes[name] for name in self.ids()]
def schemas(self) -> list[dict[str, Any]]:
return [cls.schema() for cls in self.classes()]
def load(self) -> set[str]:
return self.reload()
def reload(self, only: set[str] | None = None) -> set[str]:
"""Load or reload pattern files. Returns pattern ids that changed.
If `only` is set, only those file stems are (re)loaded; others stay as-is.
"""
_ensure_patterns_package()
changed: set[str] = set()
files = {path.stem: path for path in _iter_pattern_files(self.directory)}
self.errors = {stem: err for stem, err in self.errors.items() if stem in files}
for stem in list(self._modules):
if stem in files:
continue
if only is not None and stem not in only:
continue
old_id = self._stem_to_id.pop(stem, None)
self._modules.pop(stem, None)
if old_id:
self._classes.pop(old_id, None)
changed.add(old_id)
sys.modules.pop(f"patterns.{stem}", None)
for stem, path in files.items():
if only is not None and stem not in only:
continue
try:
cls = self._load_file(path)
except Exception as exc:
self.errors[stem] = str(exc)
print(f"Failed to load pattern {path.name}: {exc}")
continue
self.errors.pop(stem, None)
old_id = self._stem_to_id.get(stem)
if old_id and old_id != cls.id:
self._classes.pop(old_id, None)
changed.add(old_id)
prev = self._classes.get(cls.id)
self._classes[cls.id] = cls
self._stem_to_id[stem] = cls.id
if prev is not cls:
changed.add(cls.id)
return changed
def _load_file(self, path: Path) -> type[Pattern]:
name = f"patterns.{path.stem}"
existing = sys.modules.get(name)
if existing is not None and path.stem in self._modules:
mod = importlib.reload(existing)
else:
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load {path}")
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
cls = _pattern_class_in_module(mod)
self._modules[path.stem] = mod
return cls
_registry: PatternRegistry | None = None
def get_registry() -> PatternRegistry:
global _registry
if _registry is None:
_registry = PatternRegistry()
_registry.load()
return _registry
def reset_registry() -> None:
global _registry
_registry = None
class _AnimationMap(Mapping):
"""Legacy `ANIMATIONS[name](surface, frame)` view over the pattern registry."""
def __init__(self) -> None:
self._instances: dict[str, Pattern] = {}
self._classes: dict[str, type[Pattern]] = {}
def __getitem__(self, key: str) -> Callable[[Any, int], None]:
cls = get_registry()[key]
inst = self._instances.get(key)
if inst is None or type(inst) is not cls:
inst = cls()
self._instances[key] = inst
self._classes[key] = cls
fps = cls.fps or 30.0
def draw(surface: Any, frame: int) -> None:
inst.draw(surface, frame / fps, cls.default_params())
return draw
def __iter__(self):
return iter(get_registry().ids())
def __len__(self) -> int:
return len(get_registry().ids())
def keys(self): # type: ignore[override]
return get_registry().ids()
class _FpsMap(Mapping):
def __getitem__(self, key: str) -> float:
return get_registry()[key].fps
def __iter__(self):
return iter(get_registry().ids())
def __len__(self) -> int:
return len(get_registry().ids())
def get(self, key: str, default: float | None = None) -> float | None: # type: ignore[override]
registry = get_registry()
if key not in registry:
return default
return registry[key].fps
def run_cli(pattern_cls: type[Pattern]) -> None:
"""Standalone entry: `python src/patterns/scanner.py --brightness 0.3 --speed 2`."""
raise SystemExit(_run_cli(pattern_cls))
def _run_cli(pattern_cls: type[Pattern]) -> int:
from leds.panel_udp import (
PANEL_MAX_FPS,
add_panel_args,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_pattern_loop,
)
parser = argparse.ArgumentParser(
description=f"{pattern_cls.display_label()} pattern for Pico panel(s) over UDP"
)
add_panel_args(parser)
parser.add_argument(
"--speed",
type=float,
default=1.0,
metavar="N",
help=f"Time scale ({SPEED_MIN:g}–{SPEED_MAX:g}, default 1)",
)
parser.add_argument("--fps", type=float, default=None, help="UDP frames per second cap")
for name, color in pattern_cls.colors.items():
parser.add_argument(
f"--{name.replace('_', '-')}",
default=color.hex,
metavar="HEX",
help=f"{_labelize(name)} colour (default {color.hex})",
)
for name, spec in pattern_cls.params.items():
flag = f"--{name.replace('_', '-')}"
if spec.kind == "text":
parser.add_argument(
flag,
default=spec.default,
help=f"{spec.label or _labelize(name)} (text)",
)
continue
if spec.kind == "choice":
parser.add_argument(
flag,
choices=list(spec.choices),
default=spec.default,
help=f"{spec.label or _labelize(name)}",
)
continue
if spec.kind == "bool":
parser.add_argument(
flag,
action=argparse.BooleanOptionalAction,
default=bool(spec.default),
help=f"{spec.label or _labelize(name)}",
)
continue
parser.add_argument(
flag,
type=float,
default=spec.default,
help=f"{spec.label or _labelize(name)} ({spec.min:g}–{spec.max:g})",
)
args = parser.parse_args()
incoming: dict[str, Any] = {
"brightness": args.brightness,
"speed": args.speed,
"colors": {
name: getattr(args, name) for name in pattern_cls.colors
},
"extras": {
name: getattr(args, name) for name in pattern_cls.params
},
}
params = merge_params(pattern_cls, incoming)
targets = panel_targets_from_args(args)
fps = min(args.fps or pattern_cls.fps, PANEL_MAX_FPS)
print(
f"{pattern_cls.display_label()} -> {format_panel_targets(targets, args.port)} "
f"brightness={params['brightness']:g} speed={params['speed']:g} @ {fps:.0f} fps"
)
try:
run_pattern_loop(
targets,
args.port,
pattern_cls,
params,
fps=args.fps,
panel_id=args.panel_id,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
+441
View File
@@ -0,0 +1,441 @@
"""Single hardware player: draw patterns and send UDP frames to Pico panels."""
from __future__ import annotations
import asyncio
import json
import socket
import time
from typing import Any
from leds.array_config import MATRIX_BRIGHTNESS, PANEL_COUNT
from leds.panel_udp import (
DEFAULT_PORT,
PANEL_MAX_FPS,
PanelMatrixBundle,
filter_reachable_targets,
resolve_panel_targets,
send_frames_async,
setup_panel_pins,
)
from leds.pattern import (
Pattern,
clamp_brightness,
clamp_speed,
color_hex,
get_registry,
merge_params,
)
from leds import settings as settings_store
from leds.portal_span import inject_portal
PREVIEW_FPS = 15.0
def _reachable_hosts(targets) -> set[str]:
try:
return {t.host for t in filter_reachable_targets(targets)}
except OSError:
return set()
class PatternPlayer:
"""Owns the animation loop. Starts paused / black."""
def __init__(self, *, port: int = DEFAULT_PORT) -> None:
self.port = port
self.playing = False
self.blackout = True
self.brightness = MATRIX_BRIGHTNESS
self.global_brightness = settings_store.get_global_brightness()
self.speed = 1.0
self.colors: dict[str, tuple[int, int, int]] = {}
self.extras: dict[str, Any] = {}
self.pattern_id = self._default_pattern_id()
self._instance: Pattern | None = None
self._t = 0.0
self._frame = 0
self._dirty = True
self._bundle: PanelMatrixBundle | None = None
self._sock: socket.socket | None = None
self._send_pairs: list = []
self._task: asyncio.Task | None = None
self._preview_queues: list[asyncio.Queue[str]] = []
self._preview_json = json.dumps(self._empty_preview())
self._beat_pulse = 0.0
self._audio = None
self._instantiate()
def attach_audio(self, audio) -> None:
"""Wire the live BeatDetector so patterns receive beat/FFT each frame."""
self._audio = audio
def note_beat(self, status: dict[str, Any] | None = None) -> None:
"""Flash on a detected beat (called from the event loop)."""
strength = 1.0
if isinstance(status, dict):
try:
strength = max(0.35, min(1.0, float(status.get("strength") or status.get("energy") or 1.0)))
except (TypeError, ValueError):
strength = 1.0
if status.get("is_downbeat"):
strength = min(1.0, strength + 0.15)
beat_type = str(status.get("beat_type") or "")
if beat_type == "kick":
strength = min(1.0, strength + 0.1)
self._beat_pulse = strength
self._dirty = True
def _beat_snapshot(self) -> dict[str, Any]:
audio = self._audio
st = audio.status() if audio is not None else {}
running = bool(st.get("running"))
return {
"active": running,
"bpm": st.get("bpm"),
"fallback_bpm": settings_store.get_fallback_bpm(),
"audio_gain": settings_store.get_audio_gain(),
"pulse": float(self._beat_pulse),
"beat_seq": int(st.get("beat_seq") or 0),
"bar_beat": int(st.get("bar_beat") or 1),
"beats_per_bar": int(st.get("beats_per_bar") or 4),
"is_downbeat": bool(st.get("is_downbeat")),
"beat_type": st.get("beat_type") or "unknown",
"last_beat_ts": st.get("last_beat_ts"),
"kick": float(st.get("kick") or 0.0),
"snare": float(st.get("snare") or 0.0),
"hat": float(st.get("hat") or 0.0),
"bass": float(st.get("bass") or 0.0),
"energy": float(st.get("energy") or 0.0),
"flux": float(st.get("flux") or 0.0),
"level": float(st.get("input_level") or 0.0),
}
def _params(self) -> dict[str, Any]:
return {
"brightness": self.brightness,
"speed": self.speed,
"colors": self.colors,
"extras": self.extras,
"beat": self._beat_snapshot(),
}
def _default_pattern_id(self) -> str:
ids = get_registry().ids()
if "rainbow" in ids:
return "rainbow"
if ids:
return ids[0]
raise RuntimeError("no patterns loaded")
def _pattern_cls(self) -> type[Pattern]:
return get_registry()[self.pattern_id]
def _instantiate(self, *, keep_params: bool = False) -> None:
cls = self._pattern_cls()
incoming = None
if keep_params:
incoming = {
"brightness": self.brightness,
"speed": self.speed,
"colors": {k: color_hex(v) for k, v in self.colors.items()},
"extras": self.extras,
}
params = merge_params(cls, incoming)
self.brightness = params["brightness"]
self.speed = params["speed"]
self.colors = params["colors"]
self.extras = params["extras"]
self._instance = cls()
self._t = 0.0
self._frame = 0
self._dirty = True
def snapshot(self) -> dict[str, Any]:
return {
"pattern": self.pattern_id,
"playing": self.playing,
"blackout": self.blackout,
"brightness": self.brightness,
"global_brightness": self.global_brightness,
"effective_brightness": self.effective_brightness,
"speed": self.speed,
"colors": {name: color_hex(rgb) for name, rgb in self.colors.items()},
"extras": dict(self.extras),
"frame": self._frame,
}
@property
def effective_brightness(self) -> float:
return clamp_brightness(self.brightness * self.global_brightness)
def apply(self, update: dict[str, Any]) -> dict[str, Any]:
if "global_brightness" in update and update["global_brightness"] is not None:
self.global_brightness = settings_store.set_global_brightness(
update["global_brightness"]
)
self._dirty = True
changed_pattern = False
if update.get("pattern") and update["pattern"] != self.pattern_id:
if update["pattern"] not in get_registry():
raise ValueError(f"unknown pattern: {update['pattern']}")
self.pattern_id = update["pattern"]
changed_pattern = True
# Preset/pattern brightness only when explicitly provided (or pattern change).
touch_preset = changed_pattern or any(
key in update for key in ("brightness", "speed", "colors", "extras", "pattern")
)
if touch_preset:
incoming = {
"brightness": update.get("brightness", self.brightness),
"speed": update.get("speed", self.speed),
"colors": update.get(
"colors", {k: color_hex(v) for k, v in self.colors.items()}
),
"extras": update.get("extras", self.extras),
}
if changed_pattern:
self._instantiate(keep_params=False)
incoming_keep = {
"brightness": incoming["brightness"],
"speed": incoming["speed"],
}
if update.get("colors"):
incoming_keep["colors"] = update["colors"]
if update.get("extras"):
incoming_keep["extras"] = update["extras"]
params = merge_params(self._pattern_cls(), incoming_keep)
self.brightness = params["brightness"]
self.speed = params["speed"]
self.colors = params["colors"]
self.extras = params["extras"]
else:
params = merge_params(self._pattern_cls(), incoming)
self.brightness = params["brightness"]
self.speed = params["speed"]
self.colors = params["colors"]
self.extras = params["extras"]
if update.get("blackout"):
self.blackout = True
self.playing = False
elif "playing" in update and update["playing"] is not None:
self.playing = bool(update["playing"])
if self.playing:
self.blackout = False
self._dirty = True
return self.snapshot()
def rebind(self, changed_ids: set[str]) -> bool:
"""Re-instantiate if the active pattern file changed. Returns True if rebound."""
ids = get_registry().ids()
if not ids:
return False
if self.pattern_id not in ids:
self.pattern_id = "rainbow" if "rainbow" in ids else ids[0]
self._instantiate(keep_params=True)
self._dirty = True
return True
if self.pattern_id in changed_ids:
self._instantiate(keep_params=True)
return True
return False
@property
def preview_json(self) -> str:
return self._preview_json
def subscribe_preview(self) -> asyncio.Queue[str]:
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1)
self._preview_queues.append(queue)
return queue
def unsubscribe_preview(self, queue: asyncio.Queue[str]) -> None:
if queue in self._preview_queues:
self._preview_queues.remove(queue)
def _empty_preview(self) -> dict[str, Any]:
from leds.array_config import panel_layout
panels = []
for index in range(PANEL_COUNT):
width, height = panel_layout(index)
panels.append(
{
"index": index,
"width": width,
"height": height,
"rgb": [0] * (width * height * 3),
}
)
return {
"frame": 0,
"pattern": self.pattern_id,
"animation": self.pattern_id,
"playing": False,
"panels": panels,
}
def _preview_payload(self) -> dict[str, Any]:
if self._bundle is None:
return self._empty_preview()
panels = []
for target, matrix in self._bundle.matrices:
panels.append(
{
"index": target.index,
"width": target.width,
"height": target.height,
"rgb": list(matrix.rgb_bytes()),
}
)
return {
"frame": self._frame,
"pattern": self.pattern_id,
"animation": self.pattern_id,
"playing": self.playing,
"panels": panels,
}
def _publish_preview(self) -> None:
self._preview_json = json.dumps(self._preview_payload())
for queue in list(self._preview_queues):
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(self._preview_json)
except asyncio.QueueFull:
pass
def _apply_brightness(self) -> None:
if self._bundle is None:
return
pulse = self._beat_pulse
if pulse > 0:
level = clamp_brightness(self.effective_brightness * (0.55 + 0.45 * pulse))
else:
level = self.effective_brightness
for _, matrix in self._bundle.matrices:
matrix.brightness = level
def _clear_matrices(self) -> None:
if self._bundle is None:
return
level = self.effective_brightness
for _, matrix in self._bundle.matrices:
matrix.brightness = level
matrix.clear()
async def _send(self) -> None:
if self._bundle is None:
return
await send_frames_async(
self._sock,
self._send_pairs,
self.port,
None,
)
async def start(self) -> None:
targets = resolve_panel_targets()
reachable = _reachable_hosts(targets)
self._bundle = PanelMatrixBundle(targets, self.effective_brightness)
udp = [t for t in targets if not t.local]
self._sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if udp else None
)
setup_panel_pins(self._sock, targets, self.port)
self._send_pairs = [
(target, matrix)
for target, matrix in self._bundle.matrices
if target.local or target.host in reachable
]
if reachable:
print(
"Player UDP -> "
+ ", ".join(sorted(reachable))
+ f":{self.port}"
)
else:
print("Player: no reachable panels; preview only")
self._clear_matrices()
await self._send()
self._publish_preview()
self._task = asyncio.create_task(self._loop(), name="pattern-player")
async def stop(self) -> None:
if self._task is not None:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
try:
self._clear_matrices()
await self._send()
except Exception:
pass
if self._sock is not None:
self._sock.close()
self._sock = None
if self._bundle is not None:
self._bundle.close()
self._bundle = None
async def _loop(self) -> None:
last = time.monotonic()
while True:
t0 = time.monotonic()
cls = self._pattern_cls()
interval = 1.0 / min(float(cls.fps or PREVIEW_FPS), PANEL_MAX_FPS, PREVIEW_FPS)
dt = t0 - last
last = t0
if self._beat_pulse > 0.02:
self._beat_pulse *= 0.62
self._dirty = True
else:
self._beat_pulse = 0.0
if self.blackout:
if self._dirty:
self._clear_matrices()
await self._send()
self._publish_preview()
self._dirty = False
elif self.playing:
self._t += dt * self.speed
self._frame = int(self._t * (cls.fps or 30))
await self._draw_frame()
self._dirty = False
elif self._dirty:
await self._draw_frame()
self._dirty = False
elapsed = time.monotonic() - t0
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
async def _draw_frame(self) -> None:
if self._instance is None or self._bundle is None:
return
params = self._params()
self._apply_brightness()
try:
for target, matrix in self._bundle.matrices:
inject_portal(params, target.index)
self._instance.draw(matrix, self._t, params)
except Exception as exc:
print(f"Pattern {self.pattern_id} draw failed: {exc}")
self.playing = False
return
await self._send()
self._publish_preview()
+17 -8
View File
@@ -1,8 +1,7 @@
"""Render portal animations for the web simulator."""
"""Portal layout config and one-shot frame render for the web controller."""
from __future__ import annotations
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_BRIGHTNESS,
PANEL_COUNT,
@@ -14,9 +13,12 @@ from leds.array_config import (
panel_label,
)
from leds.panel_udp import HeadlessMatrix
from leds.pattern import get_registry, merge_params
from leds.portal_span import inject_portal
def portal_config() -> dict:
registry = get_registry()
panels = []
for index in range(PANEL_COUNT):
width, height = panel_layout(index)
@@ -30,13 +32,17 @@ def portal_config() -> dict:
"label": panel_label(index),
}
)
schemas = registry.schemas()
return {
"panels": panels,
"topPanel": TOP_PANEL_INDEX,
"orderClockwise": list(PORTAL_PANEL_ORDER_CLOCKWISE),
"animations": list(ANIMATIONS.keys()),
"defaultFps": DEFAULT_FPS,
"patterns": schemas,
"animations": [schema["id"] for schema in schemas],
"defaultFps": {schema["id"]: schema["fps"] for schema in schemas},
"defaultBrightness": MATRIX_BRIGHTNESS,
"defaultSpeed": 1.0,
"errors": dict(registry.errors),
}
@@ -46,14 +52,16 @@ def render_portal_frame(
*,
brightness: float = MATRIX_BRIGHTNESS,
) -> dict:
if animation not in ANIMATIONS:
raise ValueError(f"unknown animation: {animation}")
draw = ANIMATIONS[animation]
cls = get_registry()[animation]
inst = cls()
fps = cls.fps or 30.0
params = merge_params(cls, {"brightness": brightness})
panels = []
for index in range(PANEL_COUNT):
width, height = panel_layout(index)
matrix = HeadlessMatrix(width, height, brightness=brightness)
draw(matrix, frame)
inject_portal(params, index)
inst.draw(matrix, frame / fps, params)
panels.append(
{
"index": index,
@@ -65,5 +73,6 @@ def render_portal_frame(
return {
"frame": frame,
"animation": animation,
"pattern": animation,
"panels": panels,
}
+56
View File
@@ -0,0 +1,56 @@
"""Helpers so patterns can treat the five portal panels as one ring."""
from __future__ import annotations
from typing import Any
from leds.array_config import (
PANEL_HEIGHT,
PORTAL_PANEL_ORDER_CLOCKWISE,
panel_width,
)
def portal_ring_width() -> int:
return sum(panel_width(index) for index in PORTAL_PANEL_ORDER_CLOCKWISE)
def portal_ring_offsets() -> dict[int, int]:
offsets: dict[int, int] = {}
cursor = 0
for index in PORTAL_PANEL_ORDER_CLOCKWISE:
offsets[index] = cursor
cursor += panel_width(index)
return offsets
_OFFSETS = portal_ring_offsets()
_RING_WIDTH = portal_ring_width()
def inject_portal(params: dict[str, Any], panel_index: int) -> dict[str, Any]:
"""Attach per-panel portal geometry onto ``params`` (mutates and returns it)."""
params["portal"] = {
"panel_index": int(panel_index),
"x_offset": int(_OFFSETS.get(int(panel_index), 0)),
"ring_width": int(_RING_WIDTH),
"height": int(PANEL_HEIGHT),
"order": list(PORTAL_PANEL_ORDER_CLOCKWISE),
}
return params
def ring_metrics(params: dict[str, Any], local_width: int) -> tuple[int, int, int]:
"""Return ``(x_offset, ring_width, panel_index)`` with single-panel fallbacks."""
portal = params.get("portal")
if not isinstance(portal, dict):
return 0, max(int(local_width), 1), 0
offset = int(portal.get("x_offset", 0) or 0)
ring = int(portal.get("ring_width", local_width) or local_width)
index = int(portal.get("panel_index", 0) or 0)
return offset, max(ring, 1), index
def global_x(local_x: int, params: dict[str, Any], local_width: int = 0) -> int:
offset, _, _ = ring_metrics(params, local_width)
return offset + int(local_x)
+609
View File
@@ -0,0 +1,609 @@
"""Named player snapshots stored in db/presets.json."""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
from leds.pattern import get_registry, coerce_extra
# Project root / db/presets.json (src/leds → parents[2] = repo root)
DB_PATH = Path(__file__).resolve().parents[2] / "db" / "presets.json"
_lock = threading.Lock()
def _defaults() -> dict[str, dict[str, Any]]:
registry = get_registry()
ids = set(registry.ids())
seeds: list[dict[str, Any]] = []
if "rainbow" in ids:
seeds.append(
{
"name": "Rainbow",
"pattern": "rainbow",
"brightness": 0.35,
"speed": 1.0,
"colors": {},
"extras": {},
}
)
if "scanner" in ids:
seeds.append(
{
"name": "Red scanner",
"pattern": "scanner",
"brightness": 0.4,
"speed": 1.2,
"colors": {"beam": "#ff0000"},
"extras": {},
}
)
if "fire" in ids:
seeds.append(
{
"name": "Fire",
"pattern": "fire",
"brightness": 0.45,
"speed": 1.0,
"colors": {},
"extras": {},
}
)
if "plasma" in ids:
seeds.append(
{
"name": "Plasma",
"pattern": "plasma",
"brightness": 0.35,
"speed": 0.8,
"colors": {},
"extras": {},
}
)
if "solid" in ids:
seeds.append(
{
"name": "Solid red",
"pattern": "solid",
"brightness": 0.3,
"speed": 1.0,
"colors": {"a": "#ff0000", "b": "#ff0000", "c": "#ff0000"},
"extras": {},
}
)
if "inscription" in ids:
seeds.extend(
[
{
"name": "Inscription",
"pattern": "inscription",
"brightness": 0.4,
"speed": 1.0,
"colors": {"ink": "#e0b44a", "stone": "#2a1c10"},
"extras": {
"text": "AVE * SPQR * VENI VIDI VICI * CARPE DIEM * TEMPUS FUGIT * MEMENTO MORI * ALEA IACTA EST",
},
},
{
"name": "SPQR",
"pattern": "inscription",
"brightness": 0.42,
"speed": 1.1,
"colors": {"ink": "#ffcc44", "stone": "#3a1008"},
"extras": {"text": "SPQR"},
},
{
"name": "Veni Vidi Vici",
"pattern": "inscription",
"brightness": 0.42,
"speed": 1.15,
"colors": {"ink": "#f0d080", "stone": "#24180c"},
"extras": {"text": "VENI VIDI VICI"},
},
{
"name": "Carpe Diem",
"pattern": "inscription",
"brightness": 0.38,
"speed": 0.85,
"colors": {"ink": "#d4a84a", "stone": "#1c1408"},
"extras": {"text": "CARPE DIEM"},
},
{
"name": "Memento Mori",
"pattern": "inscription",
"brightness": 0.32,
"speed": 0.6,
"colors": {"ink": "#c8b090", "stone": "#0c0808"},
"extras": {"text": "MEMENTO MORI"},
},
]
)
if "runes" in ids:
seeds.extend(
[
{
"name": "Runes",
"pattern": "runes",
"brightness": 0.4,
"speed": 0.9,
"colors": {"carve": "#7ec8e8", "wood": "#120c08"},
"extras": {},
},
{
"name": "Blood runes",
"pattern": "runes",
"brightness": 0.38,
"speed": 0.75,
"colors": {"carve": "#e04020", "wood": "#1a0808"},
"extras": {},
},
]
)
if "glyphs" in ids:
seeds.append(
{
"name": "Glyphs",
"pattern": "glyphs",
"brightness": 0.4,
"speed": 0.85,
"colors": {"gold": "#f0c050", "night": "#08060c"},
"extras": {},
}
)
if "oracle" in ids:
seeds.extend(
[
{
"name": "Oracle",
"pattern": "oracle",
"brightness": 0.45,
"speed": 1.0,
"colors": {"ink": "#ffce6a", "ember": "#4a1808"},
"extras": {"hold": 2.6, "text": "AVE FATE OMEN LUX NOX REX ASTRA FIAT"},
},
{
"name": "Slow oracle",
"pattern": "oracle",
"brightness": 0.4,
"speed": 0.7,
"colors": {"ink": "#ffe8a0", "ember": "#2a0c08"},
"extras": {"hold": 4.5, "text": "AVE FATE OMEN LUX NOX REX ASTRA FIAT"},
},
]
)
if "wave" in ids:
seeds.append(
{
"name": "Wave",
"pattern": "wave",
"brightness": 0.38,
"speed": 1.0,
"colors": {"color": "#2080ff"},
"extras": {},
}
)
if "comet" in ids:
seeds.append(
{
"name": "Comet",
"pattern": "comet",
"brightness": 0.4,
"speed": 1.2,
"colors": {},
"extras": {},
}
)
if "pulse" in ids:
seeds.append(
{
"name": "Pulse",
"pattern": "pulse",
"brightness": 0.4,
"speed": 0.9,
"colors": {"color": "#9a20d8"},
"extras": {},
}
)
if "sparkle" in ids:
seeds.append(
{
"name": "Sparkle",
"pattern": "sparkle",
"brightness": 0.42,
"speed": 1.0,
"colors": {"spark": "#f0f0ff"},
"extras": {},
}
)
if "rain" in ids:
seeds.append(
{
"name": "Rain",
"pattern": "rain",
"brightness": 0.4,
"speed": 1.1,
"colors": {"drop": "#20e070"},
"extras": {},
}
)
if "stripes" in ids:
seeds.append(
{
"name": "Stripes",
"pattern": "stripes",
"brightness": 0.35,
"speed": 0.9,
"colors": {"a": "#c80000", "b": "#00c800", "c": "#2040ff"},
"extras": {},
}
)
if "bounce" in ids:
seeds.append(
{
"name": "Bounce",
"pattern": "bounce",
"brightness": 0.42,
"speed": 1.2,
"colors": {},
"extras": {},
}
)
if "rolling" in ids:
seeds.append(
{
"name": "Rolling",
"pattern": "rolling",
"brightness": 0.4,
"speed": 1.1,
"colors": {},
"extras": {},
}
)
if "spin_line" in ids:
seeds.append(
{
"name": "Spin line",
"pattern": "spin_line",
"brightness": 0.4,
"speed": 1.0,
"colors": {},
"extras": {},
}
)
if "aurora" in ids:
seeds.extend(
[
{
"name": "Aurora",
"pattern": "aurora",
"brightness": 0.4,
"speed": 0.85,
"colors": {"green": "#2cff9a", "violet": "#6a4cff", "night": "#040814"},
"extras": {"spread": 0.7, "bg_brightness": 0.45},
},
{
"name": "Violet aurora",
"pattern": "aurora",
"brightness": 0.38,
"speed": 0.6,
"colors": {"green": "#40ffe0", "violet": "#c060ff", "night": "#060410"},
"extras": {"spread": 0.9, "bg_brightness": 0.35},
},
]
)
if "meteor" in ids:
seeds.extend(
[
{
"name": "Meteor",
"pattern": "meteor",
"brightness": 0.42,
"speed": 1.15,
"colors": {"head": "#ffe8c8", "tail": "#ff6a28", "night": "#05040a"},
"extras": {"count": 6, "bg_brightness": 0.25},
},
{
"name": "Meteor storm",
"pattern": "meteor",
"brightness": 0.45,
"speed": 1.6,
"colors": {"head": "#fff0d0", "tail": "#40a0ff", "night": "#040818"},
"extras": {"count": 12, "bg_brightness": 0.2},
},
]
)
if "embers" in ids:
seeds.append(
{
"name": "Embers",
"pattern": "embers",
"brightness": 0.42,
"speed": 0.9,
"colors": {"spark": "#ff7a20", "ash": "#120806"},
"extras": {"count": 14, "bg_brightness": 0.35},
}
)
if "tide" in ids:
seeds.extend(
[
{
"name": "Tide",
"pattern": "tide",
"brightness": 0.4,
"speed": 0.85,
"colors": {"water": "#1a6ad0", "foam": "#d8f0ff", "sand": "#1a140c"},
"extras": {"depth": 0.65, "bg_brightness": 0.4},
},
{
"name": "High tide",
"pattern": "tide",
"brightness": 0.4,
"speed": 1.1,
"colors": {"water": "#1048a8", "foam": "#f0ffff", "sand": "#101018"},
"extras": {"depth": 0.9, "bg_brightness": 0.3},
},
]
)
if "procession" in ids:
seeds.append(
{
"name": "Procession",
"pattern": "procession",
"brightness": 0.42,
"speed": 1.0,
"colors": {"mark": "#e8c56a", "path": "#1a1008", "accent": "#6ec8ff"},
"extras": {"density": 0.55, "bg_brightness": 0.45},
}
)
if "rite" in ids:
seeds.append(
{
"name": "Rite",
"pattern": "rite",
"brightness": 0.44,
"speed": 1.05,
"colors": {"gold": "#f0c060", "blood": "#6a1208", "void": "#060408"},
"extras": {"intensity": 0.85},
}
)
if "braziers" in ids:
seeds.append(
{
"name": "Braziers",
"pattern": "braziers",
"brightness": 0.45,
"speed": 1.0,
"colors": {"ember": "#ff6a18", "ash": "#120806"},
"extras": {"heat": 0.8, "bg_brightness": 0.35},
}
)
if "constellation" in ids:
seeds.append(
{
"name": "Constellation",
"pattern": "constellation",
"brightness": 0.42,
"speed": 0.9,
"colors": {"star": "#ffe9a8", "link": "#6a90c8", "night": "#04060e"},
"extras": {"trail": 0.55, "bg_brightness": 0.4},
}
)
if "veil" in ids:
seeds.append(
{
"name": "Veil",
"pattern": "veil",
"brightness": 0.4,
"speed": 0.8,
"colors": {"mist": "#9bb7d8", "gold": "#c9a24a", "shadow": "#0a0810"},
"extras": {"flow": 0.65, "bg_brightness": 0.5},
}
)
if "vortex" in ids:
seeds.extend(
[
{
"name": "Vortex",
"pattern": "vortex",
"brightness": 0.42,
"speed": 1.0,
"colors": {"core": "#6a20ff", "rim": "#f0c060", "void": "#05030a"},
"extras": {"twist": 0.75, "bg_brightness": 0.4},
},
{
"name": "Gold vortex",
"pattern": "vortex",
"brightness": 0.4,
"speed": 0.7,
"colors": {"core": "#c07020", "rim": "#ffe08a", "void": "#080604"},
"extras": {"twist": 0.45, "bg_brightness": 0.35},
},
]
)
if "serpent" in ids:
seeds.append(
{
"name": "Serpent",
"pattern": "serpent",
"brightness": 0.42,
"speed": 1.05,
"colors": {
"scale": "#2ecf6a",
"belly": "#d4c45a",
"eye": "#ff4020",
"void": "#06080a",
},
"extras": {"length": 0.35, "bg_brightness": 0.35},
}
)
if "lattice" in ids:
seeds.append(
{
"name": "Lattice",
"pattern": "lattice",
"brightness": 0.4,
"speed": 1.0,
"colors": {"line": "#7ad0ff", "node": "#ffe08a", "void": "#060810"},
"extras": {"spacing": 4, "bg_brightness": 0.4},
}
)
if "eclipse" in ids:
seeds.append(
{
"name": "Eclipse",
"pattern": "eclipse",
"brightness": 0.44,
"speed": 0.75,
"colors": {"corona": "#ffb428", "flare": "#ff6a18", "void": "#020206"},
"extras": {"size": 0.55, "bg_brightness": 0.3},
}
)
return {str(i + 1): seed for i, seed in enumerate(seeds)}
def _ensure_parent() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def _read() -> dict[str, dict[str, Any]]:
if not DB_PATH.is_file():
data = _defaults()
_write(data)
return data
try:
raw = json.loads(DB_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
if not isinstance(raw, dict) or not raw:
data = _defaults()
_write(data)
return data
return {str(k): v for k, v in raw.items() if isinstance(v, dict)}
def _write(data: dict[str, dict[str, Any]]) -> None:
_ensure_parent()
DB_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _next_id(data: dict[str, Any]) -> str:
nums = []
for key in data:
try:
nums.append(int(key))
except ValueError:
continue
return str(max(nums, default=0) + 1)
def _coerce_extras(pattern: str, extras: dict[str, Any]) -> dict[str, Any]:
cls = get_registry()[pattern]
out: dict[str, Any] = {}
for name, value in extras.items():
spec = cls.params.get(str(name))
if spec is None:
continue
out[str(name)] = coerce_extra(spec, value)
return out
def _normalize(body: dict[str, Any], *, name: str | None = None) -> dict[str, Any]:
pattern = str(body.get("pattern") or "").strip()
if not pattern:
raise ValueError("pattern is required")
if pattern not in get_registry():
raise ValueError(f"unknown pattern: {pattern}")
label = (name if name is not None else body.get("name") or pattern).strip()
if not label:
label = pattern
brightness = float(body.get("brightness", 0.35))
speed = float(body.get("speed", 1.0))
colors = body.get("colors") or {}
extras = body.get("extras") or {}
if not isinstance(colors, dict) or not isinstance(extras, dict):
raise ValueError("colors and extras must be objects")
return {
"name": label,
"pattern": pattern,
"brightness": max(0.0, min(1.0, brightness)),
"speed": max(0.05, min(8.0, speed)),
"colors": {str(k): str(v) for k, v in colors.items()},
"extras": _coerce_extras(pattern, extras),
}
def list_presets() -> list[dict[str, Any]]:
with _lock:
data = _read()
out = []
for pid, preset in data.items():
item = dict(preset)
item["id"] = pid
out.append(item)
out.sort(key=lambda p: (p.get("name") or "").lower())
return out
def get_preset(preset_id: str) -> dict[str, Any] | None:
with _lock:
data = _read()
preset = data.get(str(preset_id))
if preset is None:
return None
item = dict(preset)
item["id"] = str(preset_id)
return item
def create_preset(body: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize(body)
with _lock:
data = _read()
preset_id = _next_id(data)
data[preset_id] = normalized
_write(data)
item = dict(normalized)
item["id"] = preset_id
return item
def update_preset(preset_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
with _lock:
data = _read()
key = str(preset_id)
if key not in data:
return None
merged = dict(data[key])
merged.update(body)
normalized = _normalize(merged)
data[key] = normalized
_write(data)
item = dict(normalized)
item["id"] = key
return item
def delete_preset(preset_id: str) -> bool:
with _lock:
data = _read()
key = str(preset_id)
if key not in data:
return False
del data[key]
_write(data)
return True
def apply_payload(preset: dict[str, Any], *, playing: bool = True) -> dict[str, Any]:
"""Player.apply() body derived from a preset."""
return {
"pattern": preset["pattern"],
"brightness": preset["brightness"],
"speed": preset["speed"],
"colors": dict(preset.get("colors") or {}),
"extras": dict(preset.get("extras") or {}),
"playing": playing,
}
+331
View File
@@ -0,0 +1,331 @@
"""Enumerate capture sources the way PulseAudio / PipeWire presents them (pavucontrol)."""
from __future__ import annotations
import os
import re
import subprocess
from typing import Any, Dict, List, Optional
# Pulse virtual / null sources — not shown in pavucontrol's input list.
_SKIP_PULSE_NAMES = frozenset(
{
"auto_null",
"null",
"echo-cancel-source",
}
)
def _pactl_bin() -> str:
for path in ("/usr/bin/pactl", "/bin/pactl"):
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return "pactl"
def _pactl_ok(args: List[str]) -> bool:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return False
return proc.returncode == 0
def _run_pactl(args: List[str]) -> Optional[str]:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout
def _parse_pactl_sources_short(text: str) -> List[Dict[str, str]]:
"""``pactl list sources short`` — tab-separated name per line."""
sources: List[Dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or line.lower().startswith("source"):
continue
parts = re.split(r"\s+", line, maxsplit=4)
if len(parts) < 2:
continue
name = parts[1].strip()
if not name or name in _SKIP_PULSE_NAMES:
continue
sources.append({"name": name, "description": name})
return sources
def _parse_pactl_sources(text: str) -> List[Dict[str, str]]:
sources: List[Dict[str, str]] = []
block: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if line.startswith("Source #"):
if block.get("name"):
sources.append(block)
block = {}
continue
if not line or ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip().lower()
val = val.strip()
if key == "name":
block["name"] = val
elif key == "description":
block["description"] = val
elif key == "state":
block["state"] = val
if block.get("name"):
sources.append(block)
return sources
def _default_pulse_source_name() -> Optional[str]:
out = _run_pactl(["get-default-source"])
if not out:
return None
name = out.strip()
return name or None
def _name_tokens(*parts: str) -> set:
stop = frozenset(
{
"alsa",
"input",
"output",
"monitor",
"usb",
"device",
"mono",
"stereo",
"analog",
"digital",
"audio",
"source",
"sink",
"pipewire",
"pulse",
"default",
"hw",
"facade",
"capture",
"playback",
}
)
tokens: set = set()
for part in parts:
raw = part.lower().replace(".", " ").replace("-", " ").replace("_", " ")
for tok in re.findall(r"[a-z0-9]+", raw):
if len(tok) >= 2 and tok not in stop:
tokens.add(tok)
return tokens
def _match_sounddevice_index(description: str, pulse_name: str) -> Optional[int]:
try:
import sounddevice as sd
except ImportError:
return None
devices = sd.query_devices()
desc_l = description.lower()
pulse_l = pulse_name.lower()
pulse_tokens = _name_tokens(pulse_name, description)
best: Optional[int] = None
best_score = 0
for idx, dev in enumerate(devices):
chans = int(dev.get("max_input_channels", 0))
sd_name = str(dev.get("name", ""))
sd_l = sd_name.lower()
if chans <= 0 and "monitor" not in sd_l:
continue
score = 0
if sd_name == description:
score = 100
elif desc_l == sd_l:
score = 95
elif desc_l in sd_l or sd_l in desc_l:
score = 80
elif pulse_l in sd_l or sd_l in pulse_l:
score = 60
else:
desc_tokens = _name_tokens(description, sd_name)
overlap = pulse_tokens & desc_tokens
if len(overlap) >= 1:
score = 35 + 15 * len(overlap)
if score < 50 and "monitor" in desc_l and "monitor" in sd_l:
desc_tail = desc_l.replace("monitor of", "").strip()
if desc_tail and desc_tail in sd_l:
score = max(score, 55)
if score > best_score:
best_score = score
best = idx
return best if best_score >= 35 else None
def _looks_like_pulse_source_name(text: str) -> bool:
t = text.strip().lower()
return (
t.startswith("alsa_")
or t.startswith("pulse_")
or ".monitor" in t
or "monitor_of" in t.replace("-", "_")
)
def _sounddevice_index_via_pulse_default(pulse_name: str) -> Optional[int]:
"""Set Pulse default source, then open sounddevice's default input index."""
if not pulse_name or not _pactl_ok(["set-default-source", pulse_name]):
return None
try:
import sounddevice as sd
idx = int(sd.default.device[0])
if idx >= 0:
return idx
except (TypeError, ValueError, ImportError):
pass
return None
def resolve_capture_device(device: Any) -> Any:
"""
Return a sounddevice input index (int) or None for host default.
Accepts int index, numeric string, Pulse source name, or friendly description.
"""
if device is None or device == "":
return None
if isinstance(device, int):
return device
text = str(device).strip()
if not text:
return None
try:
return int(text)
except (TypeError, ValueError):
pass
if _looks_like_pulse_source_name(text):
# Prefer pactl default — works when PortAudio names do not match Pulse ids.
idx = _sounddevice_index_via_pulse_default(text)
if idx is not None:
return idx
idx = _match_sounddevice_index(text, text)
if idx is None:
for src in list_pulse_matched_input_devices():
pn = str(src.get("pulse_name") or "")
if pn == text or pn.startswith(text) or text.startswith(pn):
pid = src.get("id")
if isinstance(pid, int):
return pid
desc = str(src.get("name") or src.get("display_name") or "")
idx = _match_sounddevice_index(desc, pn or text)
if idx is not None:
return idx
if idx is not None:
return idx
raise RuntimeError(
f"No PortAudio capture device for Pulse source {text!r}. "
"Try System default input, or set this source as default in PulseAudio first."
)
idx = _match_sounddevice_index(text, text)
if idx is not None:
return idx
raise RuntimeError(f"No input device matching {text!r}")
def _enrich_pulse_descriptions(sources: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Merge Description fields from ``pactl list sources`` when available."""
text = _run_pactl(["list", "sources"])
if not text:
return sources
by_name = {s.get("name", ""): s for s in _parse_pactl_sources(text)}
out: List[Dict[str, str]] = []
for src in sources:
name = src.get("name", "")
full = by_name.get(name) or {}
desc = full.get("description") or src.get("description") or name
merged = dict(src)
merged["description"] = desc
out.append(merged)
return out
def list_pulse_matched_input_devices() -> List[Dict[str, Any]]:
"""
Sources from ``pactl list sources``, matched to sounddevice indices when possible.
Returns [] if pactl is unavailable or yields no usable sources.
"""
short = _run_pactl(["list", "sources", "short"])
raw_sources: List[Dict[str, str]] = []
if short:
raw_sources = _parse_pactl_sources_short(short)
if not raw_sources:
text = _run_pactl(["list", "sources"])
if text:
raw_sources = _parse_pactl_sources(text)
if not raw_sources:
return []
raw_sources = _enrich_pulse_descriptions(raw_sources)
default_name = _default_pulse_source_name()
out: List[Dict[str, Any]] = []
for src in raw_sources:
pulse_name = src.get("name", "")
description = src.get("description") or pulse_name
if not pulse_name or pulse_name in _SKIP_PULSE_NAMES:
continue
if description.lower() in ("null", "auto_null"):
continue
desc_l = description.lower()
is_monitor = desc_l.startswith("monitor of") or ".monitor" in pulse_name.lower()
# Pulse source name is stable across refreshes; sounddevice index is not.
device_id: Any = pulse_name
sd_idx = _match_sounddevice_index(description, pulse_name)
is_default = default_name is not None and pulse_name == default_name
display_name = description
if is_default and "(default)" not in display_name.lower():
display_name = f"{display_name} (default)"
label = f"{description} [{pulse_name}]"
if sd_idx is not None:
label = f"[{sd_idx}] {label}"
out.append(
{
"id": device_id,
"name": description,
"display_name": display_name,
"label": label,
"pulse_name": pulse_name,
"sounddevice_index": sd_idx,
"is_monitor": is_monitor,
"is_default": is_default,
"hostapi": "PulseAudio",
}
)
if not out:
return []
out.sort(
key=lambda d: (
0 if d.get("is_monitor") else 1,
str(d.get("display_name") or "").lower(),
)
)
return out
+114
View File
@@ -0,0 +1,114 @@
"""Timed sequence playback — applies presets through PatternPlayer."""
from __future__ import annotations
import asyncio
from typing import Any, Awaitable, Callable
from leds import presets as preset_store
from leds import sequences as sequence_store
from leds.player import PatternPlayer
NotifyFn = Callable[[dict[str, Any]], Awaitable[None] | None]
class SequenceRunner:
def __init__(
self,
player: PatternPlayer,
*,
notify: NotifyFn | None = None,
) -> None:
self._player = player
self._notify = notify
self._task: asyncio.Task | None = None
self._sequence_id: str | None = None
self._step_index = 0
self._active = False
def status(self) -> dict[str, Any]:
return {
"active": self._active,
"sequence_id": self._sequence_id,
"step_index": self._step_index,
}
async def play(self, sequence_id: str) -> dict[str, Any]:
sequence = sequence_store.get_sequence(sequence_id)
if sequence is None:
raise KeyError(f"sequence not found: {sequence_id}")
if not sequence.get("steps"):
raise ValueError("sequence has no steps")
await self.stop(notify=False)
self._sequence_id = str(sequence_id)
self._step_index = 0
self._active = True
self._task = asyncio.create_task(
self._run(sequence), name=f"sequence-{sequence_id}"
)
await self._emit()
return self.status()
async def stop(self, *, notify: bool = True) -> dict[str, Any]:
task = self._task
self._task = None
self._active = False
self._sequence_id = None
self._step_index = 0
if task is not None:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if notify:
await self._emit()
return self.status()
async def _run(self, sequence: dict[str, Any]) -> None:
steps = list(sequence.get("steps") or [])
loop = bool(sequence.get("loop", True))
try:
while self._active and steps:
for index, step in enumerate(steps):
if not self._active:
return
self._step_index = index
await self._apply_step(step)
await self._emit()
await asyncio.sleep(float(step.get("duration_s", 5)))
if not loop:
break
except asyncio.CancelledError:
raise
finally:
was_active = self._active
self._active = False
self._sequence_id = None
self._step_index = 0
self._task = None
if was_active:
await self._emit()
async def _apply_step(self, step: dict[str, Any]) -> None:
preset = preset_store.get_preset(str(step["preset_id"]))
if preset is None:
raise ValueError(f"unknown preset: {step['preset_id']}")
self._player.apply(preset_store.apply_payload(preset, playing=True))
if self._notify is not None:
result = self._notify(
{
"type": "player",
"player": self._player.snapshot(),
"presetId": preset["id"],
}
)
if asyncio.iscoroutine(result):
await result
async def _emit(self) -> None:
if self._notify is None:
return
result = self._notify({"type": "sequence", "sequence": self.status()})
if asyncio.iscoroutine(result):
await result
+156
View File
@@ -0,0 +1,156 @@
"""Named timed chains of presets stored in db/sequences.json."""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
from leds import presets as preset_store
DB_PATH = Path(__file__).resolve().parents[2] / "db" / "sequences.json"
_lock = threading.Lock()
def _ensure_parent() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def _defaults() -> dict[str, dict[str, Any]]:
presets = preset_store.list_presets()
if len(presets) < 2:
return {}
return {
"1": {
"name": "Showcase",
"loop": True,
"steps": [
{"preset_id": presets[0]["id"], "duration_s": 8},
{"preset_id": presets[1]["id"], "duration_s": 8},
],
}
}
def _read() -> dict[str, dict[str, Any]]:
if not DB_PATH.is_file():
data = _defaults()
if data:
_write(data)
return data
try:
raw = json.loads(DB_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
if not isinstance(raw, dict):
return {}
return {str(k): v for k, v in raw.items() if isinstance(v, dict)}
def _write(data: dict[str, dict[str, Any]]) -> None:
_ensure_parent()
DB_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _next_id(data: dict[str, Any]) -> str:
nums = []
for key in data:
try:
nums.append(int(key))
except ValueError:
continue
return str(max(nums, default=0) + 1)
def _normalize_step(step: Any) -> dict[str, Any]:
if not isinstance(step, dict):
raise ValueError("each step must be an object")
preset_id = str(step.get("preset_id") or "").strip()
if not preset_id:
raise ValueError("step.preset_id is required")
if preset_store.get_preset(preset_id) is None:
raise ValueError(f"unknown preset: {preset_id}")
duration = float(step.get("duration_s", 5))
if duration < 0.5:
duration = 0.5
if duration > 600:
duration = 600.0
return {"preset_id": preset_id, "duration_s": duration}
def _normalize(body: dict[str, Any]) -> dict[str, Any]:
name = str(body.get("name") or "").strip() or "Sequence"
steps_in = body.get("steps") or []
if not isinstance(steps_in, list):
raise ValueError("steps must be a list")
steps = [_normalize_step(step) for step in steps_in]
if not steps:
raise ValueError("sequence needs at least one step")
return {
"name": name,
"loop": bool(body.get("loop", True)),
"steps": steps,
}
def list_sequences() -> list[dict[str, Any]]:
with _lock:
data = _read()
out = []
for sid, seq in data.items():
item = dict(seq)
item["id"] = sid
out.append(item)
out.sort(key=lambda s: (s.get("name") or "").lower())
return out
def get_sequence(sequence_id: str) -> dict[str, Any] | None:
with _lock:
data = _read()
seq = data.get(str(sequence_id))
if seq is None:
return None
item = dict(seq)
item["id"] = str(sequence_id)
return item
def create_sequence(body: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize(body)
with _lock:
data = _read()
sequence_id = _next_id(data)
data[sequence_id] = normalized
_write(data)
item = dict(normalized)
item["id"] = sequence_id
return item
def update_sequence(sequence_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
with _lock:
data = _read()
key = str(sequence_id)
if key not in data:
return None
merged = dict(data[key])
merged.update(body)
normalized = _normalize(merged)
data[key] = normalized
_write(data)
item = dict(normalized)
item["id"] = key
return item
def delete_sequence(sequence_id: str) -> bool:
with _lock:
data = _read()
key = str(sequence_id)
if key not in data:
return False
del data[key]
_write(data)
return True
+139
View File
@@ -0,0 +1,139 @@
"""Persistent controller settings (global brightness, audio, etc.)."""
from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import Any
from leds.bpm_limits import BPM_MAX, BPM_MIN, clamp_bpm
DB_PATH = Path(__file__).resolve().parents[2] / "db" / "settings.json"
_lock = threading.Lock()
DEFAULTS = {
"global_brightness": 1.0,
"fallback_bpm": 120.0,
"audio_gain": 1.0,
}
def _ensure_parent() -> None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
def load() -> dict[str, Any]:
with _lock:
if not DB_PATH.is_file():
data = dict(DEFAULTS)
_write(data)
return data
try:
raw = json.loads(DB_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
if not isinstance(raw, dict):
raw = {}
data = dict(DEFAULTS)
data.update(raw)
return data
def _write(data: dict[str, Any]) -> None:
_ensure_parent()
DB_PATH.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def save(update: dict[str, Any]) -> dict[str, Any]:
with _lock:
data = dict(DEFAULTS)
if DB_PATH.is_file():
try:
raw = json.loads(DB_PATH.read_text(encoding="utf-8"))
if isinstance(raw, dict):
data.update(raw)
except (OSError, json.JSONDecodeError):
pass
data.update(update)
_write(data)
return data
def snapshot() -> dict[str, Any]:
data = load()
return {
"global_brightness": get_global_brightness(),
"fallback_bpm": get_fallback_bpm(),
"audio_gain": get_audio_gain(),
**{
key: data[key]
for key in data
if key not in ("global_brightness", "fallback_bpm", "audio_gain")
},
}
def get_global_brightness() -> float:
value = float(load().get("global_brightness", 1.0))
return max(0.0, min(1.0, value))
def set_global_brightness(value: float) -> float:
clamped = max(0.0, min(1.0, float(value)))
save({"global_brightness": clamped})
return clamped
def get_fallback_bpm() -> float:
return clamp_bpm(load().get("fallback_bpm", 120.0), default=120.0)
def set_fallback_bpm(value: float) -> float:
clamped = clamp_bpm(value, default=120.0)
save({"fallback_bpm": clamped})
return clamped
def get_audio_gain() -> float:
try:
value = float(load().get("audio_gain", 1.0))
except (TypeError, ValueError):
value = 1.0
return max(0.0, min(3.0, value))
def set_audio_gain(value: float) -> float:
clamped = max(0.0, min(3.0, float(value)))
save({"audio_gain": clamped})
return clamped
def apply_update(body: dict[str, Any]) -> dict[str, Any]:
update: dict[str, Any] = {}
if "global_brightness" in body and body["global_brightness"] is not None:
update["global_brightness"] = max(0.0, min(1.0, float(body["global_brightness"])))
if "fallback_bpm" in body and body["fallback_bpm"] is not None:
update["fallback_bpm"] = clamp_bpm(body["fallback_bpm"], default=120.0)
if "audio_gain" in body and body["audio_gain"] is not None:
update["audio_gain"] = max(0.0, min(3.0, float(body["audio_gain"])))
if update:
save(update)
return snapshot()
# Re-export bounds for callers that import settings only.
__all__ = [
"BPM_MAX",
"BPM_MIN",
"apply_update",
"get_audio_gain",
"get_fallback_bpm",
"get_global_brightness",
"load",
"save",
"set_audio_gain",
"set_fallback_bpm",
"set_global_brightness",
"snapshot",
]
+8 -4
View File
@@ -4,10 +4,6 @@ from __future__ import annotations
from typing import Literal, Sequence, Tuple, Union
from leds.backends.pio import PioBackend
from leds.backends.rpi5_ws2812 import Rpi5Ws2812Backend
from leds.backends.spi import SpiBackend
from leds.backends.ws281x import Ws281xBackend
from leds.config import pi5_setup_hint
from leds.detect import board_family, board_model, pio_available, supports_ws281x
from leds.wire import normalize_wire_order, rgb_bytes_to_wire, swap_rg_bytes
@@ -90,8 +86,12 @@ class LedStrip:
resolved = _resolve_backend(backend)
if resolved == "pio":
from leds.backends.pio import PioBackend
self._backend = PioBackend(count, device=device)
elif resolved == "spi":
from leds.backends.spi import SpiBackend
spi_kwargs: dict[str, int] = {}
if spi_max_speed_hz is not None:
spi_kwargs["max_speed_hz"] = spi_max_speed_hz
@@ -99,11 +99,15 @@ class LedStrip:
count, spi_bus=spi_bus, spi_device=spi_device, **spi_kwargs
)
elif resolved == "rpi5_ws2812":
from leds.backends.rpi5_ws2812 import Rpi5Ws2812Backend
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:
from leds.backends.ws281x import Ws281xBackend
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
self._backend_name = resolved
+8
View File
@@ -48,6 +48,14 @@ _FONT: dict[str, tuple[int, ...]] = {
"7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08),
"8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E),
"9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C),
"*": (0x00, 0x15, 0x0A, 0x04, 0x0A, 0x15, 0x00),
".": (0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06),
"-": (0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00),
"!": (0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04),
"?": (0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04),
"'": (0x04, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00),
":": (0x00, 0x06, 0x06, 0x00, 0x06, 0x06, 0x00),
"/": (0x01, 0x02, 0x04, 0x04, 0x08, 0x10, 0x10),
}
CHAR_WIDTH = 5
View File
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""Portal hardware web controller — FastAPI entry point."""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
SRC_DIR = Path(__file__).resolve().parent
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from leds.audio import BeatDetector
from leds.pattern import PATTERNS_DIR, get_registry
from leds.player import PatternPlayer
from leds.portal_sim import portal_config, render_portal_frame
from leds.sequence_runner import SequenceRunner
from leds import presets as preset_store
from leds import sequences as sequence_store
from leds import settings as settings_store
STATIC_DIR = SRC_DIR / "static"
SSE_HEADERS = {"Cache-Control": "no-cache", "Connection": "keep-alive"}
_reload_queues: list[asyncio.Queue[str]] = []
_event_queues: list[asyncio.Queue[str]] = []
_player: PatternPlayer | None = None
_sequences: SequenceRunner | None = None
_audio: BeatDetector | None = None
def _dev_reload_enabled() -> bool:
return os.environ.get("PORTAL_DEV_RELOAD", "0") == "1"
def get_player() -> PatternPlayer:
if _player is None:
raise RuntimeError("player not started")
return _player
def get_sequences() -> SequenceRunner:
if _sequences is None:
raise RuntimeError("sequence runner not started")
return _sequences
def get_audio() -> BeatDetector:
if _audio is None:
raise RuntimeError("audio not started")
return _audio
async def _notify_reload_clients() -> None:
for queue in list(_reload_queues):
await queue.put("reload")
async def _notify_event_clients(payload: dict[str, Any]) -> None:
message = json.dumps(payload)
for queue in list(_event_queues):
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(message)
except asyncio.QueueFull:
pass
async def _watch_static_files() -> None:
from watchfiles import awatch
async for _changes in awatch(STATIC_DIR):
await _notify_reload_clients()
async def _watch_pattern_files() -> None:
from watchfiles import awatch
PATTERNS_DIR.mkdir(parents=True, exist_ok=True)
async for changes in awatch(PATTERNS_DIR):
stems = {
Path(path).stem
for _change, path in changes
if Path(path).suffix == ".py" and not Path(path).name.startswith("_")
}
if not stems:
continue
changed = get_registry().reload(stems)
rebound = get_player().rebind(changed)
await _notify_event_clients(
{
"type": "patterns",
"patterns": get_registry().schemas(),
"errors": dict(get_registry().errors),
"player": get_player().snapshot() if rebound else None,
}
)
@asynccontextmanager
async def _lifespan(_app: FastAPI):
global _player, _sequences, _audio
_player = PatternPlayer()
await _player.start()
_sequences = SequenceRunner(_player, notify=_notify_event_clients)
_audio = BeatDetector(on_beat=_player.note_beat, notify=_notify_event_clients)
_player.attach_audio(_audio)
watchers = [asyncio.create_task(_watch_pattern_files(), name="watch-patterns")]
if _dev_reload_enabled():
watchers.append(asyncio.create_task(_watch_static_files(), name="watch-static"))
yield
for watcher in watchers:
watcher.cancel()
try:
await watcher
except asyncio.CancelledError:
pass
await _audio.stop()
_audio = None
await _sequences.stop(notify=False)
_sequences = None
await _player.stop()
_player = None
def create_app() -> FastAPI:
app = FastAPI(title="Portal Controller", lifespan=_lifespan)
@app.get("/")
async def index() -> HTMLResponse:
html = (STATIC_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:
config = portal_config()
config["player"] = get_player().snapshot()
config["presets"] = preset_store.list_presets()
config["sequences"] = sequence_store.list_sequences()
config["sequence"] = get_sequences().status()
config["audio"] = get_audio().status()
config["settings"] = settings_store.snapshot()
return config
@app.get("/api/settings")
async def api_settings_get() -> dict:
return settings_store.snapshot()
@app.put("/api/settings")
async def api_settings_put(request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
try:
return settings_store.apply_update(body)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/player")
async def api_player_get() -> dict:
return get_player().snapshot()
@app.put("/api/player")
async def api_player_put(request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
try:
snapshot = get_player().apply(body)
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return snapshot
@app.get("/api/presets")
async def api_presets_list() -> list:
return preset_store.list_presets()
@app.post("/api/presets")
async def api_presets_create(request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
if body.get("from_player"):
snap = get_player().snapshot()
body = {
"name": body.get("name") or snap["pattern"],
"pattern": snap["pattern"],
"brightness": snap["brightness"],
"speed": snap["speed"],
"colors": snap["colors"],
"extras": snap["extras"],
}
try:
preset = preset_store.create_preset(body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _notify_event_clients({"type": "presets", "presets": preset_store.list_presets()})
return preset
@app.put("/api/presets/{preset_id}")
async def api_presets_update(preset_id: str, request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
try:
preset = preset_store.update_preset(preset_id, body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if preset is None:
raise HTTPException(status_code=404, detail="preset not found")
await _notify_event_clients({"type": "presets", "presets": preset_store.list_presets()})
return preset
@app.delete("/api/presets/{preset_id}")
async def api_presets_delete(preset_id: str) -> dict:
if not preset_store.delete_preset(preset_id):
raise HTTPException(status_code=404, detail="preset not found")
await _notify_event_clients({"type": "presets", "presets": preset_store.list_presets()})
return {"ok": True}
@app.post("/api/presets/{preset_id}/apply")
async def api_presets_apply(preset_id: str) -> dict:
preset = preset_store.get_preset(preset_id)
if preset is None:
raise HTTPException(status_code=404, detail="preset not found")
await get_sequences().stop()
try:
snapshot = get_player().apply(preset_store.apply_payload(preset, playing=True))
except (KeyError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _notify_event_clients({"type": "player", "player": snapshot, "presetId": preset_id})
return {"player": snapshot, "preset": preset}
@app.get("/api/sequences")
async def api_sequences_list() -> list:
return sequence_store.list_sequences()
@app.post("/api/sequences")
async def api_sequences_create(request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
try:
sequence = sequence_store.create_sequence(body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _notify_event_clients(
{"type": "sequences", "sequences": sequence_store.list_sequences()}
)
return sequence
@app.put("/api/sequences/{sequence_id}")
async def api_sequences_update(sequence_id: str, request: Request) -> dict:
try:
body = await request.json()
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid JSON") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="expected object")
try:
sequence = sequence_store.update_sequence(sequence_id, body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if sequence is None:
raise HTTPException(status_code=404, detail="sequence not found")
await _notify_event_clients(
{"type": "sequences", "sequences": sequence_store.list_sequences()}
)
return sequence
@app.delete("/api/sequences/{sequence_id}")
async def api_sequences_delete(sequence_id: str) -> dict:
runner = get_sequences()
if runner.status().get("sequence_id") == sequence_id:
await runner.stop()
if not sequence_store.delete_sequence(sequence_id):
raise HTTPException(status_code=404, detail="sequence not found")
await _notify_event_clients(
{"type": "sequences", "sequences": sequence_store.list_sequences()}
)
return {"ok": True}
@app.get("/api/sequences/status")
async def api_sequences_status() -> dict:
return get_sequences().status()
@app.post("/api/sequences/{sequence_id}/play")
async def api_sequences_play(sequence_id: str) -> dict:
try:
status = await get_sequences().play(sequence_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
sequence = sequence_store.get_sequence(sequence_id)
return {"sequence": status, "item": sequence}
@app.post("/api/sequences/stop")
async def api_sequences_stop() -> dict:
status = await get_sequences().stop()
return {"sequence": status}
@app.get("/api/audio/devices")
async def api_audio_devices() -> dict:
try:
devices = await asyncio.to_thread(get_audio().list_input_devices)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"devices": devices}
@app.post("/api/audio/start")
async def api_audio_start(request: Request) -> dict:
device = None
try:
body = await request.json()
if isinstance(body, dict) and "device" in body:
device = body.get("device")
except Exception:
device = None
try:
status = await get_audio().start(device=device)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if status.get("error"):
raise HTTPException(status_code=400, detail=status["error"])
return {"ok": True, "status": status}
@app.post("/api/audio/stop")
async def api_audio_stop() -> dict:
status = await get_audio().stop()
return {"ok": True, "status": status}
@app.post("/api/audio/reset")
async def api_audio_reset() -> dict:
ok = get_audio().reset_tracking()
return {"ok": ok, "status": get_audio().status()}
@app.get("/api/audio/status")
async def api_audio_status() -> dict:
return {"status": get_audio().status()}
@app.get("/api/preview")
async def api_preview() -> StreamingResponse:
player = get_player()
async def generate():
queue = player.subscribe_preview()
try:
yield f"data: {player.preview_json}\n\n"
while True:
payload = await queue.get()
yield f"data: {payload}\n\n"
finally:
player.unsubscribe_preview(queue)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@app.get("/api/events")
async def api_events() -> StreamingResponse:
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=8)
_event_queues.append(queue)
async def generate():
try:
yield f"data: {json.dumps({'type': 'connected'})}\n\n"
while True:
message = await queue.get()
yield f"data: {message}\n\n"
finally:
if queue in _event_queues:
_event_queues.remove(queue)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
@app.get("/api/frame")
async def api_frame(animation: str = "rainbow", frame: int = 0, brightness: float = 0.35) -> dict:
try:
return await asyncio.to_thread(
render_portal_frame, animation, frame, brightness=brightness
)
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@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():
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=SSE_HEADERS,
)
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
path = STATIC_DIR / "favicon.ico"
if path.is_file():
return FileResponse(path)
raise HTTPException(status_code=404)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
return app
app = create_app()
def main() -> int:
parser = argparse.ArgumentParser(description="Portal hardware web controller")
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 src/leds + src/static changes (default: on; does not watch patterns/)",
)
parser.add_argument(
"--browser-reload",
action=argparse.BooleanOptionalAction,
default=True,
help="reload browser when src/static files change (default: on)",
)
args = parser.parse_args()
if not STATIC_DIR.is_dir():
print(f"Missing static directory: {STATIC_DIR}", file=sys.stderr)
return 1
try:
import uvicorn
except ImportError:
print(
"Web controller 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 = SRC_DIR / "leds"
reload_dirs = [str(STATIC_DIR), str(leds_dir)] if args.reload else None
print(f"Portal controller → http://{args.host}:{args.port}/")
print("Drives Pico panels over UDP; starts paused / black")
if args.reload:
print("Python reload: on (src/leds, src/static)")
if args.browser_reload:
print("Browser reload: on (watches src/static)")
print("Pattern hot-load: on (watches src/patterns)")
print("Ctrl+C to stop")
uvicorn.run(
"main:app",
app_dir=str(SRC_DIR),
host=args.host,
port=args.port,
reload=args.reload,
reload_dirs=reload_dirs,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""Standalone LED patterns. Each module defines one Pattern subclass."""
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Drifting aurora curtains across the portal ring."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse
from leds.colors import blend, dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Aurora(Pattern):
id = "aurora"
label = "Aurora"
fps = 28
colors = {
"green": Color("#2cff9a"),
"violet": Color("#6a4cff"),
"night": Color("#040814"),
}
params = {
"spread": Param(0.7, min=0.2, max=1.0, step=0.05, label="Spread"),
"bg_brightness": Param(0.45, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
spread = float(extras["spread"])
bg_b = float(extras["bg_brightness"])
green = params["colors"]["green"]
violet = params["colors"]["violet"]
night = params["colors"]["night"]
pulse = resolve_pulse(params, t, fallback_bpm=96, attack=0.2)
energy = band(params, "energy")
hat = band(params, "hat")
_, ring, _ = ring_metrics(params, w)
drift = t * (ring * 0.08)
for y in range(h):
row = y / max(h - 1, 1)
for x in range(w):
gx = global_x(x, params, w)
a = math.sin((gx + drift) * 0.09 + y * 0.7)
b = math.sin((gx - drift * 0.55) * 0.16 + t * 1.1)
c = math.sin((gx + drift * 0.25) * 0.04 + y * 1.4)
field = 0.5 + 0.5 * (0.5 * a + 0.35 * b + 0.15 * c)
field = min(1.0, field * (0.55 + 0.45 * spread) + 0.18 * pulse + 0.15 * energy)
curtain = field * (0.55 + 0.45 * (1.0 - abs(row - 0.4)))
sky = dim(night, bg_b * (0.4 + 0.25 * field))
ribbon = blend(green, violet, 0.35 + 0.45 * row + 0.1 * hat)
glow = dim(ribbon, curtain)
set_pixel(
surface,
x,
y,
(
min(255, sky[0] + glow[0]),
min(255, sky[1] + glow[1]),
min(255, sky[2] + glow[2]),
),
)
if __name__ == "__main__":
run_cli(Aurora)
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Bouncing blob."""
from __future__ import annotations
import math
from leds.colors import wheel
from leds.pattern import Pattern, run_cli, set_pixel, size
class Bounce(Pattern):
id = "bounce"
label = "Bounce"
fps = 35
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
surface.fill((0, 0, 0))
phase = frame * 0.2
x = int((math.sin(phase) + 1) / 2 * (w - 1))
y = int((math.cos(phase * 1.3) + 1) / 2 * (h - 1))
color = wheel(frame * 5)
for dy in range(-1, 2):
for dx in range(-1, 2):
px, py = x + dx, y + dy
if 0 <= px < w and 0 <= py < h:
set_pixel(surface, px, py, color)
if __name__ == "__main__":
run_cli(Bounce)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Brazier fires at the hexagonal panel seams, driven by kick/bass FFT."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_beat_wave, resolve_pulse
from leds.colors import dim, heat_color
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, portal_ring_offsets, ring_metrics
class Braziers(Pattern):
id = "braziers"
label = "Braziers"
fps = 30
colors = {
"ember": Color("#ff6a18"),
"ash": Color("#120806"),
}
params = {
"heat": Param(0.8, min=0.2, max=1.0, step=0.05, label="Heat"),
"bg_brightness": Param(0.35, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
heat = float(extras["heat"])
bg_b = float(extras["bg_brightness"])
ember = params["colors"]["ember"]
ash = params["colors"]["ash"]
pulse = resolve_pulse(params, t, fallback_bpm=118, attack=0.22)
wave = resolve_beat_wave(params, t, fallback_bpm=118)
kick = band(params, "kick")
bass = band(params, "bass")
energy = band(params, "energy")
x_offset, ring, _ = ring_metrics(params, w)
seams = list(portal_ring_offsets().values())
drive = max(pulse, kick, bass * 0.85)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
dist = min(min((gx - s) % ring, (s - gx) % ring) for s in seams)
column = math.exp(-(dist**2) / (8.0 + 10.0 * (1.0 - heat)))
rise = (h - 1 - y) / max(h - 1, 1)
flicker = 0.75 + 0.25 * ((gx * 3 + y * 5) % 7) / 6.0
level = column * (0.35 + 0.65 * rise) * flicker
level = min(
1.0,
level * heat * (0.55 + 0.55 * drive + 0.2 * wave + 0.15 * energy),
)
bg = dim(ash, bg_b * (0.4 + 0.3 * rise))
flame = heat_color(level)
tint = dim(ember, level * 0.35)
set_pixel(
surface,
x,
y,
(
min(255, bg[0] + flame[0] + tint[0]),
min(255, bg[1] + flame[1] + tint[1]),
min(255, bg[2] + flame[2] + tint[2]),
),
)
if drive > 0.4:
for seam in seams:
local = seam - x_offset
if not (0 <= local < w):
continue
sy = max(0, h - 1 - int(drive * (h - 1)))
set_pixel(surface, local, sy, dim((255, 230, 160), drive))
if __name__ == "__main__":
run_cli(Braziers)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Rainbow comet."""
from __future__ import annotations
from leds.colors import dim, wheel
from leds.pattern import Pattern, run_cli, set_pixel, size
class Comet(Pattern):
id = "comet"
label = "Comet"
fps = 30
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
head = frame % (w + 12)
surface.fill((0, 0, 0))
for y in range(h):
for i in range(12):
x = head - i
if 0 <= x < w:
set_pixel(surface, x, y, dim(wheel(frame * 3 + i * 10), (12 - i) / 12))
if __name__ == "__main__":
run_cli(Comet)
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Stars stepping around the portal ring on each live beat."""
from __future__ import annotations
import math
from leds.bpm_limits import (
band,
resolve_beat_index,
resolve_beat_phase,
resolve_pulse,
)
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
STARS = (
0.02,
0.09,
0.17,
0.23,
0.31,
0.38,
0.47,
0.54,
0.62,
0.71,
0.79,
0.88,
0.95,
)
class Constellation(Pattern):
id = "constellation"
label = "Constellation"
fps = 30
colors = {
"star": Color("#ffe9a8"),
"link": Color("#6a90c8"),
"night": Color("#04060e"),
}
params = {
"trail": Param(0.55, min=0.1, max=1.0, step=0.05, label="Trail"),
"bg_brightness": Param(0.4, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
trail = float(extras["trail"])
bg_b = float(extras["bg_brightness"])
star = params["colors"]["star"]
link = params["colors"]["link"]
night = params["colors"]["night"]
pulse = resolve_pulse(params, t, fallback_bpm=108, attack=0.16)
phase = resolve_beat_phase(params, t, fallback_bpm=108)
step = resolve_beat_index(params, t, fallback_bpm=108)
hat = band(params, "hat")
energy = band(params, "energy")
x_offset, ring, _ = ring_metrics(params, w)
active = step % len(STARS)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
mist = 0.25 + 0.2 * math.sin(gx * 0.05 + t * 0.4) + 0.08 * energy
set_pixel(surface, x, y, dim(night, mist * bg_b))
mid = h // 2
for i, frac in enumerate(STARS):
gx = int(frac * ring) % max(ring, 1)
nxt = STARS[(i + 1) % len(STARS)]
gx2 = int(nxt * ring) % max(ring, 1)
span = (gx2 - gx) % max(ring, 1)
if span > ring // 2:
continue
age = (active - i) % len(STARS)
link_level = max(0.0, 1.0 - age / max(len(STARS) * trail, 1)) * 0.35
if link_level <= 0:
continue
for k in range(0, span, 2):
px = (gx + k) % max(ring, 1)
local = px - x_offset
if 0 <= local < w:
set_pixel(surface, local, mid, dim(link, link_level))
for i, frac in enumerate(STARS):
gx = int(frac * ring) % max(ring, 1)
local = gx - x_offset
if not (0 <= local < w):
continue
age = (active - i) % len(STARS)
if i == active:
level = 0.5 + 0.5 * max(pulse, hat)
else:
level = max(0.0, 1.0 - age / max(len(STARS) * trail, 1)) * 0.45
if level <= 0:
continue
for dx, dy in ((0, 0), (1, 0), (-1, 0), (0, 1), (0, -1)):
xx, yy = local + dx, mid + dy
if 0 <= xx < w and 0 <= yy < h:
fall = 1.0 if dx == 0 and dy == 0 else 0.45
set_pixel(surface, xx, yy, dim(star, level * fall))
if i == active and pulse > 0:
yy = max(0, mid - int(phase * mid))
set_pixel(surface, local, yy, dim(star, pulse))
if __name__ == "__main__":
run_cli(Constellation)
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Dark disc with a breathing gold corona."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
class Eclipse(Pattern):
id = "eclipse"
label = "Eclipse"
fps = 28
colors = {
"corona": Color("#ffb428"),
"flare": Color("#ff6a18"),
"void": Color("#020206"),
}
params = {
"size": Param(0.55, min=0.25, max=0.9, step=0.05, label="Disc"),
"bg_brightness": Param(0.3, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
disc = float(extras["size"])
bg_b = float(extras["bg_brightness"])
corona = params["colors"]["corona"]
flare = params["colors"]["flare"]
void = params["colors"]["void"]
pulse = resolve_pulse(params, t, fallback_bpm=88, attack=0.22)
energy = band(params, "energy")
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
reach = math.hypot(cx, cy) + 0.5
radius = (0.35 + 0.45 * disc + 0.08 * pulse) * reach
for y in range(h):
for x in range(w):
dist = math.hypot(x - cx, y - cy)
angle = math.atan2(y - cy, x - cx)
spike = 0.5 + 0.5 * math.sin(angle * 7.0 + t * 1.6)
rim = math.exp(-((dist - radius) ** 2) / (2.4 + 2.0 * pulse))
inner = 1.0 if dist < radius else math.exp(-((dist - radius) ** 2) / 1.4)
sky = dim(void, bg_b * (0.25 + 0.2 * energy))
glow = dim(corona, rim * (0.45 + 0.55 * pulse) * (0.7 + 0.3 * spike))
fire = dim(flare, rim * rim * (0.2 + 0.5 * pulse))
shade = dim(void, inner * 0.85)
set_pixel(
surface,
x,
y,
(
min(255, sky[0] + glow[0] + fire[0] + shade[0]),
min(255, sky[1] + glow[1] + fire[1] + shade[1]),
min(255, sky[2] + glow[2] + fire[2] + shade[2]),
),
)
if __name__ == "__main__":
run_cli(Eclipse)
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Rising ember sparks from the portal floor."""
from __future__ import annotations
import random
from leds.bpm_limits import resolve_pulse
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
class Embers(Pattern):
id = "embers"
label = "Embers"
fps = 30
colors = {
"spark": Color("#ff7a20"),
"ash": Color("#120806"),
}
params = {
"count": Param(14, min=4, max=28, step=1, label="Sparks"),
"bg_brightness": Param(0.35, min=0.0, max=1.0, step=0.05, label="Background"),
}
def __init__(self) -> None:
self._sparks: dict[int, list[dict[str, float]]] = {}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
wanted = max(4, int(extras["count"]))
bg_b = float(extras["bg_brightness"])
spark = params["colors"]["spark"]
ash = params["colors"]["ash"]
pulse = resolve_pulse(params, t, fallback_bpm=108, attack=0.22)
key = id(surface)
sparks = self._sparks.get(key)
if sparks is None or len(sparks) != wanted:
sparks = [
{
"x": float(random.randint(0, max(w - 1, 0))),
"y": float(random.uniform(0, h)),
"speed": random.uniform(0.12, 0.38),
"life": random.uniform(0.4, 1.0),
}
for _ in range(wanted)
]
self._sparks[key] = sparks
for y in range(h):
rise = (h - 1 - y) / max(h - 1, 1)
for x in range(w):
set_pixel(surface, x, y, dim(ash, bg_b * (0.35 + 0.4 * rise)))
for ember in sparks:
ember["y"] -= ember["speed"] * (0.7 + 0.6 * pulse)
ember["life"] -= 0.012
if ember["y"] < -1 or ember["life"] <= 0:
ember["x"] = float(random.randint(0, max(w - 1, 0)))
ember["y"] = float(h + random.uniform(0, 2))
ember["speed"] = random.uniform(0.12, 0.38)
ember["life"] = random.uniform(0.45, 1.0)
px, py = int(ember["x"]), int(ember["y"])
if 0 <= px < w and 0 <= py < h:
set_pixel(surface, px, py, dim(spark, ember["life"]))
if py + 1 < h:
set_pixel(surface, px, py + 1, dim(spark, ember["life"] * 0.35))
if __name__ == "__main__":
run_cli(Embers)
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Fire / heat rise."""
from __future__ import annotations
import random
from leds.colors import heat_color
from leds.pattern import Pattern, run_cli, set_pixel, size
class Fire(Pattern):
id = "fire"
label = "Fire"
fps = 35
def __init__(self) -> None:
self._heat: dict[int, list[float]] = {}
def draw(self, surface, t, params):
w, h = size(surface)
n = w * h
key = id(surface)
heat = self._heat.get(key)
if heat is None or len(heat) != n:
heat = [0.0] * n
self._heat[key] = heat
for x in range(w):
heat[(h - 1) * w + x] = random.random() * 0.5 + 0.5
for y in range(h - 2, -1, -1):
for x in range(w):
below = (y + 1) * w + x
left = below - 1 if x > 0 else below
right = below + 1 if x < w - 1 else below
decay = random.uniform(0.0, 0.18)
heat[y * w + x] = max(0.0, (heat[left] + heat[below] + heat[right]) / 3 - decay)
for y in range(h):
for x in range(w):
set_pixel(surface, x, y, heat_color(heat[y * w + x]))
if __name__ == "__main__":
run_cli(Fire)
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Ancient signs scrolling continuously around the portal ring."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse, resolve_scroll
from leds.colors import dim
from leds.font import blit_sequence, resolve_glyphs, sequence_width, vcenter
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
BACKGROUNDS = ("night", "wood", "stone", "solid", "stars")
def _fill_background(
surface,
*,
w: int,
h: int,
t: float,
params: dict,
style: str,
base,
bg_b: float,
pulse: float,
energy: float,
) -> None:
_ = t
name = style if style in BACKGROUNDS else "night"
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
if name == "wood":
grain = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(gx * 0.9 + y * 2.2))
frost = 0.04 + 0.12 * pulse + 0.08 * energy
bg = dim(base, grain * bg_b)
set_pixel(
surface,
x,
y,
(
min(255, bg[0] + int(frost * bg_b * 40)),
min(255, bg[1] + int(frost * bg_b * 70)),
min(255, bg[2] + int(frost * bg_b * 90)),
),
)
elif name == "stone":
grain = 0.55 + 0.45 * math.sin(gx * 0.37 + y * 1.1)
level = grain * (0.9 + 0.1 * pulse + 0.08 * energy)
set_pixel(surface, x, y, dim(base, level * bg_b))
elif name == "solid":
set_pixel(surface, x, y, dim(base, (0.9 + 0.1 * pulse) * bg_b))
elif name == "stars":
level = 0.12 + 0.08 * energy
twinkle = (gx * 17 + y * 31) % 29
if twinkle == 0:
level = 0.45 + 0.25 * pulse
elif twinkle == 7:
level = 0.28 + 0.12 * pulse
set_pixel(surface, x, y, dim(base, level * bg_b))
else: # night
veil = 0.55 + 0.45 * math.sin(gx * 0.12 + y * 0.35)
set_pixel(
surface,
x,
y,
dim(base, (0.45 + 0.55 * veil + 0.08 * pulse + 0.08 * energy) * bg_b),
)
class Glyphs(Pattern):
id = "glyphs"
label = "Glyphs"
fps = 28
colors = {
"gold": Color("#f0c050"),
"night": Color("#08060c"),
}
params = {
"background": Param(
"night",
kind="choice",
choices=BACKGROUNDS,
label="Background",
),
"text_brightness": Param(1.0, min=0.0, max=1.0, step=0.05, label="Text brightness"),
"bg_brightness": Param(0.55, min=0.0, max=1.0, step=0.05, label="Background brightness"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
pulse = resolve_pulse(params, t, fallback_bpm=92, attack=0.15)
snare = band(params, "snare")
energy = band(params, "energy")
text_b = float(extras["text_brightness"])
bg_b = float(extras["bg_brightness"])
bg_name = str(extras.get("background") or "night")
glyphs = resolve_glyphs(params, default="hieroglyphs")
gold = dim(params["colors"]["gold"], text_b * (0.85 + 0.15 * max(pulse, snare)))
night = params["colors"]["night"]
x_offset, ring, _ = ring_metrics(params, w)
gap = 2
span = sequence_width(glyphs, gap=gap)
period = max(span + gap, 1)
_fill_background(
surface,
w=w,
h=h,
t=t,
params=params,
style=bg_name,
base=night,
bg_b=bg_b,
pulse=pulse,
energy=energy,
)
scroll = resolve_scroll(
params,
t,
fallback_bpm=92,
pixels_per_beat=max(2.0, ring / 16.0),
loop=period,
)
y0 = vcenter(h, 7)
first = math.floor((x_offset + scroll) / period) - 1
last = math.ceil((x_offset + w + scroll) / period) + 1
for k in range(first, last + 1):
blit_sequence(
surface,
-scroll - x_offset + k * period,
y0,
glyphs,
gold,
gap=gap,
)
if h >= 9 and bg_name in ("night", "stone"):
line = dim(gold, 0.3 + 0.15 * pulse)
for x in range(w):
set_pixel(surface, x, h - 1, line)
if __name__ == "__main__":
run_cli(Glyphs)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Gold Latin mottoes scrolling continuously around the portal ring."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse, resolve_scroll
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
from leds.text import CHAR_HEIGHT, draw_text, text_width
MOTTOES = (
"AVE",
"SPQR",
"VENI VIDI VICI",
"CARPE DIEM",
"TEMPUS FUGIT",
"MEMENTO MORI",
"ALEA IACTA EST",
)
SEPARATOR = " * "
DEFAULT_TEXT = SEPARATOR.join(MOTTOES)
class Inscription(Pattern):
id = "inscription"
label = "Inscription"
fps = 28
colors = {
"ink": Color("#e0b44a"),
"stone": Color("#2a1c10"),
}
params = {
"text": Param(
DEFAULT_TEXT,
kind="text",
max_length=160,
label="Text",
placeholder="AVE * SPQR",
),
"text_brightness": Param(1.0, min=0.0, max=1.0, step=0.05, label="Text brightness"),
"bg_brightness": Param(0.55, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
pulse = resolve_pulse(params, t, fallback_bpm=96, attack=0.18)
bass = band(params, "bass")
energy = band(params, "energy")
ink = dim(
params["colors"]["ink"],
float(extras["text_brightness"]) * (0.85 + 0.15 * max(pulse, bass)),
)
stone = params["colors"]["stone"]
bg_b = float(extras["bg_brightness"])
banner = str(extras.get("text") or DEFAULT_TEXT).strip() or DEFAULT_TEXT
x_offset, ring, _ = ring_metrics(params, w)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
grain = 0.55 + 0.45 * math.sin(gx * 0.37 + y * 1.1)
level = grain * (0.92 + 0.08 * energy)
set_pixel(surface, x, y, dim(stone, level * bg_b))
span = text_width(banner)
if span <= 0:
return
loop = max(span + max(ring // 8, 8), ring)
scroll = resolve_scroll(
params,
t,
fallback_bpm=96,
pixels_per_beat=max(2.0, ring / 8.0),
loop=loop,
)
origin = -scroll - x_offset
y0 = max(0, (h - CHAR_HEIGHT) // 2)
draw_text(surface, banner, origin, y0, ink)
draw_text(surface, banner, origin + loop, y0, ink)
if __name__ == "__main__":
run_cli(Inscription)
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Beat-reactive lattice wards across each panel."""
from __future__ import annotations
import math
from leds.bpm_limits import resolve_beat_index, resolve_drive, resolve_pulse
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x
class Lattice(Pattern):
id = "lattice"
label = "Lattice"
fps = 30
colors = {
"line": Color("#7ad0ff"),
"node": Color("#ffe08a"),
"void": Color("#060810"),
}
params = {
"spacing": Param(4, min=3, max=8, step=1, label="Spacing"),
"bg_brightness": Param(0.4, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
spacing = max(3, int(extras["spacing"]))
bg_b = float(extras["bg_brightness"])
line = params["colors"]["line"]
node = params["colors"]["node"]
void = params["colors"]["void"]
pulse = resolve_pulse(params, t, fallback_bpm=120, attack=0.12)
drive = resolve_drive(params, t, fallback_bpm=120)
step = resolve_beat_index(params, t, fallback_bpm=120)
shift = step % spacing
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
haze = 0.28 + 0.12 * math.sin(gx * 0.08 + t * 0.5)
set_pixel(surface, x, y, dim(void, haze * bg_b))
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
on_v = (gx + shift) % spacing == 0
on_h = y % max(2, spacing // 2) == 0
if on_v or on_h:
set_pixel(surface, x, y, dim(line, 0.28 + 0.55 * drive))
if on_v and on_h:
set_pixel(surface, x, y, dim(node, 0.5 + 0.5 * pulse))
if __name__ == "__main__":
run_cli(Lattice)
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Diagonal meteors with fading trails."""
from __future__ import annotations
import random
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
class Meteor(Pattern):
id = "meteor"
label = "Meteor"
fps = 32
colors = {
"head": Color("#ffe8c8"),
"tail": Color("#ff6a28"),
"night": Color("#05040a"),
}
params = {
"count": Param(6, min=2, max=16, step=1, label="Meteors"),
"bg_brightness": Param(0.25, min=0.0, max=1.0, step=0.05, label="Background"),
}
def __init__(self) -> None:
self._showers: dict[int, list[dict[str, float]]] = {}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
wanted = max(2, int(extras["count"]))
bg_b = float(extras["bg_brightness"])
head = params["colors"]["head"]
tail = params["colors"]["tail"]
night = params["colors"]["night"]
key = id(surface)
showers = self._showers.get(key)
if showers is None or len(showers) != wanted:
showers = [
{
"x": float(random.randint(-4, w + 2)),
"y": float(random.randint(-h, h - 1)),
"speed": random.uniform(0.6, 1.6),
"len": random.randint(4, 8),
}
for _ in range(wanted)
]
showers[0]["x"] = w / 2
showers[0]["y"] = max(h / 2, 1.0)
self._showers[key] = showers
for y in range(h):
for x in range(w):
set_pixel(surface, x, y, dim(night, bg_b))
for meteor in showers:
meteor["x"] += meteor["speed"]
meteor["y"] += meteor["speed"] * 0.55
if meteor["x"] > w + 6 or meteor["y"] > h + 4:
meteor["x"] = float(random.randint(-8, w // 2))
meteor["y"] = float(random.randint(-h, -1))
meteor["speed"] = random.uniform(0.6, 1.6)
meteor["len"] = random.randint(4, 8)
length = int(meteor["len"])
for i in range(length):
px = int(meteor["x"] - i)
py = int(meteor["y"] - i * 0.55)
if not (0 <= px < w and 0 <= py < h):
continue
fade = (length - i) / length
color = head if i == 0 else dim(tail, fade)
set_pixel(surface, px, py, color)
if __name__ == "__main__":
run_cli(Meteor)
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Words of omen fading on torchlit stone, locked to live beats."""
from __future__ import annotations
import math
import re
from leds.bpm_limits import (
band,
resolve_beat_index,
resolve_beat_phase,
resolve_beat_wave,
resolve_pulse,
)
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x
from leds.text import CHAR_HEIGHT, draw_text, text_width
WORDS = (
"AVE",
"FATE",
"OMEN",
"LUX",
"NOX",
"REX",
"ASTRA",
"FIAT",
)
DEFAULT_TEXT = " ".join(WORDS)
_SPLIT = re.compile(r"[\s,/|;]+")
def _words(text: str) -> tuple[str, ...]:
parts = tuple(part for part in _SPLIT.split(text.strip().upper()) if part)
return parts or WORDS
class Oracle(Pattern):
id = "oracle"
label = "Oracle"
fps = 30
colors = {
"ink": Color("#ffce6a"),
"ember": Color("#4a1808"),
}
params = {
"beats_per_word": Param(4, min=2, max=16, step=1, label="Beats / word"),
"text": Param(
DEFAULT_TEXT,
kind="text",
max_length=160,
label="Words",
placeholder="AVE FATE OMEN",
),
"text_brightness": Param(1.0, min=0.0, max=1.0, step=0.05, label="Text brightness"),
"bg_brightness": Param(0.55, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
ink = params["colors"]["ink"]
ember = params["colors"]["ember"]
beats_per_word = max(2, int(extras.get("beats_per_word", 4)))
text_b = float(extras["text_brightness"])
bg_b = float(extras["bg_brightness"])
words = _words(str(extras.get("text") or DEFAULT_TEXT))
pulse = resolve_pulse(params, t, fallback_bpm=80, attack=0.2)
wave = resolve_beat_wave(params, t, fallback_bpm=80)
kick = band(params, "kick")
energy = band(params, "energy")
idx = (resolve_beat_index(params, t, fallback_bpm=80) // beats_per_word) % len(words)
local_beat = (
resolve_beat_index(params, t, fallback_bpm=80) % beats_per_word
) + resolve_beat_phase(params, t, fallback_bpm=80)
if local_beat < 0.35:
alpha = local_beat / 0.35
elif local_beat > beats_per_word - 0.8:
alpha = max(0.0, (beats_per_word - local_beat) / 0.8)
else:
alpha = 1.0
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
heat = 0.35 + 0.65 * (
0.5
+ 0.5
* math.sin(gx * 0.45 + t * 8.0)
* math.cos(y * 0.9 - t * 3.1)
)
heat = min(1.0, heat + 0.3 * max(pulse, kick) * wave + 0.15 * energy)
edge = 1.0 - abs((y / max(h - 1, 1)) - 0.5) * 0.5
set_pixel(surface, x, y, dim(ember, heat * edge * bg_b))
word = words[idx]
tw = text_width(word)
x0 = (w - tw) // 2
y0 = max(0, (h - CHAR_HEIGHT) // 2)
if alpha > 0:
draw_text(
surface,
word,
x0,
y0,
dim(ink, alpha * text_b * (0.8 + 0.2 * max(pulse, kick))),
)
if __name__ == "__main__":
run_cli(Oracle)
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Plasma field."""
from __future__ import annotations
import math
from leds.colors import hsv_to_rgb
from leds.pattern import Pattern, run_cli, set_pixel, size
class Plasma(Pattern):
id = "plasma"
label = "Plasma"
fps = 25
def draw(self, surface, t, params):
w, h = size(surface)
phase = t * self.fps * 0.08
for y in range(h):
for x in range(w):
v = (
math.sin(x * 0.15 + phase)
+ math.sin(y * 0.2 - phase * 1.3)
+ math.sin((x + y) * 0.1 + phase * 0.7)
) / 3.0
set_pixel(surface, x, y, hsv_to_rgb((v + 1) / 2, 1.0, 1.0))
if __name__ == "__main__":
run_cli(Plasma)
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Ritual procession of runes circling the full hexagonal portal."""
from __future__ import annotations
import math
from leds.bpm_limits import (
band,
resolve_beat_index,
resolve_beat_phase,
resolve_pulse,
)
from leds.colors import dim
from leds.font import blit_glyph, font_param, glyph_width, resolve_glyphs, vcenter
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Procession(Pattern):
id = "procession"
label = "Procession"
fps = 30
colors = {
"mark": Color("#e8c56a"),
"path": Color("#1a1008"),
"accent": Color("#6ec8ff"),
}
params = {
"font": font_param("mixed"),
"density": Param(0.55, min=0.2, max=1.0, step=0.05, label="Density"),
"bg_brightness": Param(0.45, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
density = float(extras["density"])
bg_b = float(extras["bg_brightness"])
mark = params["colors"]["mark"]
path = params["colors"]["path"]
accent = params["colors"]["accent"]
pulse = resolve_pulse(params, t, fallback_bpm=110, attack=0.14)
phase = resolve_beat_phase(params, t, fallback_bpm=110)
step = resolve_beat_index(params, t, fallback_bpm=110)
kick = band(params, "kick")
energy = band(params, "energy")
x_offset, ring, _ = ring_metrics(params, w)
glyphs = resolve_glyphs(params, default="mixed")
spacing = max(6, int(14 * (1.35 - density)))
scroll = int((step + phase) * spacing) % max(ring, 1)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
grain = 0.35 + 0.65 * (0.5 + 0.5 * math.sin(gx * 0.21 + y * 1.4))
torch = (0.08 * pulse + 0.1 * kick) * (1.0 - abs(y - h / 2) / max(h / 2, 1))
set_pixel(surface, x, y, dim(path, (grain + torch + 0.05 * energy) * bg_b))
y0 = vcenter(h, 7)
for i, glyph in enumerate(glyphs):
gw = glyph_width(glyph)
gx0 = (i * spacing - scroll) % max(ring, 1)
for base in (gx0, gx0 - ring, gx0 + ring):
local = base - x_offset
if local + gw < 0 or local >= w:
continue
color = accent if (i + step) % 5 == 0 else mark
blit_glyph(surface, local, y0, glyph, dim(color, 0.65 + 0.35 * max(pulse, kick)))
head = int((step + phase) * (ring / 8.0)) % max(ring, 1)
local_head = head - x_offset
if 0 <= local_head < w:
for dy in range(h):
falloff = 1.0 - abs(dy - h / 2) / (h / 2 + 0.1)
set_pixel(
surface,
local_head,
dy,
dim(accent, (0.35 + 0.65 * max(pulse, energy)) * falloff),
)
if __name__ == "__main__":
run_cli(Procession)
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Breathing solid pulse."""
from __future__ import annotations
import math
from leds.colors import dim
from leds.pattern import Color, Pattern, run_cli
class Pulse(Pattern):
id = "pulse"
label = "Pulse"
fps = 30
colors = {"color": Color("#7800b4")}
def draw(self, surface, t, params):
frame = t * self.fps
level = (math.sin(frame * 0.12) + 1) / 2
surface.fill(dim(params["colors"]["color"], 0.15 + level * 0.85))
if __name__ == "__main__":
run_cli(Pulse)
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Falling rain drops."""
from __future__ import annotations
import random
from leds.colors import dim
from leds.pattern import Color, Pattern, run_cli, set_pixel, size
class Rain(Pattern):
id = "rain"
label = "Rain"
fps = 30
colors = {"drop": Color("#00dc50")}
def __init__(self) -> None:
self._drops: dict[int, list[dict]] = {}
def draw(self, surface, t, params):
w, h = size(surface)
key = id(surface)
drops = self._drops.get(key)
if drops is None:
drops = [
{
"x": random.randint(0, w - 1),
"y": random.randint(-h, 0),
"speed": random.randint(1, 3),
}
for _ in range(max(w // 2, 1))
]
self._drops[key] = drops
surface.fill((0, 0, 0))
color = params["colors"]["drop"]
for drop in drops:
drop["y"] += drop["speed"]
if drop["y"] >= h:
drop["y"] = random.randint(-3, -1)
drop["x"] = random.randint(0, w - 1)
drop["speed"] = random.randint(1, 3)
x, y = drop["x"], int(drop["y"])
if 0 <= y < h:
set_pixel(surface, x, y, color)
if y > 0:
set_pixel(surface, x, y - 1, dim(color, 0.4))
if __name__ == "__main__":
run_cli(Rain)
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Rainbow scroll across the portal."""
from __future__ import annotations
from leds.colors import wheel
from leds.pattern import Pattern, is_matrix, run_cli
class Rainbow(Pattern):
id = "rainbow"
label = "Rainbow"
fps = 30
def draw(self, surface, t, params):
frame = int(t * self.fps)
if not is_matrix(surface):
n = len(surface)
for i in range(n):
surface[i] = wheel(frame * 2 + i * 256 // max(n, 1))
return
w, h = surface.width, surface.height
strip = getattr(surface, "strip", None)
if strip is not None and hasattr(surface, "index_at"):
for y in range(h):
for x in range(w):
strip[surface.index_at(x, y)] = wheel(x * 3 + y * 8 + frame * 2)
return
for y in range(h):
for x in range(w):
surface[x, y] = wheel(x * 3 + y * 8 + frame * 2)
if __name__ == "__main__":
run_cli(Rainbow)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Temple rite: beat-locked rings with energy racing the portal perimeter."""
from __future__ import annotations
import math
from leds.bpm_limits import (
band,
resolve_beat_index,
resolve_beat_phase,
resolve_pulse,
)
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Rite(Pattern):
id = "rite"
label = "Rite"
fps = 32
colors = {
"gold": Color("#f0c060"),
"blood": Color("#6a1208"),
"void": Color("#060408"),
}
params = {
"intensity": Param(0.85, min=0.2, max=1.0, step=0.05, label="Intensity"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
intensity = float(extras["intensity"])
gold = params["colors"]["gold"]
blood = params["colors"]["blood"]
void = params["colors"]["void"]
pulse = resolve_pulse(params, t, fallback_bpm=120, attack=0.18)
phase = resolve_beat_phase(params, t, fallback_bpm=120)
step = resolve_beat_index(params, t, fallback_bpm=120)
kick = band(params, "kick")
bass = band(params, "bass")
energy = band(params, "energy")
x_offset, ring, panel = ring_metrics(params, w)
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
radius = phase * (max(w, h) * 0.85)
hit = max(pulse, kick, bass * 0.7)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
dist = math.hypot(x - cx, y - cy)
ring_glow = math.exp(-((dist - radius) ** 2) / 2.8)
floor = 0.12 + 0.1 * math.sin(gx * 0.08 + t * 0.7) + 0.08 * energy
base = dim(void, floor)
warm = dim(
blood,
(0.25 + 0.55 * hit) * intensity * (1.0 - dist / (max(w, h))),
)
rim = dim(gold, ring_glow * intensity * (0.4 + 0.6 * hit))
set_pixel(
surface,
x,
y,
(
min(255, base[0] + warm[0] + rim[0]),
min(255, base[1] + warm[1] + rim[1]),
min(255, base[2] + warm[2] + rim[2]),
),
)
courier = int(((step + phase) / 8.0) * ring) % max(ring, 1)
for dx in range(-2, 3):
gx = (courier + dx) % max(ring, 1)
local = gx - x_offset
if not (0 <= local < w):
continue
level = (1.0 - abs(dx) / 3.0) * (0.45 + 0.55 * hit) * intensity
for y in range(h):
edge = 1.0 - abs(y - cy) / (cy + 0.5)
set_pixel(surface, local, y, dim(gold, level * edge))
face = step % 5
if panel == face:
for x in range(w):
set_pixel(surface, x, 0, dim(gold, 0.2 + 0.4 * hit))
set_pixel(surface, x, h - 1, dim(gold, 0.15 + 0.35 * hit))
if __name__ == "__main__":
run_cli(Rite)
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Full-height band rolling left to right."""
from __future__ import annotations
from leds.colors import dim, wheel
from leds.pattern import Pattern, run_cli, set_pixel, size
class Rolling(Pattern):
id = "rolling"
label = "Rolling"
fps = 30
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
tail = 10
head = frame % (w + tail)
surface.fill((0, 0, 0))
for y in range(h):
hue_shift = y * 18 + frame * 3
for i in range(tail):
x = head - i
if 0 <= x < w:
set_pixel(surface, x, y, dim(wheel(hue_shift + i * 8), (tail - i) / tail))
if __name__ == "__main__":
run_cli(Rolling)
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Elder Futhark stave drifting continuously around the portal ring."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse, resolve_scroll
from leds.colors import dim
from leds.font import blit_sequence, font_param, resolve_glyphs, sequence_width, vcenter
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Runes(Pattern):
id = "runes"
label = "Runes"
fps = 28
colors = {
"carve": Color("#7ec8e8"),
"wood": Color("#120c08"),
}
params = {
"font": font_param("runes"),
"text_brightness": Param(1.0, min=0.0, max=1.0, step=0.05, label="Text brightness"),
"bg_brightness": Param(0.55, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
pulse = resolve_pulse(params, t, fallback_bpm=100, attack=0.16)
hat = band(params, "hat")
energy = band(params, "energy")
text_b = float(extras["text_brightness"])
bg_b = float(extras["bg_brightness"])
carve = dim(params["colors"]["carve"], text_b * (0.85 + 0.15 * max(pulse, hat)))
wood = params["colors"]["wood"]
x_offset, ring, _ = ring_metrics(params, w)
glyphs = resolve_glyphs(params, default="runes")
gap = 2
span = sequence_width(glyphs, gap=gap)
loop = max(span + gap, ring)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
grain = 0.4 + 0.6 * (0.5 + 0.5 * math.sin(gx * 0.9 + y * 2.2))
frost = (0.04 + 0.08 * energy) * bg_b
bg = dim(wood, grain * bg_b)
set_pixel(
surface,
x,
y,
(
min(255, bg[0] + int(frost * 40)),
min(255, bg[1] + int(frost * 70)),
min(255, bg[2] + int(frost * 90)),
),
)
scroll = resolve_scroll(
params,
t,
fallback_bpm=100,
pixels_per_beat=max(2.0, ring / 10.0),
loop=loop,
)
origin = -scroll - x_offset
y0 = vcenter(h, 7)
blit_sequence(surface, origin, y0, glyphs, dim(carve, 0.9 + 0.1 * pulse), gap=gap, wrap=loop)
for i in range(3):
gx = (scroll * 3 + i * 17 + x_offset) % max(ring, 1)
local = gx - x_offset
if not (0 <= local < w):
continue
gy = (i * 3 + scroll // 5) % max(h, 1)
if (gx * 13 + gy * 29 + scroll) % 11 == 0:
set_pixel(surface, local, gy, dim((220, 240, 255), text_b))
if __name__ == "__main__":
run_cli(Runes)
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Cylon / scanner beam."""
from __future__ import annotations
from leds.colors import dim
from leds.pattern import Color, Pattern, run_cli, set_pixel, size
class Scanner(Pattern):
id = "scanner"
label = "Scanner"
fps = 35
colors = {"beam": Color("#ff0000")}
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
pos = frame % max(w * 2 - 2, 1)
if pos >= w:
pos = w * 2 - 2 - pos
surface.fill((0, 0, 0))
color = params["colors"]["beam"]
for y in range(h):
for dx in range(-3, 4):
x = pos + dx
if 0 <= x < w:
set_pixel(surface, x, y, dim(color, 1.0 - abs(dx) / 4.0))
if __name__ == "__main__":
run_cli(Scanner)
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""A serpent coiling around the full portal ring."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Serpent(Pattern):
id = "serpent"
label = "Serpent"
fps = 30
colors = {
"scale": Color("#2ecf6a"),
"belly": Color("#d4c45a"),
"eye": Color("#ff4020"),
"void": Color("#06080a"),
}
params = {
"length": Param(0.35, min=0.15, max=0.7, step=0.05, label="Length"),
"bg_brightness": Param(0.35, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
length = float(extras["length"])
bg_b = float(extras["bg_brightness"])
scale = params["colors"]["scale"]
belly = params["colors"]["belly"]
eye = params["colors"]["eye"]
void = params["colors"]["void"]
pulse = resolve_pulse(params, t, fallback_bpm=104, attack=0.14)
energy = band(params, "energy")
x_offset, ring, _ = ring_metrics(params, w)
body = max(8, int(ring * length))
head = int(t * (ring * 0.22)) % max(ring, 1)
mid = (h - 1) / 2.0
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
grain = 0.3 + 0.2 * math.sin(gx * 0.11 + y)
set_pixel(surface, x, y, dim(void, (grain + 0.1 * energy) * bg_b))
for i in range(body):
gx = (head - i) % max(ring, 1)
local = gx - x_offset
if not (0 <= local < w):
continue
wave = math.sin((head - i) * 0.28 + t * 3.2) * (1.6 + 0.8 * pulse)
py = int(mid + wave)
fade = 1.0 - i / body
color = eye if i < 2 else (belly if i % 5 == 0 else scale)
level = fade * (0.55 + 0.45 * (pulse if i < 4 else 1.0))
for dy in range(-1, 2):
yy = py + dy
if 0 <= yy < h:
thick = 1.0 if dy == 0 else 0.45
set_pixel(surface, local, yy, dim(color, level * thick))
if __name__ == "__main__":
run_cli(Serpent)
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Solid fill cycling through declared colours."""
from __future__ import annotations
from leds.pattern import Color, Pattern, run_cli
class Solid(Pattern):
id = "solid"
label = "Solid"
fps = 1
colors = {
"a": Color("#ff0000"),
"b": Color("#00ff00"),
"c": Color("#0000ff"),
}
def draw(self, surface, t, params):
palette = list(params["colors"].values()) or [(255, 0, 0)]
frame = int(t * self.fps)
surface.fill(palette[frame % len(palette)])
if __name__ == "__main__":
run_cli(Solid)
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Twinkling sparkles."""
from __future__ import annotations
import random
from leds.pattern import Color, Pattern, each_pixel, is_matrix, run_cli, set_pixel, size
class Sparkle(Pattern):
id = "sparkle"
label = "Sparkle"
fps = 40
colors = {"spark": Color("#dcdcff")}
def draw(self, surface, t, params):
for x, y in each_pixel(surface):
if is_matrix(surface):
r, g, b = surface[x, y]
else:
r, g, b = surface[x]
set_pixel(surface, x, y, (r >> 2, g >> 2, b >> 2))
w, h = size(surface)
color = params["colors"]["spark"]
for _ in range(6):
set_pixel(surface, random.randint(0, w - 1), random.randint(0, h - 1), color)
if __name__ == "__main__":
run_cli(Sparkle)
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Radar-style spinning line from panel centre."""
from __future__ import annotations
import math
from leds.colors import dim, wheel
from leds.pattern import Pattern, is_matrix, iter_line, run_cli, set_pixel, size
class SpinLine(Pattern):
id = "spin_line"
label = "Spin line"
fps = 28
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
surface.fill((0, 0, 0))
if not is_matrix(surface):
n = len(surface)
pos = int((frame * 3) % max(n, 1))
for i in range(8):
idx = (pos - i) % n
surface[idx] = dim(wheel(frame * 5 + i * 20), (8 - i) / 8)
return
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
angle = frame * 0.14
reach = max(w, h) * 1.2
x1 = int(cx + math.cos(angle) * reach)
y1 = int(cy + math.sin(angle) * reach)
color = wheel(frame * 4)
for x, y in iter_line(int(cx), int(cy), x1, y1):
if 0 <= x < w and 0 <= y < h:
set_pixel(surface, x, y, color)
hub_x, hub_y = int(cx), int(cy)
if 0 <= hub_x < w and 0 <= hub_y < h:
set_pixel(surface, hub_x, hub_y, (255, 255, 255))
if __name__ == "__main__":
run_cli(SpinLine)
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Scrolling colour bands."""
from __future__ import annotations
from leds.pattern import Color, Pattern, run_cli, set_pixel, size
class Stripes(Pattern):
id = "stripes"
label = "Stripes"
fps = 25
colors = {
"a": Color("#c80000"),
"b": Color("#00c800"),
"c": Color("#0000c8"),
}
def draw(self, surface, t, params):
w, h = size(surface)
frame = int(t * self.fps)
offset = frame % max(w, 1)
palette = [
params["colors"]["a"],
params["colors"]["b"],
params["colors"]["c"],
]
for y in range(h):
for x in range(w):
band = (x + offset) // 3 % 3
set_pixel(surface, x, y, palette[band])
if __name__ == "__main__":
run_cli(Stripes)
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Rising and falling tide with a foaming crest."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_beat_wave, resolve_pulse
from leds.colors import blend, dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Tide(Pattern):
id = "tide"
label = "Tide"
fps = 28
colors = {
"water": Color("#1a6ad0"),
"foam": Color("#d8f0ff"),
"sand": Color("#1a140c"),
}
params = {
"depth": Param(0.65, min=0.25, max=1.0, step=0.05, label="Depth"),
"bg_brightness": Param(0.4, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
depth = float(extras["depth"])
bg_b = float(extras["bg_brightness"])
water = params["colors"]["water"]
foam = params["colors"]["foam"]
sand = params["colors"]["sand"]
pulse = resolve_pulse(params, t, fallback_bpm=84, attack=0.2)
wave = resolve_beat_wave(params, t, fallback_bpm=84)
energy = band(params, "energy")
_, ring, _ = ring_metrics(params, w)
mean = (h - 1) * (0.25 + 0.55 * (1.0 - depth) + 0.12 * wave)
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
crest = mean + 1.4 * math.sin(gx * (math.tau / max(ring, 1)) * 3.0 + t * 1.8)
crest += 0.6 * math.sin(gx * 0.21 - t * 2.4)
below = y - crest
if below >= 0:
mix = min(1.0, 0.35 + 0.65 * (below / max(h, 1)) + 0.15 * pulse)
shade = blend(water, foam, 0.08 + 0.2 * energy)
if below < 1.2:
set_pixel(surface, x, y, dim(foam, 0.55 + 0.45 * pulse))
else:
set_pixel(surface, x, y, dim(shade, mix))
else:
grain = 0.35 + 0.2 * math.sin(gx * 0.17 + y)
set_pixel(surface, x, y, dim(sand, grain * bg_b))
if __name__ == "__main__":
run_cli(Tide)
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Mist veil flowing around the portal, driven by live beat and FFT energy."""
from __future__ import annotations
import math
from leds.bpm_limits import (
band,
resolve_bpm,
resolve_beat_wave,
resolve_pulse,
)
from leds.colors import dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
from leds.portal_span import global_x, ring_metrics
class Veil(Pattern):
id = "veil"
label = "Veil"
fps = 28
colors = {
"mist": Color("#9bb7d8"),
"gold": Color("#c9a24a"),
"shadow": Color("#0a0810"),
}
params = {
"flow": Param(0.65, min=0.15, max=1.0, step=0.05, label="Flow"),
"bg_brightness": Param(0.5, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
bpm = resolve_bpm(params, fallback=90)
flow = float(extras["flow"])
bg_b = float(extras["bg_brightness"])
mist = params["colors"]["mist"]
gold = params["colors"]["gold"]
shadow = params["colors"]["shadow"]
pulse = resolve_pulse(params, t, fallback_bpm=90, attack=0.2)
wave = resolve_beat_wave(params, t, fallback_bpm=90)
energy = band(params, "energy")
flux = band(params, "flux")
hat = band(params, "hat")
_, ring, _ = ring_metrics(params, w)
drift = t * (ring * (bpm / 60.0) / 8.0) * flow
for y in range(h):
for x in range(w):
gx = global_x(x, params, w)
a = math.sin((gx + drift) * 0.07 + y * 0.55)
b = math.sin((gx - drift * 0.6) * 0.13 - y * 0.9 + t * 0.8)
c = math.sin((gx + drift * 0.3) * 0.03 + t * 1.1)
field = 0.5 + 0.5 * (0.5 * a + 0.35 * b + 0.15 * c)
field = min(
1.0,
field + 0.22 * pulse * wave + 0.2 * energy + 0.12 * flux,
)
edge = 1.0 - abs((y / max(h - 1, 1)) - 0.5) * 0.55
base = dim(shadow, bg_b * (0.35 + 0.25 * field))
fog = dim(mist, field * edge * (0.35 + 0.45 * flow + 0.15 * hat))
gild = dim(gold, field * field * (0.1 + 0.3 * pulse) * edge)
set_pixel(
surface,
x,
y,
(
min(255, base[0] + fog[0] + gild[0]),
min(255, base[1] + fog[1] + gild[1]),
min(255, base[2] + fog[2] + gild[2]),
),
)
if __name__ == "__main__":
run_cli(Veil)
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Spinning portal vortex with a gold rim."""
from __future__ import annotations
import math
from leds.bpm_limits import band, resolve_pulse
from leds.colors import blend, dim
from leds.pattern import Color, Param, Pattern, run_cli, set_pixel, size
class Vortex(Pattern):
id = "vortex"
label = "Vortex"
fps = 30
colors = {
"core": Color("#6a20ff"),
"rim": Color("#f0c060"),
"void": Color("#05030a"),
}
params = {
"twist": Param(0.75, min=0.2, max=1.0, step=0.05, label="Twist"),
"bg_brightness": Param(0.4, min=0.0, max=1.0, step=0.05, label="Background"),
}
def draw(self, surface, t, params):
w, h = size(surface)
extras = params["extras"]
twist = float(extras["twist"])
bg_b = float(extras["bg_brightness"])
core = params["colors"]["core"]
rim = params["colors"]["rim"]
void = params["colors"]["void"]
pulse = resolve_pulse(params, t, fallback_bpm=112, attack=0.16)
energy = band(params, "energy")
kick = band(params, "kick")
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
reach = math.hypot(cx, cy) + 0.5
spin = t * (1.4 + 2.2 * twist)
for y in range(h):
for x in range(w):
dx = x - cx
dy = y - cy
dist = math.hypot(dx, dy) / max(reach, 1.0)
angle = math.atan2(dy, dx) + spin + dist * (3.0 + 4.0 * twist)
arm = 0.5 + 0.5 * math.sin(angle * 3.0)
hole = max(0.0, 1.0 - dist * (1.15 - 0.25 * pulse))
band_glow = math.exp(-((dist - (0.55 + 0.2 * pulse)) ** 2) / 0.08)
mix = blend(void, core, min(1.0, arm * hole + 0.2 * energy))
gold = dim(rim, band_glow * (0.45 + 0.55 * max(pulse, kick)))
base = dim(mix, (0.35 + 0.65 * hole) * (0.55 + 0.45 * bg_b))
set_pixel(
surface,
x,
y,
(
min(255, base[0] + gold[0]),
min(255, base[1] + gold[1]),
min(255, base[2] + gold[2]),
),
)
if __name__ == "__main__":
run_cli(Vortex)
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Sine-wave field."""
from __future__ import annotations
import math
from leds.colors import dim
from leds.pattern import Color, Pattern, run_cli, set_pixel, size
class Wave(Pattern):
id = "wave"
label = "Wave"
fps = 25
colors = {"color": Color("#0050c8")}
def draw(self, surface, t, params):
w, h = size(surface)
frame = t * self.fps
color = params["colors"]["color"]
for y in range(h):
for x in range(w):
wave = math.sin(x * 0.25 + frame * 0.15) * math.cos(y * 0.35 - frame * 0.1)
set_pixel(surface, x, y, dim(color, (wave + 1) / 2))
if __name__ == "__main__":
run_cli(Wave)
+1 -10
View File
@@ -3,20 +3,11 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Portal Simulator</title>
<title>Portal</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>
+379
View File
@@ -0,0 +1,379 @@
/** @typedef {{ index: number, width: number, height: number, position: string, label?: string }} PanelConfig */
/** @typedef {{ name: string, label: string, default: string }} ColorSpec */
/** @typedef {{ name: string, label: string, type?: 'float'|'text'|'choice'|'bool', default: number|string|boolean, min?: number, max?: number, step?: number, max_length?: number, placeholder?: string, choices?: string[] }} ParamSpec */
/** @typedef {{ id: string, label: string, fps: number, colors: ColorSpec[], params: ParamSpec[] }} PatternSchema */
/** @typedef {{ frame: number, animation?: string, pattern?: string, playing?: boolean, panels: Array<{ index: number, width: number, height: number, rgb: number[] }> }} FramePayload */
/** @typedef {{ pattern: string, playing: boolean, blackout?: boolean, brightness: number, global_brightness?: number, effective_brightness?: number, speed: number, colors: Record<string, string>, extras: Record<string, number|string>, frame: number }} PlayerState */
/** @typedef {{ id: string, name: string, pattern: string, brightness: number, speed: number, colors: Record<string, string>, extras: Record<string, number|string> }} Preset */
/** @typedef {{ preset_id: string, duration_s: number }} SequenceStep */
/** @typedef {{ id: string, name: string, loop: boolean, steps: SequenceStep[] }} Sequence */
/** @typedef {{ active: boolean, sequence_id: string | null, step_index: number }} SequenceStatus */
/** @typedef {{ running: boolean, bpm: number|null, last_beat_ts?: number|null, beat_seq?: number, beat_type?: string, beat_type_confidence?: number, bar_beat?: number, beats_per_bar?: number, is_downbeat?: boolean, phase_confidence?: number, bar_phase_readout?: string, error?: string|null, device?: string|number|null, input_level?: number, mode?: string, kick?: number, snare?: number, hat?: number, bass?: number, energy?: number, flux?: number, strength?: number }} AudioStatus */
/** @typedef {{ id: string|number, name?: string, display_name?: string, label?: string }} AudioDevice */
/** @typedef {{ global_brightness?: number, fallback_bpm?: number, audio_gain?: number }} PortalSettings */
/** @typedef {{ panels: PanelConfig[], patterns: PatternSchema[], animations?: string[], defaultFps?: Record<string, number>, defaultBrightness: number, defaultSpeed?: number, player?: PlayerState, presets?: Preset[], sequences?: Sequence[], sequence?: SequenceStatus, audio?: AudioStatus, settings?: PortalSettings, errors?: Record<string, string> }} 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();
}
/**
* @returns {Promise<PlayerState>}
*/
export async function fetchPlayer() {
const res = await fetch("/api/player");
if (!res.ok) throw new Error(`player ${res.status}`);
return res.json();
}
/**
* @param {Partial<PlayerState> & { blackout?: boolean }} body
* @returns {Promise<PlayerState>}
*/
export async function putPlayer(body) {
const res = await fetch("/api/player", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status}`;
try {
const payload = await res.json();
if (payload.detail) detail = payload.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
/**
* @returns {Promise<Preset[]>}
*/
export async function fetchPresets() {
const res = await fetch("/api/presets");
if (!res.ok) throw new Error(`presets ${res.status}`);
return res.json();
}
/**
* @param {{ name?: string, from_player?: boolean } & Partial<Preset>} body
* @returns {Promise<Preset>}
*/
export async function createPreset(body) {
const res = await fetch("/api/presets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status}`;
try {
const payload = await res.json();
if (payload.detail) detail = payload.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
/**
* @param {string} id
* @param {Partial<Preset>} body
* @returns {Promise<Preset>}
*/
export async function updatePreset(id, body) {
const res = await fetch(`/api/presets/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let detail = `${res.status}`;
try {
const payload = await res.json();
if (payload.detail) detail = payload.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
/**
* @param {string} id
* @returns {Promise<void>}
*/
export async function deletePreset(id) {
const res = await fetch(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) throw new Error(`delete preset ${res.status}`);
}
/**
* @param {string} id
* @returns {Promise<{ player: PlayerState, preset: Preset }>}
*/
export async function applyPreset(id) {
const res = await fetch(`/api/presets/${encodeURIComponent(id)}/apply`, { method: "POST" });
if (!res.ok) {
let detail = `${res.status}`;
try {
const payload = await res.json();
if (payload.detail) detail = payload.detail;
} catch {
/* ignore */
}
throw new Error(detail);
}
return res.json();
}
/**
* @param {Response} res
* @param {string} fallback
*/
async function _errorDetail(res, fallback) {
let detail = fallback;
try {
const payload = await res.json();
if (payload.detail) detail = payload.detail;
} catch {
/* ignore */
}
return detail;
}
/**
* @returns {Promise<Sequence[]>}
*/
export async function fetchSequences() {
const res = await fetch("/api/sequences");
if (!res.ok) throw new Error(`sequences ${res.status}`);
return res.json();
}
/**
* @param {{ name?: string, loop?: boolean, steps: SequenceStep[] }} body
* @returns {Promise<Sequence>}
*/
export async function createSequence(body) {
const res = await fetch("/api/sequences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`));
return res.json();
}
/**
* @param {string} id
* @param {Partial<Sequence>} body
* @returns {Promise<Sequence>}
*/
export async function updateSequence(id, body) {
const res = await fetch(`/api/sequences/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`));
return res.json();
}
/**
* @param {string} id
* @returns {Promise<void>}
*/
export async function deleteSequence(id) {
const res = await fetch(`/api/sequences/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok) throw new Error(`delete sequence ${res.status}`);
}
/**
* @param {string} id
* @returns {Promise<{ sequence: SequenceStatus, item: Sequence }>}
*/
export async function playSequence(id) {
const res = await fetch(`/api/sequences/${encodeURIComponent(id)}/play`, { method: "POST" });
if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`));
return res.json();
}
/**
* @returns {Promise<{ sequence: SequenceStatus }>}
*/
export async function stopSequence() {
const res = await fetch("/api/sequences/stop", { method: "POST" });
if (!res.ok) throw new Error(await _errorDetail(res, `${res.status}`));
return res.json();
}
/**
* @returns {Promise<PortalSettings>}
*/
export async function fetchSettings() {
const res = await fetch("/api/settings");
if (!res.ok) throw new Error(await _errorDetail(res, `settings ${res.status}`));
return res.json();
}
/**
* @param {Partial<PortalSettings>} body
* @returns {Promise<PortalSettings>}
*/
export async function putSettings(body) {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(await _errorDetail(res, `settings ${res.status}`));
return res.json();
}
/**
* @returns {Promise<AudioDevice[]>}
*/
export async function fetchAudioDevices() {
const res = await fetch("/api/audio/devices");
if (!res.ok) throw new Error(await _errorDetail(res, `audio devices ${res.status}`));
const payload = await res.json();
return payload.devices || [];
}
/**
* @returns {Promise<AudioStatus>}
*/
export async function fetchAudioStatus() {
const res = await fetch("/api/audio/status");
if (!res.ok) throw new Error(await _errorDetail(res, `audio status ${res.status}`));
const payload = await res.json();
return payload.status || payload;
}
/**
* @param {string|number|null} [device]
* @returns {Promise<AudioStatus>}
*/
export async function startAudio(device = null) {
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(device == null || device === "" ? {} : { device }),
});
if (!res.ok) throw new Error(await _errorDetail(res, `audio start ${res.status}`));
const payload = await res.json();
return payload.status || payload;
}
/**
* @returns {Promise<AudioStatus>}
*/
export async function stopAudio() {
const res = await fetch("/api/audio/stop", { method: "POST" });
if (!res.ok) throw new Error(await _errorDetail(res, `audio stop ${res.status}`));
const payload = await res.json();
return payload.status || payload;
}
/**
* @returns {Promise<AudioStatus>}
*/
export async function resetAudio() {
const res = await fetch("/api/audio/reset", { method: "POST" });
if (!res.ok) throw new Error(await _errorDetail(res, `audio reset ${res.status}`));
const payload = await res.json();
return payload.status || payload;
}
export class PreviewStream {
/** @param {(payload: FramePayload) => void} onFrame */
constructor(onFrame) {
this._onFrame = onFrame;
this._source = null;
this._retry = null;
/** @type {(() => void) | null} */
this.onDisconnect = null;
}
start() {
this.stop();
this._source = new EventSource("/api/preview");
this._source.onmessage = (event) => {
this._onFrame(JSON.parse(event.data));
};
this._source.onerror = () => {
this.onDisconnect?.();
this.stop();
this._retry = setTimeout(() => this.start(), 1500);
};
}
stop() {
if (this._retry) {
clearTimeout(this._retry);
this._retry = null;
}
if (this._source) {
this._source.close();
this._source = null;
}
}
}
export class EventStream {
/** @param {(payload: object) => void} onEvent */
constructor(onEvent) {
this._onEvent = onEvent;
this._source = null;
this._retry = null;
}
start() {
this.stop();
this._source = new EventSource("/api/events");
this._source.onmessage = (event) => {
this._onEvent(JSON.parse(event.data));
};
this._source.onerror = () => {
this.stop();
this._retry = setTimeout(() => this.start(), 1500);
};
}
stop() {
if (this._retry) {
clearTimeout(this._retry);
this._retry = null;
}
if (this._source) {
this._source.close();
this._source = null;
}
}
}
+282
View File
@@ -0,0 +1,282 @@
import {
EventStream,
PreviewStream,
applyPreset,
createPreset,
createSequence,
deletePreset,
deleteSequence,
fetchConfig,
playSequence,
updatePreset,
updateSequence,
} from "../api.js";
import "./portal-controls.js";
const APP_STYLES = `
:host {
display: flex;
justify-content: center;
align-items: stretch;
min-height: 100vh;
--border: #2a3044;
--panel: #12151f;
--text: #e8ecf7;
--muted: #8b93a8;
--accent: #5b8cff;
}
portal-controls {
width: 100%;
}
.status {
position: fixed;
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;
}
`;
export class PortalApp extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
/** @type {PreviewStream | null} */
this._preview = null;
/** @type {EventStream | null} */
this._events = null;
/** @type {import('./portal-controls.js').PortalControls | null} */
this._controls = null;
this._applyingRemote = false;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${APP_STYLES}</style>
<portal-controls></portal-controls>
<div class="status">Loading…</div>
`;
this._controls = root.querySelector("portal-controls");
this._status = root.querySelector(".status");
this.addEventListener("portal-preset-apply", (e) => this._applyPreset(e.detail.id));
this.addEventListener("portal-preset-editor-save", (e) => this._saveFromEditor(e.detail));
this.addEventListener("portal-preset-editor-delete", (e) => this._deletePreset(e.detail));
this.addEventListener("portal-preset-editor-live", (e) => {
this._status.textContent = `Preview · ${e.detail.draft.name}`;
});
this.addEventListener("portal-preset-editor-error", (e) => {
this._status.textContent = `Error: ${e.detail.message}`;
});
this.addEventListener("portal-sequence-play", (e) => this._playSequence(e.detail.id));
this.addEventListener("portal-sequence-editor-save", (e) => this._saveSequence(e.detail));
this.addEventListener("portal-sequence-editor-delete", (e) => this._deleteSequence(e.detail));
this._init();
}
disconnectedCallback() {
this._preview?.stop();
this._events?.stop();
}
async _init() {
try {
this._config = await fetchConfig();
this._controls.setConfig(this._config, this._config.player);
this._status.textContent = "Ready";
this._startPreview();
this._startEvents();
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
_startPreview() {
this._preview?.stop();
this._preview = new PreviewStream((payload) => this._onFrame(payload));
this._preview.onDisconnect = () => {
this._status.textContent = "Preview disconnected — retrying…";
};
this._preview.start();
}
_startEvents() {
this._events?.stop();
this._events = new EventStream((event) => this._onEvent(event));
this._events.start();
}
async _applyPreset(id) {
try {
const result = await applyPreset(id);
this._applyingRemote = true;
try {
this._controls.setPlayer(result.player);
this._controls.setActivePreset(id);
this._controls.setSequenceStatus({ active: false, sequence_id: null, step_index: 0 });
} finally {
this._applyingRemote = false;
}
this._status.textContent = `Preset · ${result.preset.name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
async _playSequence(id) {
try {
const result = await playSequence(id);
this._controls.setSequenceStatus(result.sequence);
this._controls.setActivePreset(null);
const name = result.item?.name || id;
this._status.textContent = `Sequence · ${name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
async _saveFromEditor({ id, draft }) {
try {
const preset = id
? await updatePreset(id, draft)
: await createPreset(draft);
this._upsertPreset(preset);
this._controls.setActivePreset(preset.id);
this._controls.closeEditor();
await this._applyPreset(preset.id);
this._status.textContent = `Saved · ${preset.name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
async _saveSequence({ id, draft }) {
try {
const sequence = id
? await updateSequence(id, draft)
: await createSequence(draft);
this._upsertSequence(sequence);
this._controls.closeEditor();
await this._playSequence(sequence.id);
this._status.textContent = `Saved · ${sequence.name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
_upsertPreset(preset) {
const presets = [...(this._config?.presets || []).filter((p) => p.id !== preset.id), preset];
presets.sort((a, b) => a.name.localeCompare(b.name));
if (this._config) this._config.presets = presets;
this._controls.setPresets(presets);
}
_upsertSequence(sequence) {
const sequences = [
...(this._config?.sequences || []).filter((s) => s.id !== sequence.id),
sequence,
];
sequences.sort((a, b) => a.name.localeCompare(b.name));
if (this._config) this._config.sequences = sequences;
this._controls.setSequences(sequences);
}
async _deletePreset({ id, name }) {
if (!window.confirm(`Delete preset “${name}”?`)) return;
try {
await deletePreset(id);
const presets = (this._config?.presets || []).filter((p) => p.id !== id);
if (this._config) this._config.presets = presets;
this._controls.setPresets(presets);
this._controls.closeEditor();
this._status.textContent = `Deleted · ${name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
async _deleteSequence({ id, name }) {
if (!window.confirm(`Delete sequence “${name}”?`)) return;
try {
await deleteSequence(id);
const sequences = (this._config?.sequences || []).filter((s) => s.id !== id);
if (this._config) this._config.sequences = sequences;
this._controls.setSequences(sequences);
this._controls.closeEditor();
this._status.textContent = `Deleted · ${name}`;
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
/** @param {import('../api.js').FramePayload} payload */
_onFrame(payload) {
const name = payload.pattern || payload.animation || "";
const state = payload.playing ? "playing" : "paused";
this._status.textContent = `${name} · ${state} · frame ${payload.frame}`;
}
_onEvent(event) {
if (event.type === "patterns" && this._config) {
this._applyingRemote = true;
try {
this._controls.setPatterns(event.patterns, event.player);
this._config.patterns = event.patterns;
} finally {
this._applyingRemote = false;
}
this._status.textContent = "Patterns reloaded";
}
if (event.type === "presets") {
if (this._config) this._config.presets = event.presets;
this._controls.setPresets(event.presets || []);
}
if (event.type === "sequences") {
if (this._config) this._config.sequences = event.sequences;
this._controls.setSequences(event.sequences || []);
}
if (event.type === "sequence") {
this._controls.setSequenceStatus(event.sequence || null);
if (event.sequence?.active && this._config) {
const item = (this._config.sequences || []).find(
(s) => s.id === event.sequence.sequence_id,
);
if (item) {
const step = item.steps?.[event.sequence.step_index];
const presetName = step
? (this._config.presets || []).find((p) => p.id === step.preset_id)?.name
: null;
this._status.textContent = presetName
? `Sequence · ${item.name} · ${presetName}`
: `Sequence · ${item.name}`;
}
}
}
if (event.type === "player" && event.player) {
this._applyingRemote = true;
try {
this._controls.setPlayer(event.player);
if (event.presetId) this._controls.setActivePreset(event.presetId);
} finally {
this._applyingRemote = false;
}
}
if (event.type === "audio" && event.status) {
this._controls.setAudioStatus(event.status);
}
}
}
customElements.define("portal-app", PortalApp);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,538 @@
import { putPlayer } from "../api.js";
const EDITOR_STYLES = `
:host {
display: none;
position: fixed;
inset: 0;
z-index: 40;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(4, 6, 12, 0.72);
font-family: system-ui, sans-serif;
color: var(--text, #e8ecf7);
}
:host([open]) {
display: flex;
}
.dialog {
width: min(100%, 420px);
max-height: min(92vh, 720px);
overflow: auto;
background: #12151f;
border: 1px solid #2a3044;
border-radius: 12px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid #2a3044;
position: sticky;
top: 0;
background: #12151f;
z-index: 1;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
}
.body {
padding: 0.85rem 1rem 1rem;
}
label {
display: flex;
justify-content: space-between;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: #8b93a8;
}
label.checkbox-row {
justify-content: flex-start;
align-items: center;
gap: 0.55rem;
cursor: pointer;
}
label.checkbox-row input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: #81d4fa;
}
label .value {
color: #e8ecf7;
font-variant-numeric: tabular-nums;
}
select, input[type="range"], input[type="text"], input[type="color"], textarea, button {
width: 100%;
}
select, input[type="text"], textarea, button {
background: #1a1f2e;
color: inherit;
border: 1px solid #2a3044;
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
textarea {
min-height: 3.2rem;
resize: vertical;
font-family: inherit;
line-height: 1.35;
}
input[type="color"] {
height: 2.1rem;
border: 1px solid #2a3044;
border-radius: 6px;
background: #1a1f2e;
padding: 0.15rem;
}
button {
cursor: pointer;
background: #243049;
}
button:hover { border-color: #5b8cff; }
button.primary {
background: #2a3f6e;
border-color: #5b8cff;
}
button.danger {
background: #3a1f28;
border-color: #7a3545;
}
.actions {
display: grid;
gap: 0.5rem;
margin-top: 1rem;
}
#colors:empty, #extras:empty { display: none; }
.hint {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #8b93a8;
}
.section {
margin-top: 0.85rem;
padding-top: 0.35rem;
border-top: 1px solid #2a3044;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #8b93a8;
}
`;
const AUDIO_EXTRA_NAMES = new Set([
"beat_detect",
"audio_attack",
"react_band",
]);
const STYLE_EXTRA_NAMES = new Set([
"font",
]);
/**
* Modal editor: create/edit a preset from a pattern schema.
* Changes push live to the player (debounced). Events:
* - portal-preset-editor-save { draft, id|null }
* - portal-preset-editor-delete { id }
* - portal-preset-editor-close
*/
export class PortalPresetEditor extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PatternSchema[]} */
this._patterns = [];
/** @type {string | null} */
this._editId = null;
this._name = "";
this._pattern = "";
this._brightness = 0.35;
this._speed = 1;
/** @type {Record<string, string>} */
this._colors = {};
/** @type {Record<string, number | string | boolean>} */
this._extras = {};
this._liveTimer = null;
this._liveBusy = false;
this._liveQueued = false;
this._suppressLive = false;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${EDITOR_STYLES}</style>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="preset-title">
<div class="head">
<h2 id="preset-title">Preset</h2>
<button type="button" data-action="close">Close</button>
</div>
<div class="body">
<label for="name">Name</label>
<input id="name" type="text" maxlength="40" placeholder="Preset name" />
<label for="pattern">Pattern</label>
<select id="pattern"></select>
<p class="hint" id="pattern-hint"></p>
<label for="brightness">Brightness <span class="value" id="brightness-val">0.35</span></label>
<input id="brightness" type="range" min="0" max="1" step="0.05" value="0.35" />
<label for="speed">Speed <span class="value" id="speed-val">1.0×</span></label>
<input id="speed" type="range" min="0.1" max="4" step="0.1" value="1" />
<div id="colors"></div>
<div id="extras"></div>
<div class="actions">
<button type="button" class="primary" data-action="save">Save</button>
<button type="button" class="danger" data-action="delete" hidden>Delete</button>
</div>
</div>
</div>
`;
this._titleEl = root.querySelector("#preset-title");
this._nameEl = root.querySelector("#name");
this._patternEl = root.querySelector("#pattern");
this._patternHint = root.querySelector("#pattern-hint");
this._brightnessEl = root.querySelector("#brightness");
this._brightnessVal = root.querySelector("#brightness-val");
this._speedEl = root.querySelector("#speed");
this._speedVal = root.querySelector("#speed-val");
this._colorsEl = root.querySelector("#colors");
this._extrasEl = root.querySelector("#extras");
this._deleteEl = root.querySelector('[data-action="delete"]');
this._nameEl.addEventListener("input", () => {
this._name = this._nameEl.value;
});
this._patternEl.addEventListener("change", () => {
this._pattern = this._patternEl.value;
this._syncFields({ reset: true });
if (!this._name.trim()) {
this._name = this._schema()?.label || this._pattern;
this._nameEl.value = this._name;
}
this._queueLive();
});
this._brightnessEl.addEventListener("input", () => {
this._brightness = Number(this._brightnessEl.value);
this._brightnessVal.textContent = this._brightness.toFixed(2);
this._queueLive();
});
this._speedEl.addEventListener("input", () => {
this._speed = Number(this._speedEl.value);
this._speedVal.textContent = `${this._speed.toFixed(1)}×`;
this._queueLive();
});
root.querySelector('[data-action="close"]').addEventListener("click", () => this.close());
root.querySelector('[data-action="save"]').addEventListener("click", () => this._emitSave());
this._deleteEl.addEventListener("click", () => {
if (!this._editId) return;
this.dispatchEvent(
new CustomEvent("portal-preset-editor-delete", {
bubbles: true,
composed: true,
detail: { id: this._editId, name: this._name || "preset" },
}),
);
});
root.addEventListener("click", (e) => {
if (e.target === root) this.close();
});
}
/** @param {import('../api.js').PatternSchema[]} patterns */
setPatterns(patterns) {
this._patterns = patterns || [];
this._fillPatterns();
}
/**
* @param {{ patterns: import('../api.js').PatternSchema[], preset?: import('../api.js').Preset | null }} opts
*/
open({ patterns, preset = null } = {}) {
if (!this.shadowRoot) this.connectedCallback();
if (patterns) this.setPatterns(patterns);
this._editId = preset?.id || null;
this._titleEl.textContent = this._editId ? "Edit preset" : "New preset";
this._deleteEl.hidden = !this._editId;
this._patternEl.disabled = Boolean(this._editId);
this._suppressLive = true;
if (preset) {
this._name = preset.name || "";
this._pattern = preset.pattern;
this._brightness = Number(preset.brightness ?? 0.35);
this._speed = Number(preset.speed ?? 1);
this._colors = { ...(preset.colors || {}) };
this._extras = { ...(preset.extras || {}) };
} else {
const first = this._patterns[0];
this._pattern = first?.id || "";
this._name = first?.label || "";
this._brightness = 0.35;
this._speed = 1;
this._colors = {};
this._extras = {};
}
this._fillPatterns();
this._nameEl.value = this._name;
this._brightnessEl.value = String(this._brightness);
this._brightnessVal.textContent = Number(this._brightness).toFixed(2);
this._speedEl.value = String(this._speed);
this._speedVal.textContent = `${Number(this._speed).toFixed(1)}×`;
this._syncFields({ reset: !preset });
this.setAttribute("open", "");
this._nameEl.focus();
this._suppressLive = false;
this._queueLive(0);
}
close() {
if (this._liveTimer) {
clearTimeout(this._liveTimer);
this._liveTimer = null;
}
this.removeAttribute("open");
this.dispatchEvent(
new CustomEvent("portal-preset-editor-close", { bubbles: true, composed: true }),
);
}
get isOpen() {
return this.hasAttribute("open");
}
_schema() {
return this._patterns.find((p) => p.id === this._pattern) || null;
}
_fillPatterns() {
if (!this._patternEl) return;
this._patternEl.innerHTML = "";
for (const schema of this._patterns) {
const opt = document.createElement("option");
opt.value = schema.id;
opt.textContent = schema.label;
this._patternEl.appendChild(opt);
}
if (this._pattern) this._patternEl.value = this._pattern;
}
_syncFields({ reset }) {
const schema = this._schema();
this._colorsEl.innerHTML = "";
this._extrasEl.innerHTML = "";
this._patternHint.textContent = schema
? `Edits preview live on the panels. Save to keep.`
: "Choose a pattern to configure colours and params.";
if (!schema) return;
if (reset) {
this._colors = {};
this._extras = {};
for (const spec of schema.colors) this._colors[spec.name] = spec.default;
for (const spec of schema.params) this._extras[spec.name] = spec.default;
} else {
for (const spec of schema.colors) {
if (!(spec.name in this._colors)) this._colors[spec.name] = spec.default;
}
for (const name of Object.keys(this._colors)) {
if (!schema.colors.some((c) => c.name === name)) delete this._colors[name];
}
for (const spec of schema.params) {
if (!(spec.name in this._extras)) this._extras[spec.name] = spec.default;
}
for (const name of Object.keys(this._extras)) {
if (!schema.params.some((p) => p.name === name)) delete this._extras[name];
}
}
for (const spec of schema.colors) {
const label = document.createElement("label");
label.textContent = spec.label;
const input = document.createElement("input");
input.type = "color";
input.value = this._normalizeHex(this._colors[spec.name]);
input.addEventListener("input", () => {
this._colors[spec.name] = input.value;
this._queueLive();
});
this._colorsEl.append(label, input);
}
const patternParams = schema.params.filter(
(p) => !AUDIO_EXTRA_NAMES.has(p.name) && !STYLE_EXTRA_NAMES.has(p.name),
);
const styleParams = schema.params.filter((p) => STYLE_EXTRA_NAMES.has(p.name));
const audioParams = schema.params.filter((p) => AUDIO_EXTRA_NAMES.has(p.name));
this._appendExtraParams(patternParams);
if (styleParams.length) {
const heading = document.createElement("div");
heading.className = "section";
heading.textContent = "Style";
this._extrasEl.appendChild(heading);
this._appendExtraParams(styleParams);
}
if (audioParams.length) {
const heading = document.createElement("div");
heading.className = "section";
heading.textContent = "Audio";
this._extrasEl.appendChild(heading);
this._appendExtraParams(audioParams);
}
}
/** @param {import('../api.js').ParamSpec[]} specs */
_appendExtraParams(specs) {
for (const spec of specs) {
if (spec.type === "text") {
const label = document.createElement("label");
label.textContent = spec.label;
const input = document.createElement("textarea");
input.rows = 2;
input.maxLength = spec.max_length || 160;
input.placeholder = spec.placeholder || "";
input.value = String(this._extras[spec.name] ?? "");
input.addEventListener("input", () => {
this._extras[spec.name] = input.value;
this._queueLive(180);
});
this._extrasEl.append(label, input);
continue;
}
if (spec.type === "choice") {
const label = document.createElement("label");
label.textContent = spec.label;
const select = document.createElement("select");
for (const choice of spec.choices || []) {
const opt = document.createElement("option");
opt.value = choice;
opt.textContent = choice;
select.appendChild(opt);
}
select.value = String(this._extras[spec.name] ?? spec.default ?? "");
select.addEventListener("change", () => {
this._extras[spec.name] = select.value;
this._queueLive();
});
this._extrasEl.append(label, select);
continue;
}
if (spec.type === "bool") {
const row = document.createElement("label");
row.className = "checkbox-row";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = !!(this._extras[spec.name] ?? spec.default);
const text = document.createElement("span");
text.textContent = spec.label;
input.addEventListener("change", () => {
this._extras[spec.name] = input.checked;
this._queueLive();
});
row.append(input, text);
this._extrasEl.append(row);
continue;
}
const label = document.createElement("label");
const valueEl = document.createElement("span");
valueEl.className = "value";
valueEl.textContent = String(this._extras[spec.name]);
label.append(spec.label, valueEl);
const input = document.createElement("input");
input.type = "range";
input.min = String(spec.min);
input.max = String(spec.max);
input.step = String(spec.step);
input.value = String(this._extras[spec.name]);
input.addEventListener("input", () => {
this._extras[spec.name] = Number(input.value);
valueEl.textContent = String(this._extras[spec.name]);
this._queueLive();
});
this._extrasEl.append(label, input);
}
}
_normalizeHex(value) {
if (!value) return "#ffffff";
const hex = value.startsWith("#") ? value : `#${value}`;
return hex.length === 7 ? hex : "#ffffff";
}
_draft() {
const name = this._nameEl.value.trim() || this._schema()?.label || this._pattern;
return {
name,
pattern: this._pattern,
brightness: this._brightness,
speed: this._speed,
colors: { ...this._colors },
extras: { ...this._extras },
};
}
_emitSave() {
const draft = this._draft();
if (!draft.pattern) return;
this.dispatchEvent(
new CustomEvent("portal-preset-editor-save", {
bubbles: true,
composed: true,
detail: { id: this._editId, draft },
}),
);
}
/** @param {number} [delayMs] */
_queueLive(delayMs = 70) {
if (this._suppressLive || !this.hasAttribute("open")) return;
if (this._liveTimer) clearTimeout(this._liveTimer);
this._liveTimer = setTimeout(() => this._pushLive(), delayMs);
}
async _pushLive() {
this._liveTimer = null;
if (!this.hasAttribute("open")) return;
const draft = this._draft();
if (!draft.pattern) return;
if (this._liveBusy) {
this._liveQueued = true;
return;
}
this._liveBusy = true;
try {
await putPlayer({ ...draft, playing: true });
this.dispatchEvent(
new CustomEvent("portal-preset-editor-live", {
bubbles: true,
composed: true,
detail: { draft },
}),
);
} catch (err) {
this.dispatchEvent(
new CustomEvent("portal-preset-editor-error", {
bubbles: true,
composed: true,
detail: { message: err.message },
}),
);
} finally {
this._liveBusy = false;
if (this._liveQueued) {
this._liveQueued = false;
this._queueLive(40);
}
}
}
}
customElements.define("portal-preset-editor", PortalPresetEditor);
@@ -0,0 +1,357 @@
const EDITOR_STYLES = `
:host {
display: none;
position: fixed;
inset: 0;
z-index: 40;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(4, 6, 12, 0.72);
font-family: system-ui, sans-serif;
color: var(--text, #e8ecf7);
}
:host([open]) {
display: flex;
}
.dialog {
width: min(100%, 460px);
max-height: min(92vh, 720px);
overflow: auto;
background: #12151f;
border: 1px solid #2a3044;
border-radius: 12px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid #2a3044;
position: sticky;
top: 0;
background: #12151f;
z-index: 1;
}
.head h2 {
margin: 0;
font-size: 1.05rem;
}
.body {
padding: 0.85rem 1rem 1rem;
}
label {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: #8b93a8;
}
select, input[type="text"], input[type="number"], button {
width: 100%;
background: #1a1f2e;
color: inherit;
border: 1px solid #2a3044;
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
cursor: pointer;
background: #243049;
}
button:hover { border-color: #5b8cff; }
button.primary {
background: #2a3f6e;
border-color: #5b8cff;
}
button.danger {
background: #3a1f28;
border-color: #7a3545;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.85rem 0 0.35rem;
font-size: 0.85rem;
color: #e8ecf7;
}
.check input {
width: auto;
}
.steps {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.step {
display: grid;
grid-template-columns: 1fr 4.5rem auto;
gap: 0.35rem;
align-items: center;
}
.step input[type="number"] {
text-align: right;
}
.step .remove {
width: auto;
padding: 0.45rem 0.55rem;
}
.hint {
margin: 0.35rem 0 0;
font-size: 0.75rem;
color: #8b93a8;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 1rem;
}
.actions-wide {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.empty {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: #8b93a8;
}
`;
/**
* Modal editor: create/edit a timed sequence of presets.
* Events:
* - portal-sequence-editor-save { draft, id|null }
* - portal-sequence-editor-delete { id, name }
* - portal-sequence-editor-close
*/
export class PortalSequenceEditor extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').Preset[]} */
this._presets = [];
/** @type {string | null} */
this._editId = null;
this._name = "";
this._loop = true;
/** @type {import('../api.js').SequenceStep[]} */
this._steps = [];
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${EDITOR_STYLES}</style>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="sequence-title">
<div class="head">
<h2 id="sequence-title">Sequence</h2>
<button type="button" data-action="close">Close</button>
</div>
<div class="body">
<label for="name">Name</label>
<input id="name" type="text" maxlength="40" placeholder="Sequence name" />
<label class="check">
<input id="loop" type="checkbox" checked />
Loop sequence
</label>
<label>Steps</label>
<p class="hint">Each step plays a preset for a number of seconds.</p>
<div class="steps" id="steps"></div>
<p class="empty" id="steps-empty" hidden>Add at least one step.</p>
<div class="actions-wide">
<button type="button" data-action="add-step">Add step</button>
</div>
<div class="actions">
<button type="button" class="primary" data-action="save">Save</button>
<button type="button" data-action="close-bottom">Cancel</button>
</div>
<div class="actions-wide">
<button type="button" class="danger" data-action="delete" hidden>Delete</button>
</div>
</div>
</div>
`;
this._titleEl = root.querySelector("#sequence-title");
this._nameEl = root.querySelector("#name");
this._loopEl = root.querySelector("#loop");
this._stepsEl = root.querySelector("#steps");
this._stepsEmpty = root.querySelector("#steps-empty");
this._deleteEl = root.querySelector('[data-action="delete"]');
this._nameEl.addEventListener("input", () => {
this._name = this._nameEl.value;
});
this._loopEl.addEventListener("change", () => {
this._loop = this._loopEl.checked;
});
root.querySelector('[data-action="close"]').addEventListener("click", () => this.close());
root.querySelector('[data-action="close-bottom"]').addEventListener("click", () => this.close());
root.querySelector('[data-action="save"]').addEventListener("click", () => this._emitSave());
root.querySelector('[data-action="add-step"]').addEventListener("click", () => {
const first = this._presets[0];
if (!first) return;
this._steps.push({ preset_id: first.id, duration_s: 8 });
this._renderSteps();
});
this._deleteEl.addEventListener("click", () => {
if (!this._editId) return;
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-delete", {
bubbles: true,
composed: true,
detail: { id: this._editId, name: this._name || "sequence" },
}),
);
});
root.addEventListener("click", (e) => {
if (e.target === root) this.close();
});
}
/** @param {import('../api.js').Preset[]} presets */
setPresets(presets) {
this._presets = presets || [];
if (this.hasAttribute("open")) this._renderSteps();
}
/**
* @param {{ presets: import('../api.js').Preset[], sequence?: import('../api.js').Sequence | null }} opts
*/
open({ presets, sequence = null } = {}) {
if (!this.shadowRoot) this.connectedCallback();
if (presets) this.setPresets(presets);
this._editId = sequence?.id || null;
this._titleEl.textContent = this._editId ? "Edit sequence" : "New sequence";
this._deleteEl.hidden = !this._editId;
if (sequence) {
this._name = sequence.name || "";
this._loop = sequence.loop !== false;
this._steps = (sequence.steps || []).map((step) => ({
preset_id: String(step.preset_id),
duration_s: Number(step.duration_s) || 8,
}));
} else {
this._name = "";
this._loop = true;
const first = this._presets[0];
this._steps = first ? [{ preset_id: first.id, duration_s: 8 }] : [];
}
this._nameEl.value = this._name;
this._loopEl.checked = this._loop;
this._renderSteps();
this.setAttribute("open", "");
this._nameEl.focus();
}
close() {
this.removeAttribute("open");
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-close", { bubbles: true, composed: true }),
);
}
get isOpen() {
return this.hasAttribute("open");
}
_renderSteps() {
if (!this._stepsEl) return;
this._stepsEl.innerHTML = "";
this._stepsEmpty.hidden = this._steps.length > 0 || this._presets.length === 0;
if (!this._presets.length) {
this._stepsEmpty.hidden = false;
this._stepsEmpty.textContent = "Create a preset before building a sequence.";
return;
}
this._stepsEmpty.textContent = "Add at least one step.";
this._steps.forEach((step, index) => {
const row = document.createElement("div");
row.className = "step";
const select = document.createElement("select");
for (const preset of this._presets) {
const opt = document.createElement("option");
opt.value = preset.id;
opt.textContent = preset.name;
select.appendChild(opt);
}
select.value = step.preset_id;
if (![...select.options].some((o) => o.value === step.preset_id) && this._presets[0]) {
select.value = this._presets[0].id;
step.preset_id = this._presets[0].id;
}
select.addEventListener("change", () => {
this._steps[index].preset_id = select.value;
});
const duration = document.createElement("input");
duration.type = "number";
duration.min = "0.5";
duration.max = "600";
duration.step = "0.5";
duration.value = String(step.duration_s);
duration.title = "Duration (seconds)";
duration.addEventListener("change", () => {
let value = Number(duration.value);
if (!Number.isFinite(value) || value < 0.5) value = 0.5;
if (value > 600) value = 600;
this._steps[index].duration_s = value;
duration.value = String(value);
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "×";
remove.title = "Remove step";
remove.addEventListener("click", () => {
this._steps.splice(index, 1);
this._renderSteps();
});
row.append(select, duration, remove);
this._stepsEl.appendChild(row);
});
}
_draft() {
const name = this._nameEl.value.trim() || "Sequence";
return {
name,
loop: this._loopEl.checked,
steps: this._steps.map((step) => ({
preset_id: step.preset_id,
duration_s: Number(step.duration_s) || 8,
})),
};
}
_emitSave() {
const draft = this._draft();
if (!draft.steps.length) {
this._stepsEmpty.hidden = false;
return;
}
this.dispatchEvent(
new CustomEvent("portal-sequence-editor-save", {
bubbles: true,
composed: true,
detail: { id: this._editId, draft },
}),
);
}
}
customElements.define("portal-sequence-editor", PortalSequenceEditor);
@@ -1,4 +1,4 @@
/** Live reload when web/ files change (dev server only). */
/** Live reload when static/ files change (dev server only). */
function connect() {
const source = new EventSource("/api/dev/reload");
+3
View File
@@ -11,10 +11,13 @@ html,
body {
margin: 0;
height: 100%;
overflow-x: hidden;
background: var(--bg);
}
portal-app {
display: block;
height: 100%;
max-width: 100%;
overflow-x: hidden;
}
@@ -36,7 +36,10 @@ def main() -> int:
targets = panel_targets_from_args(args)
playlist = animation_playlist(args.animation)
print(f"Panel animations -> {format_panel_targets(targets, args.port)}")
print(
f"Panel animations -> {format_panel_targets(targets, args.port)} "
f"brightness={args.brightness:g}"
)
print(f"Playlist: {', '.join(playlist)}")
try:

Some files were not shown because too many files have changed in this diff Show More