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:
+2
-1
@@ -11,4 +11,5 @@ build/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
firmware/pico/build/
|
||||
*.uf2
|
||||
db/
|
||||
!db/.gitkeep
|
||||
|
||||
@@ -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
@@ -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": {}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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,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`).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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).
|
||||
@@ -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,10 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from leds.backends.spi import spidev_bufsize_hint
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
from rpi5_ws2812.ws2812 import WS2812SpiDriver
|
||||
|
||||
from leds.backends.spi import spidev_bufsize_hint
|
||||
except ImportError as exc: # pragma: no cover - import guard
|
||||
np = None
|
||||
WS2812SpiDriver = None
|
||||
_RPI5_IMPORT_ERROR = exc
|
||||
else:
|
||||
_RPI5_IMPORT_ERROR = None
|
||||
|
||||
|
||||
class Rpi5Ws2812Backend:
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))))
|
||||
@@ -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
|
||||
@@ -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":
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
+519
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
"""Standalone LED patterns. Each module defines one Pattern subclass."""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
Reference in New Issue
Block a user