Compare commits
5 Commits
d682753e42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ace5770b3a | |||
| cb9758b97b | |||
| aab62efd4f | |||
| 2382ef16a1 | |||
| cfdd6de291 |
9
Pipfile
@@ -13,10 +13,13 @@ watchfiles = "*"
|
||||
requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
microdot = "*"
|
||||
fastapi = "*"
|
||||
python-multipart = "*"
|
||||
websockets = "*"
|
||||
httpx = "*"
|
||||
numpy = "*"
|
||||
sounddevice = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
|
||||
[dev-packages]
|
||||
pytest = "*"
|
||||
@@ -27,8 +30,8 @@ python_version = "3.11"
|
||||
[scripts]
|
||||
web = "python tests/web.py"
|
||||
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
||||
run = "sh -c 'cd src && python main.py'"
|
||||
dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
|
||||
run = "sh -c 'cd src && uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\"'"
|
||||
dev = "sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 uvicorn fastapi_app:app --host 0.0.0.0 --port \"${PORT:-80}\" --reload --reload-dir . --reload-include \"**/*.html\" --reload-include \"**/*.css\" --reload-include \"**/*.js\" --reload-exclude \"**/db/**\" --reload-exclude \"**/settings.json\" --reload-exclude \"**/settings.json.*\"'"
|
||||
test = "python -m pytest"
|
||||
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
||||
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
||||
|
||||
354
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80"
|
||||
"sha256": "898e7932e8decb3f1b5e1fd620883f2727cbd2f1c1295d8cd559105172d814cb"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@@ -24,6 +24,22 @@
|
||||
"index": "pypi",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"annotated-doc": {
|
||||
"hashes": [
|
||||
"sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320",
|
||||
"sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.0.4"
|
||||
},
|
||||
"annotated-types": {
|
||||
"hashes": [
|
||||
"sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53",
|
||||
"sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.7.0"
|
||||
},
|
||||
"anyio": {
|
||||
"hashes": [
|
||||
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
||||
@@ -455,11 +471,20 @@
|
||||
},
|
||||
"esptool": {
|
||||
"hashes": [
|
||||
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||
"sha256:0a077cb3ee8e60e223882c06ab7dae9b3686816c2547904d7472a42e6284e7de"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==5.2.0"
|
||||
"version": "==5.3.0"
|
||||
},
|
||||
"fastapi": {
|
||||
"hashes": [
|
||||
"sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620",
|
||||
"sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.136.3"
|
||||
},
|
||||
"h11": {
|
||||
"hashes": [
|
||||
@@ -469,13 +494,85 @@
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.16.0"
|
||||
},
|
||||
"httpcore": {
|
||||
"hashes": [
|
||||
"sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55",
|
||||
"sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.0.9"
|
||||
},
|
||||
"httptools": {
|
||||
"hashes": [
|
||||
"sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683",
|
||||
"sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb",
|
||||
"sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b",
|
||||
"sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527",
|
||||
"sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124",
|
||||
"sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca",
|
||||
"sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081",
|
||||
"sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c",
|
||||
"sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77",
|
||||
"sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09",
|
||||
"sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f",
|
||||
"sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085",
|
||||
"sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376",
|
||||
"sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5",
|
||||
"sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62",
|
||||
"sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5",
|
||||
"sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8",
|
||||
"sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681",
|
||||
"sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999",
|
||||
"sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1",
|
||||
"sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005",
|
||||
"sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d",
|
||||
"sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d",
|
||||
"sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d",
|
||||
"sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d",
|
||||
"sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba",
|
||||
"sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247",
|
||||
"sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745",
|
||||
"sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07",
|
||||
"sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b",
|
||||
"sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4",
|
||||
"sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2",
|
||||
"sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557",
|
||||
"sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d",
|
||||
"sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826",
|
||||
"sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b",
|
||||
"sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813",
|
||||
"sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0",
|
||||
"sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150",
|
||||
"sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e",
|
||||
"sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77",
|
||||
"sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568",
|
||||
"sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6",
|
||||
"sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8",
|
||||
"sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b",
|
||||
"sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7",
|
||||
"sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168",
|
||||
"sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a",
|
||||
"sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0",
|
||||
"sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72"
|
||||
],
|
||||
"version": "==0.8.0"
|
||||
},
|
||||
"httpx": {
|
||||
"hashes": [
|
||||
"sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc",
|
||||
"sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==0.28.1"
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5",
|
||||
"sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"
|
||||
"sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2",
|
||||
"sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==3.16"
|
||||
"version": "==3.18"
|
||||
},
|
||||
"intelhex": {
|
||||
"hashes": [
|
||||
@@ -500,15 +597,6 @@
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.1.2"
|
||||
},
|
||||
"microdot": {
|
||||
"hashes": [
|
||||
"sha256:206c52870e3b1d5e6d387802e2ed0afae8c4598c80542a21e3efe377efc128c8",
|
||||
"sha256:7bb9a69fa97a47d8fe07e61d9dd405804744132ca52d26705cf1173431ff7f4b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.6.2"
|
||||
},
|
||||
"mpremote": {
|
||||
"hashes": [
|
||||
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
|
||||
@@ -607,11 +695,11 @@
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a",
|
||||
"sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"
|
||||
"sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7",
|
||||
"sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==4.9.6"
|
||||
"version": "==4.10.0"
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
@@ -621,6 +709,140 @@
|
||||
"markers": "implementation_name != 'PyPy'",
|
||||
"version": "==3.0"
|
||||
},
|
||||
"pydantic": {
|
||||
"hashes": [
|
||||
"sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba",
|
||||
"sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.13.4"
|
||||
},
|
||||
"pydantic-core": {
|
||||
"hashes": [
|
||||
"sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0",
|
||||
"sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262",
|
||||
"sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda",
|
||||
"sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0",
|
||||
"sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e",
|
||||
"sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b",
|
||||
"sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594",
|
||||
"sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29",
|
||||
"sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2",
|
||||
"sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c",
|
||||
"sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d",
|
||||
"sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398",
|
||||
"sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d",
|
||||
"sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3",
|
||||
"sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f",
|
||||
"sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb",
|
||||
"sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7",
|
||||
"sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5",
|
||||
"sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9",
|
||||
"sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462",
|
||||
"sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4",
|
||||
"sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b",
|
||||
"sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d",
|
||||
"sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df",
|
||||
"sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2",
|
||||
"sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0",
|
||||
"sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519",
|
||||
"sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd",
|
||||
"sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7",
|
||||
"sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac",
|
||||
"sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6",
|
||||
"sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565",
|
||||
"sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898",
|
||||
"sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb",
|
||||
"sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928",
|
||||
"sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6",
|
||||
"sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3",
|
||||
"sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a",
|
||||
"sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596",
|
||||
"sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987",
|
||||
"sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e",
|
||||
"sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d",
|
||||
"sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712",
|
||||
"sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008",
|
||||
"sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd",
|
||||
"sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1",
|
||||
"sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be",
|
||||
"sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea",
|
||||
"sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292",
|
||||
"sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33",
|
||||
"sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3",
|
||||
"sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4",
|
||||
"sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b",
|
||||
"sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826",
|
||||
"sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac",
|
||||
"sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7",
|
||||
"sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d",
|
||||
"sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf",
|
||||
"sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4",
|
||||
"sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc",
|
||||
"sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15",
|
||||
"sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3",
|
||||
"sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b",
|
||||
"sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914",
|
||||
"sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04",
|
||||
"sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c",
|
||||
"sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b",
|
||||
"sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9",
|
||||
"sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce",
|
||||
"sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4",
|
||||
"sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a",
|
||||
"sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f",
|
||||
"sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424",
|
||||
"sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894",
|
||||
"sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9",
|
||||
"sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76",
|
||||
"sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201",
|
||||
"sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb",
|
||||
"sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109",
|
||||
"sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4",
|
||||
"sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848",
|
||||
"sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526",
|
||||
"sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0",
|
||||
"sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01",
|
||||
"sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458",
|
||||
"sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e",
|
||||
"sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba",
|
||||
"sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a",
|
||||
"sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39",
|
||||
"sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c",
|
||||
"sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000",
|
||||
"sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b",
|
||||
"sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf",
|
||||
"sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4",
|
||||
"sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd",
|
||||
"sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28",
|
||||
"sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9",
|
||||
"sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30",
|
||||
"sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983",
|
||||
"sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1",
|
||||
"sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76",
|
||||
"sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5",
|
||||
"sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4",
|
||||
"sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7",
|
||||
"sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c",
|
||||
"sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066",
|
||||
"sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3",
|
||||
"sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02",
|
||||
"sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89",
|
||||
"sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50",
|
||||
"sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76",
|
||||
"sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49",
|
||||
"sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b",
|
||||
"sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d",
|
||||
"sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7",
|
||||
"sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4",
|
||||
"sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c",
|
||||
"sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e",
|
||||
"sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff",
|
||||
"sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.46.4"
|
||||
},
|
||||
"pygments": {
|
||||
"hashes": [
|
||||
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",
|
||||
@@ -670,6 +892,15 @@
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.2.2"
|
||||
},
|
||||
"python-multipart": {
|
||||
"hashes": [
|
||||
"sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e",
|
||||
"sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.0.32"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||
@@ -775,11 +1006,11 @@
|
||||
},
|
||||
"rich-click": {
|
||||
"hashes": [
|
||||
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
||||
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
||||
"sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93",
|
||||
"sha256:4008f921da88b5d91646c134ec881c1500e5a6b3f093e90e8f29400e09608371"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.9.7"
|
||||
"version": "==1.9.8"
|
||||
},
|
||||
"selenium": {
|
||||
"hashes": [
|
||||
@@ -818,6 +1049,14 @@
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==0.5.5"
|
||||
},
|
||||
"starlette": {
|
||||
"hashes": [
|
||||
"sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89",
|
||||
"sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"tibs": {
|
||||
"hashes": [
|
||||
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
||||
@@ -879,6 +1118,14 @@
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"typing-inspection": {
|
||||
"hashes": [
|
||||
"sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7",
|
||||
"sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"
|
||||
],
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==0.4.2"
|
||||
},
|
||||
"urllib3": {
|
||||
"extras": [
|
||||
"socks"
|
||||
@@ -890,6 +1137,71 @@
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==2.7.0"
|
||||
},
|
||||
"uvicorn": {
|
||||
"extras": [
|
||||
"standard"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f",
|
||||
"sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"
|
||||
],
|
||||
"markers": "python_version >= '3.10'",
|
||||
"version": "==0.49.0"
|
||||
},
|
||||
"uvloop": {
|
||||
"hashes": [
|
||||
"sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772",
|
||||
"sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e",
|
||||
"sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743",
|
||||
"sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54",
|
||||
"sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec",
|
||||
"sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659",
|
||||
"sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8",
|
||||
"sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad",
|
||||
"sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7",
|
||||
"sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35",
|
||||
"sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289",
|
||||
"sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142",
|
||||
"sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77",
|
||||
"sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733",
|
||||
"sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd",
|
||||
"sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193",
|
||||
"sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74",
|
||||
"sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0",
|
||||
"sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6",
|
||||
"sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473",
|
||||
"sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21",
|
||||
"sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242",
|
||||
"sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705",
|
||||
"sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702",
|
||||
"sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6",
|
||||
"sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f",
|
||||
"sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e",
|
||||
"sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d",
|
||||
"sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370",
|
||||
"sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4",
|
||||
"sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792",
|
||||
"sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa",
|
||||
"sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079",
|
||||
"sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2",
|
||||
"sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86",
|
||||
"sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6",
|
||||
"sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4",
|
||||
"sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3",
|
||||
"sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21",
|
||||
"sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c",
|
||||
"sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e",
|
||||
"sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25",
|
||||
"sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820",
|
||||
"sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9",
|
||||
"sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88",
|
||||
"sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2",
|
||||
"sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c",
|
||||
"sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c",
|
||||
"sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42"
|
||||
],
|
||||
"version": "==0.22.1"
|
||||
},
|
||||
"watchfiles": {
|
||||
"hashes": [
|
||||
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
|
||||
|
||||
13
README.md
@@ -1,17 +1,18 @@
|
||||
# led-controller
|
||||
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (binary wire format).
|
||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (peer-to-peer on 2.4 GHz Wi‑Fi radio).
|
||||
|
||||
- **Bridge ESP32**: runs a WebSocket server; the Pi connects as client (`bridge_ws_url` in `settings.json`, e.g. `ws://192.168.4.1/ws`).
|
||||
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them and pushes group membership.
|
||||
- **Bridge ESP32**: routes Pi traffic to drivers. The Pi connects over **WebSocket** (`bridge_transport`: `wifi`, `bridge_ws_url` e.g. `ws://192.168.4.1/ws`) or **USB serial** (`bridge_transport`: `serial`, `bridge_serial_port`).
|
||||
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them (MAC-keyed) and pushes group membership.
|
||||
- Optional **Wi-Fi drivers** on the LAN still work over UDP discovery + outbound WebSocket.
|
||||
- Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md)
|
||||
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per frame, no JSON on the wire)
|
||||
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per ESP-NOW frame; Pi ↔ bridge uses JSON devices envelope)
|
||||
|
||||
## Run
|
||||
|
||||
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||
- Start app: `pipenv run run` (override listen port with the **`PORT`** environment variable)
|
||||
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||
- Start app: `pipenv run run` (FastAPI + uvicorn; override listen port with **`PORT`**)
|
||||
- Dev mode (uvicorn **`--reload`** on `src/` + browser refresh via `dev-live-reload.js`): `pipenv run dev`
|
||||
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||
|
||||
## UI modes
|
||||
|
||||
49
bridge-ethernet/src/espnow_radio.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Wi-Fi radio for ESP-NOW only (hidden AP locks channel)."""
|
||||
|
||||
import time
|
||||
|
||||
import network
|
||||
|
||||
from settings import WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def _channel(settings):
|
||||
try:
|
||||
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
|
||||
except (TypeError, ValueError):
|
||||
return WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def init_espnow_radio(settings):
|
||||
ch = _channel(settings)
|
||||
name = settings.get("name") or "bridge"
|
||||
password = settings.get("ap_password") or ""
|
||||
|
||||
network.WLAN(network.STA_IF).active(False)
|
||||
network.WLAN(network.AP_IF).active(False)
|
||||
time.sleep_ms(100)
|
||||
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
ap.active(True)
|
||||
time.sleep_ms(50)
|
||||
if password:
|
||||
try:
|
||||
ap.config(essid=name, password=password, channel=ch, hidden=True)
|
||||
except TypeError:
|
||||
ap.config(essid=name, channel=ch)
|
||||
else:
|
||||
try:
|
||||
ap.config(essid=name, channel=ch, hidden=True)
|
||||
except TypeError:
|
||||
ap.config(essid=name, channel=ch)
|
||||
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
try:
|
||||
sta.config(channel=ch)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("espnow radio ch", ch)
|
||||
return ch
|
||||
7
bridge-ethernet/src/espnow_wire.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""WebSocket uplink framing (Pi ↔ bridge)."""
|
||||
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
|
||||
|
||||
def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
@@ -1 +1 @@
|
||||
{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]}
|
||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": [], "14": ["#E8F4FF", "#9ECFFF", "#5080C8", "#FFFFFF", "#B0DCFF", "#0A1520", "#FF8020", "#071018"], "15": []}
|
||||
@@ -1 +1 @@
|
||||
{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}}
|
||||
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}, "3": {"name": "Winter", "type": "zones", "zones": ["11", "12"], "scenes": [], "palette_id": "14"}, "4": {"name": "t", "type": "zones", "zones": ["13"], "scenes": [], "palette_id": "15"}}
|
||||
110
docs/API.md
@@ -3,11 +3,13 @@
|
||||
This document covers:
|
||||
|
||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
|
||||
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **ESP-NOW bridge** (WebSocket) to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
|
||||
2. **LED driver JSON** — the compact **v1** message format. ESP-NOW traffic is wrapped in a **devices envelope** (`dv` map keyed by MAC) on the Pi ↔ bridge link (WebSocket or USB serial); drivers receive compact per-device bodies (≤250 bytes). **Wi-Fi** drivers still accept **single JSON text messages** over an outbound WebSocket (same logical fields).
|
||||
|
||||
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
|
||||
|
||||
**Serial:** UART path and baud come from settings (defaults include `serial_port` such as `/dev/ttyS0` and `serial_baudrate`). **Wi-Fi drivers:** **UDP** on port **8766** is the **discovery** channel: each driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
|
||||
**ESP-NOW bridge:** Set **`bridge_transport`** to **`wifi`** (default) or **`serial`**. Wi‑Fi mode uses **`bridge_ws_url`** (e.g. `ws://192.168.4.1/ws`) after joining the bridge AP; serial mode uses **`bridge_serial_port`** and **`bridge_serial_baudrate`** (default **921600**). Saved bridge profiles and connect helpers live under **`/settings/wifi/*`** (see below). Architecture: [espnow-architecture.md](espnow-architecture.md).
|
||||
|
||||
**Wi-Fi drivers (optional):** **UDP** on port **8766** is the **discovery** channel: each driver’s JSON hello (**`device_name`**, **MAC**, optional **`type`**) **creates or updates** that device in **`db/device.json`** (keyed by MAC); the Pi echoes the datagram. After a valid hello with **`v`:** **`"1"`**, the Pi also opens an **outbound WebSocket** to that IP (**`wifi_driver_ws_port`**, default **80**; **`wifi_driver_ws_path`**, default **`/ws`**) for v1 commands; presets are not pushed automatically on connect (use **Send Presets** / profile apply). The Pi may send periodic UDP **hello** nudges to known Wi‑Fi device IPs when the WebSocket is down (**`wifi_driver_hello_interval_s`** in settings).
|
||||
|
||||
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||
|
||||
@@ -52,7 +54,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
|
||||
|
||||
Connect to **`ws://<host>:<port>/ws`**.
|
||||
|
||||
- Send **JSON**: the object is forwarded through the **ESP-NOW bridge** (devices envelope or legacy MAC-prefixed payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
|
||||
- Send **JSON**: the object is forwarded through the **ESP-NOW bridge** as a **devices envelope** (or legacy MAC-prefixed / binary payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used. Example envelope: [msg.json](msg.json).
|
||||
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||
|
||||
@@ -62,7 +64,7 @@ Wi-Fi devices are not targeted by `/ws` directly; use **`POST /presets/send`**,
|
||||
|
||||
## HTTP API by resource
|
||||
|
||||
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||
Below, `<id>` values are string identifiers used by the JSON stores. **Device** ids are **12-character lowercase hex MACs** (no colons); other resources typically use numeric string ids.
|
||||
|
||||
### Settings — `/settings`
|
||||
|
||||
@@ -74,28 +76,46 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
|
||||
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
||||
| GET | `/settings/page` | Serves `templates/settings.html`. |
|
||||
|
||||
### Devices — `/devices`
|
||||
### Bridge — `/settings/wifi`
|
||||
|
||||
Registry in `db/device.json`: storage key **`<id>`** (string, e.g. `"1"`) maps to an object that always includes:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **`id`** | Same as the storage key (stable handle for URLs). |
|
||||
| **`name`** | Shown in the UI and used in `select` keys. |
|
||||
| **`type`** | `led` (only value today; extensible). |
|
||||
| **`transport`** | `espnow` or `wifi`. |
|
||||
| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. |
|
||||
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
|
||||
|
||||
Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key).
|
||||
Pi-side bridge configuration (ESP-NOW path to drivers). Mounted from `controllers/wifi_bridge.py`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/devices` | Map of device id → device object. |
|
||||
| GET | `/settings/wifi/interfaces` | List Wi‑Fi interfaces via NetworkManager (`nmcli`). |
|
||||
| GET | `/settings/wifi/scan?device=<ifname>` | Scan SSIDs on the given interface. |
|
||||
| GET | `/settings/wifi/bridges` | Bridge state: `bridge_transport`, `bridge_ws_url`, `bridge_connected`, `wifi_interface`, saved **`bridges`** profiles, serial port/baud. |
|
||||
| PUT | `/settings/wifi/bridges` | Merge bridge settings and/or replace the **`bridges`** profile list. |
|
||||
| DELETE | `/settings/wifi/bridges/<id>` | Remove a saved bridge profile. |
|
||||
| POST | `/settings/wifi/bridges/<id>/connect` | Connect using a saved profile (`transport`: `wifi` or `serial`). |
|
||||
| POST | `/settings/wifi/connect` | Join a bridge AP and open its WebSocket. Body: `device`, `ssid`, optional `password`, `ap_ip` (default `192.168.4.1`), `ws_port`, `label`, `save_profile`. |
|
||||
| POST | `/settings/wifi/serial/connect` | Open the bridge over USB serial. Body: `port`, optional `baudrate`, `label`, `save_profile`. |
|
||||
|
||||
### Devices — `/devices`
|
||||
|
||||
Registry in `db/device.json`: storage key **`<id>`** is the device **MAC** (12 lowercase hex characters, no colons). Each record includes:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **`id`** | Same as the storage key (12-char hex MAC). |
|
||||
| **`name`** | Shown in the UI; matched when building zone **`select`** lists. |
|
||||
| **`type`** | `led` (only value today; extensible). |
|
||||
| **`transport`** | `espnow` (default) or `wifi`. |
|
||||
| **`address`** | For **`espnow`**: same as **`id`** (MAC). For **`wifi`**: IP or hostname used for outbound WebSocket / OTA. |
|
||||
| **`connected`** | Response-only on GET list/detail: always **`null`** today (ESP-NOW has no live session flag on the Pi). |
|
||||
| **`default_pattern`**, **`zones`** | Optional. Legacy **`tabs`** may still appear in old files and is migrated away on load. |
|
||||
|
||||
Drivers also **self-register** on ESP-NOW **ANNOUNCE** (bridge uplink) or Wi‑Fi UDP hello; manual **`POST /devices`** is optional.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/devices` | Map of device id → device object (includes **`connected`**). |
|
||||
| GET | `/devices/<id>` | One device, 404 if missing. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`mac`** (required for Wi‑Fi when address is set), **`default_pattern`**, **`zones`**. Returns `{ "<id>": { ... } }`, 201. |
|
||||
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
|
||||
| DELETE | `/devices/<id>` | Remove device. |
|
||||
| POST | `/devices/<id>/identify` | ESP-NOW: sends a short red **blink** preset (`__identify`, 10 Hz) via the bridge, then **`off`** after ~2 s. Not persisted on the Pi. |
|
||||
| POST | `/groups/<id>/identify` | Same identify blink for every device in the group (broadcast envelope; drivers filter by group membership). |
|
||||
|
||||
### Profiles — `/profiles`
|
||||
|
||||
@@ -228,26 +248,56 @@ Pattern metadata lives in **`db/pattern.json`**; driver source files live under
|
||||
|
||||
## LED driver message format (transport / ESP-NOW / Wi-Fi)
|
||||
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge, and the same logical object can be sent as a **single JSON text message** to a Wi-Fi driver over the **WebSocket**.
|
||||
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces per-device bodies; **`build_devices_envelope()`** (`src/util/bridge_envelope.py`) wraps them for the bridge WebSocket or USB serial link. Wi-Fi drivers accept the same logical body as a **single JSON text message** over the outbound WebSocket.
|
||||
|
||||
### Top-level fields
|
||||
### Devices envelope (Pi → bridge)
|
||||
|
||||
On the bridge link, traffic uses a top-level **`dv`** map (long name **`devices`** still accepted on receive):
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"presets": { },
|
||||
"select": { },
|
||||
"save": true,
|
||||
"default": "preset_id",
|
||||
"b": 255
|
||||
"dv": {
|
||||
"e8:f6:0a:16:ea:10": {
|
||||
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
|
||||
"s": ["2", 0],
|
||||
"g": ["5"],
|
||||
"sg": false,
|
||||
"sv": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [espnow-architecture.md](espnow-architecture.md) for routing (`sg`, broadcast MAC `ff:ff:ff:ff:ff:ff`, and group filtering).
|
||||
|
||||
### Per-device body fields (inside `dv` or Wi-Fi WebSocket)
|
||||
|
||||
Short wire keys are used on the bridge and over ESP-NOW (long names still accepted on receive):
|
||||
|
||||
```json
|
||||
{
|
||||
"v": "1",
|
||||
"p": { },
|
||||
"s": ["preset_id", 0],
|
||||
"sv": true,
|
||||
"df": "preset_id",
|
||||
"b": 255,
|
||||
"g": ["5"],
|
||||
"sg": false
|
||||
}
|
||||
```
|
||||
|
||||
| Short | Long | Meaning |
|
||||
|-------|------|---------|
|
||||
| `p` | `presets` | Map of preset id → preset object (see below). |
|
||||
| `s` | `select` | **`["preset_id"]`** or **`["preset_id", step]`** — routing is by MAC envelope / group membership, not by device name. |
|
||||
| `sv` | `save` | If true, driver may persist presets to flash. |
|
||||
| `df` | `default` | Startup default preset id. |
|
||||
| `g` | `groups` | Group ids for membership updates or broadcast filtering. |
|
||||
| `sg` | `set_groups` | If true, replace stored group list before applying the body. |
|
||||
|
||||
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||
- **`default`**: Preset id string to use as startup default on the device.
|
||||
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||
|
||||
### Preset object (wire / driver keys)
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
The LED Driver system is a MicroPython-based application for controlling LED strips via ESP32-C3 microcontrollers. The system uses a custom firmware image with usqlite and microdot built-in as frozen modules. The system provides:
|
||||
|
||||
- Real-time LED pattern control
|
||||
- Multi-device management via peer-to-peer communication (ESPNow)
|
||||
- Multi-device management via ESP-NOW (peer-to-peer on 2.4 GHz; Pi reaches drivers through a bridge ESP32)
|
||||
- Group-based device control
|
||||
- Web-based configuration interface
|
||||
- Binary message protocol for efficient communication
|
||||
@@ -49,7 +49,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
|
||||
- Preset system for saving and loading pattern configurations
|
||||
- Profile and Scene system for complex lighting setups
|
||||
- Preset sequencing within groups for time-based transitions
|
||||
- Peer-to-peer communication via ESPNow
|
||||
- ESP-NOW peer-to-peer between bridge and led-driver devices; Pi ↔ bridge over WebSocket or USB serial
|
||||
- Binary message protocol for bandwidth efficiency
|
||||
- Persistent settings storage (usqlite database)
|
||||
- Web-based configuration interface (Microdot web server)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
|
||||
|
||||
**Pi ↔ bridge WebSocket:** v1 **devices envelope** (JSON) — see [espnow-sender/msg.json](../espnow-sender/msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
|
||||
**Pi ↔ bridge:** v1 **devices envelope** (JSON) over **WebSocket** (`bridge_transport`: `wifi`) or **USB serial** (`bridge_transport`: `serial`) — example: [msg.json](msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
|
||||
|
||||
## System overview
|
||||
|
||||
@@ -11,19 +11,33 @@ This document describes how **led-controller**, the **bridge ESP32**, and **led-
|
||||
| Component | Firmware / path | Role |
|
||||
|-----------|-----------------|------|
|
||||
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope |
|
||||
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; routes envelope per MAC; max **20** peers (LRU) |
|
||||
| **Bridge** | [`espnow-sender/`](../espnow-sender/) (or [`bridge-serial/`](../bridge-serial/) for UART-only) | WebSocket **server** `/ws` and/or USB serial; routes envelope per MAC; max **20** ESP-NOW peers (LRU) |
|
||||
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
|
||||
|
||||
Configure the Pi in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"bridge_transport": "wifi",
|
||||
"bridge_ws_url": "ws://192.168.4.1/ws",
|
||||
"wifi_channel": 5
|
||||
}
|
||||
```
|
||||
|
||||
Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing).
|
||||
For **USB serial** to the bridge ESP32 instead of Wi‑Fi:
|
||||
|
||||
```json
|
||||
{
|
||||
"bridge_transport": "serial",
|
||||
"bridge_serial_port": "/dev/ttyACM0",
|
||||
"bridge_serial_baudrate": 921600,
|
||||
"wifi_channel": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Wi‑Fi mode:** connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). Use **Help → Bridge** or **`POST /settings/wifi/connect`** to join and set `bridge_ws_url`. **Serial mode:** plug in the bridge and set `bridge_serial_port` (or use **`POST /settings/wifi/serial/connect`**).
|
||||
|
||||
All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing).
|
||||
|
||||
---
|
||||
|
||||
@@ -180,5 +194,5 @@ Driver applies only if `group_id` is in its stored list.
|
||||
| Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) |
|
||||
| Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) |
|
||||
| Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) |
|
||||
| Bridge firmware | [`espnow-sender/main.py`](../espnow-sender/main.py) |
|
||||
| Bridge firmware | [`espnow-sender/src/main.py`](../espnow-sender/src/main.py) |
|
||||
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LED controller — user guide
|
||||
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **ESP-NOW bridge** (WebSocket) or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. ESP-NOW devices are reached through the **bridge** (Pi connects via **Wi‑Fi** to the bridge AP or **USB serial**). Optional **Wi-Fi** drivers on the LAN use a direct outbound WebSocket from the Pi.
|
||||
|
||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||
|
||||
@@ -84,7 +84,9 @@ The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add**
|
||||
|
||||
The **Patterns** dialog (Edit mode) lists pattern names and typical **delay** ranges from the pattern definitions. Choosing a pattern still happens inside the preset editor.
|
||||
|
||||
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge/serial path you configure for preset traffic.
|
||||
**Wi-Fi drivers** can install new pattern modules over HTTP: the REST API exposes **`/patterns/ota/*`**, **`POST /patterns/<name>/send`**, **`POST /patterns/upload`**, and **`POST /patterns/driver`** (see [API.md](API.md)). ESP-NOW devices follow the bridge path you configure (**Help → Bridge**: join the bridge AP or connect USB serial).
|
||||
|
||||
**Devices** (Edit mode): the registry lists drivers by **MAC**. New ESP-NOW devices appear automatically after **ANNOUNCE**; you can also add rows manually. **Identify** sends a short red blink (~2 s) so you can spot hardware on a wall or bench.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,5 +112,5 @@ On narrow screens, use **Menu** to reach the same actions as the desktop header
|
||||
|
||||
## Further reading
|
||||
|
||||
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys, pattern **manifest**).
|
||||
- **[API.md](API.md)** — REST routes, bridge settings (`/settings/wifi/*`), session scoping, WebSocket `/ws`, and LED driver JSON (devices envelope `dv`, short keys `p`/`s`/`sv`, pattern **manifest**).
|
||||
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<title>Header: tab buttons and action bar</title>
|
||||
<rect width="820" height="108" fill="#1a1a1a"/>
|
||||
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
|
||||
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Zones</text>
|
||||
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
|
||||
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
|
||||
@@ -13,7 +13,7 @@
|
||||
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
|
||||
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
|
||||
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Zones</text>
|
||||
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
|
||||
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,26 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
|
||||
<title id="t">Narrow screen: Menu aggregates header actions</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="mobile-menu-title">
|
||||
<title id="mobile-menu-title">Narrow screen: Menu aggregates header actions</title>
|
||||
<rect width="300" height="340" fill="#2e2e2e"/>
|
||||
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
|
||||
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
|
||||
<text x="22" y="30" fill="#eee" font-family="sans-serif" font-size="12">Menu</text>
|
||||
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<text x="86" y="30" fill="#ccc" font-family="sans-serif" font-size="11">tab</text>
|
||||
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
|
||||
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||
<text x="142" y="30" fill="#fff" font-family="sans-serif" font-size="11">tab</text>
|
||||
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
|
||||
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
|
||||
<text x="24" y="84" fill="#888" font-family="sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||
<g font-family="sans-serif" font-size="12" fill="#e8e8e8">
|
||||
<text x="24" y="108">Run mode</text>
|
||||
<text x="24" y="132">Profiles</text>
|
||||
<text x="24" y="156">Tabs</text>
|
||||
<text x="24" y="156">Zones</text>
|
||||
<text x="24" y="180">Presets</text>
|
||||
<text x="24" y="204">Help</text>
|
||||
</g>
|
||||
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area presets as on desktop</text>
|
||||
<text x="24" y="268" fill="#aaa" font-family="sans-serif" font-size="10">Content area - presets as on desktop</text>
|
||||
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
<text x="36" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
|
||||
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||
<text x="124" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -1,23 +1,18 @@
|
||||
{
|
||||
"g":{
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
"v": "1",
|
||||
"dv": {
|
||||
"ff:ff:ff:ff:ff:ff": {
|
||||
"p": {
|
||||
"2": {
|
||||
"p": "on",
|
||||
"c": ["#FFFFFF"],
|
||||
"a": true
|
||||
}
|
||||
},
|
||||
"sv": true,
|
||||
"st": 0
|
||||
}
|
||||
},
|
||||
"s": ["2", 0],
|
||||
"g": ["5", "18"],
|
||||
"sg": false,
|
||||
"sv": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
led-tool
@@ -1,2 +0,0 @@
|
||||
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||
send_file # noqa: F401
|
||||
@@ -1,8 +0,0 @@
|
||||
try:
|
||||
from functools import wraps
|
||||
except ImportError: # pragma: no cover
|
||||
# MicroPython does not currently implement functools.wraps
|
||||
def wraps(wrapped):
|
||||
def _(wrapper):
|
||||
return wrapper
|
||||
return _
|
||||
@@ -1,225 +0,0 @@
|
||||
try:
|
||||
import jwt
|
||||
HAS_JWT = True
|
||||
except ImportError:
|
||||
HAS_JWT = False
|
||||
try:
|
||||
import ubinascii
|
||||
except ImportError:
|
||||
import binascii as ubinascii
|
||||
try:
|
||||
import uhashlib as hashlib
|
||||
except ImportError:
|
||||
import hashlib
|
||||
try:
|
||||
import uhmac as hmac
|
||||
except ImportError:
|
||||
try:
|
||||
import hmac
|
||||
except ImportError:
|
||||
hmac = None
|
||||
import json
|
||||
|
||||
from microdot.microdot import invoke_handler
|
||||
from microdot.helpers import wraps
|
||||
|
||||
|
||||
class SessionDict(dict):
|
||||
"""A session dictionary.
|
||||
|
||||
The session dictionary is a standard Python dictionary that has been
|
||||
extended with convenience ``save()`` and ``delete()`` methods.
|
||||
"""
|
||||
def __init__(self, request, session_dict):
|
||||
super().__init__(session_dict)
|
||||
self.request = request
|
||||
|
||||
def save(self):
|
||||
"""Update the session cookie."""
|
||||
self.request.app._session.update(self.request, self)
|
||||
|
||||
def delete(self):
|
||||
"""Delete the session cookie."""
|
||||
self.request.app._session.delete(self.request)
|
||||
|
||||
|
||||
class Session:
|
||||
"""Session handling
|
||||
|
||||
:param app: The application instance.
|
||||
:param secret_key: The secret key, as a string or bytes object.
|
||||
:param cookie_options: A dictionary with cookie options to pass as
|
||||
arguments to :meth:`Response.set_cookie()
|
||||
<microdot.Response.set_cookie>`.
|
||||
"""
|
||||
secret_key = None
|
||||
|
||||
def __init__(self, app=None, secret_key=None, cookie_options=None):
|
||||
self.secret_key = secret_key
|
||||
self.cookie_options = cookie_options or {}
|
||||
if app is not None:
|
||||
self.initialize(app)
|
||||
|
||||
def initialize(self, app, secret_key=None, cookie_options=None):
|
||||
if secret_key is not None:
|
||||
self.secret_key = secret_key
|
||||
if cookie_options is not None:
|
||||
self.cookie_options = cookie_options
|
||||
if 'path' not in self.cookie_options:
|
||||
self.cookie_options['path'] = '/'
|
||||
if 'http_only' not in self.cookie_options:
|
||||
self.cookie_options['http_only'] = True
|
||||
app._session = self
|
||||
|
||||
def get(self, request):
|
||||
"""Retrieve the user session.
|
||||
|
||||
:param request: The client request.
|
||||
|
||||
The return value is a session dictionary with the data stored in the
|
||||
user's session, or ``{}`` if the session data is not available or
|
||||
invalid.
|
||||
"""
|
||||
if not self.secret_key:
|
||||
raise ValueError('The session secret key is not configured')
|
||||
if hasattr(request.g, '_session'):
|
||||
return request.g._session
|
||||
session = request.cookies.get('session')
|
||||
if session is None:
|
||||
request.g._session = SessionDict(request, {})
|
||||
return request.g._session
|
||||
request.g._session = SessionDict(request, self.decode(session))
|
||||
return request.g._session
|
||||
|
||||
def update(self, request, session):
|
||||
"""Update the user session.
|
||||
|
||||
:param request: The client request.
|
||||
:param session: A dictionary with the update session data for the user.
|
||||
|
||||
Applications would normally not call this method directly, instead they
|
||||
would use the :meth:`SessionDict.save` method on the session
|
||||
dictionary, which calls this method. For example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
session['foo'] = 'bar'
|
||||
session.save()
|
||||
return 'Hello, World!'
|
||||
|
||||
Calling this method adds a cookie with the updated session to the
|
||||
request currently being processed.
|
||||
"""
|
||||
if not self.secret_key:
|
||||
raise ValueError('The session secret key is not configured')
|
||||
|
||||
encoded_session = self.encode(session)
|
||||
|
||||
@request.after_request
|
||||
def _update_session(request, response):
|
||||
response.set_cookie('session', encoded_session,
|
||||
**self.cookie_options)
|
||||
return response
|
||||
|
||||
def delete(self, request):
|
||||
"""Remove the user session.
|
||||
|
||||
:param request: The client request.
|
||||
|
||||
Applications would normally not call this method directly, instead they
|
||||
would use the :meth:`SessionDict.delete` method on the session
|
||||
dictionary, which calls this method. For example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
session.delete()
|
||||
return 'Hello, World!'
|
||||
|
||||
Calling this method adds a cookie removal header to the request
|
||||
currently being processed.
|
||||
"""
|
||||
@request.after_request
|
||||
def _delete_session(request, response):
|
||||
response.delete_cookie('session', **self.cookie_options)
|
||||
return response
|
||||
|
||||
def encode(self, payload, secret_key=None):
|
||||
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
return jwt.encode(payload, secret_key or self.secret_key,
|
||||
algorithm='HS256')
|
||||
else:
|
||||
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
payload_json = json.dumps(payload)
|
||||
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||
|
||||
# Create HMAC signature
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
return f"{payload_b64}.{signature}"
|
||||
|
||||
def decode(self, session, secret_key=None):
|
||||
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||
if HAS_JWT:
|
||||
try:
|
||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||
algorithms=['HS256'])
|
||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||
return {}
|
||||
return payload
|
||||
else:
|
||||
try:
|
||||
# Simple decoding for MicroPython
|
||||
if '.' not in session:
|
||||
return {}
|
||||
|
||||
payload_b64, signature = session.rsplit('.', 1)
|
||||
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||
|
||||
# Verify HMAC signature
|
||||
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||
if hmac:
|
||||
# Use hmac module if available
|
||||
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||
else:
|
||||
# Fallback: simple SHA256(key + message)
|
||||
h = hashlib.sha256(key + payload_json.encode())
|
||||
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||
|
||||
if signature != expected_signature:
|
||||
return {}
|
||||
|
||||
return json.loads(payload_json)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def with_session(f):
|
||||
"""Decorator that passes the user session to the route handler.
|
||||
|
||||
The session dictionary is passed to the decorated function as an argument
|
||||
after the request object. Example::
|
||||
|
||||
@app.route('/')
|
||||
@with_session
|
||||
def index(request, session):
|
||||
return 'Hello, World!'
|
||||
|
||||
Note that the decorator does not save the session. To update the session,
|
||||
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
|
||||
"""
|
||||
@wraps(f)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
return await invoke_handler(
|
||||
f, request, request.app._session.get(request), *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -1,70 +0,0 @@
|
||||
from utemplate import recompile
|
||||
|
||||
_loader = None
|
||||
|
||||
|
||||
class Template:
|
||||
"""A template object.
|
||||
|
||||
:param template: The filename of the template to render, relative to the
|
||||
configured template directory.
|
||||
"""
|
||||
@classmethod
|
||||
def initialize(cls, template_dir='templates',
|
||||
loader_class=recompile.Loader):
|
||||
"""Initialize the templating subsystem.
|
||||
|
||||
:param template_dir: the directory where templates are stored. This
|
||||
argument is optional. The default is to load
|
||||
templates from a *templates* subdirectory.
|
||||
:param loader_class: the ``utemplate.Loader`` class to use when loading
|
||||
templates. This argument is optional. The default
|
||||
is the ``recompile.Loader`` class, which
|
||||
automatically recompiles templates when they
|
||||
change.
|
||||
"""
|
||||
global _loader
|
||||
_loader = loader_class(None, template_dir)
|
||||
|
||||
def __init__(self, template):
|
||||
if _loader is None: # pragma: no cover
|
||||
self.initialize()
|
||||
#: The name of the template
|
||||
self.name = template
|
||||
self.template = _loader.load(template)
|
||||
|
||||
def generate(self, *args, **kwargs):
|
||||
"""Return a generator that renders the template in chunks, with the
|
||||
given arguments."""
|
||||
return self.template(*args, **kwargs)
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
"""Render the template with the given arguments and return it as a
|
||||
string."""
|
||||
return ''.join(self.generate(*args, **kwargs))
|
||||
|
||||
def generate_async(self, *args, **kwargs):
|
||||
"""Return an asynchronous generator that renders the template in
|
||||
chunks, using the given arguments."""
|
||||
class sync_to_async_iter():
|
||||
def __init__(self, iter):
|
||||
self.iter = iter
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self.iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
return sync_to_async_iter(self.generate(*args, **kwargs))
|
||||
|
||||
async def render_async(self, *args, **kwargs):
|
||||
"""Render the template with the given arguments asynchronously and
|
||||
return it as a string."""
|
||||
response = ''
|
||||
async for chunk in self.generate_async(*args, **kwargs):
|
||||
response += chunk
|
||||
return response
|
||||
@@ -1,231 +0,0 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
from microdot import Request, Response
|
||||
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
|
||||
from microdot.helpers import wraps
|
||||
|
||||
|
||||
class WebSocketError(Exception):
|
||||
"""Exception raised when an error occurs in a WebSocket connection."""
|
||||
pass
|
||||
|
||||
|
||||
class WebSocket:
|
||||
"""A WebSocket connection object.
|
||||
|
||||
An instance of this class is sent to handler functions to manage the
|
||||
WebSocket connection.
|
||||
"""
|
||||
CONT = 0
|
||||
TEXT = 1
|
||||
BINARY = 2
|
||||
CLOSE = 8
|
||||
PING = 9
|
||||
PONG = 10
|
||||
|
||||
#: Specify the maximum message size that can be received when calling the
|
||||
#: ``receive()`` method. Messages with payloads that are larger than this
|
||||
#: size will be rejected and the connection closed. Set to 0 to disable
|
||||
#: the size check (be aware of potential security issues if you do this),
|
||||
#: or to -1 to use the value set in
|
||||
#: ``Request.max_body_length``. The default is -1.
|
||||
#:
|
||||
#: Example::
|
||||
#:
|
||||
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
|
||||
max_message_length = -1
|
||||
|
||||
def __init__(self, request):
|
||||
self.request = request
|
||||
self.closed = False
|
||||
|
||||
async def handshake(self):
|
||||
response = self._handshake_response()
|
||||
await self.request.sock[1].awrite(
|
||||
b'HTTP/1.1 101 Switching Protocols\r\n')
|
||||
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
|
||||
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
|
||||
await self.request.sock[1].awrite(
|
||||
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
|
||||
|
||||
async def receive(self):
|
||||
"""Receive a message from the client."""
|
||||
while True:
|
||||
opcode, payload = await self._read_frame()
|
||||
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
||||
if send_opcode: # pragma: no cover
|
||||
await self.send(data, send_opcode)
|
||||
elif data: # pragma: no branch
|
||||
return data
|
||||
|
||||
async def send(self, data, opcode=None):
|
||||
"""Send a message to the client.
|
||||
|
||||
:param data: the data to send, given as a string or bytes.
|
||||
:param opcode: a custom frame opcode to use. If not given, the opcode
|
||||
is ``TEXT`` or ``BINARY`` depending on the type of the
|
||||
data.
|
||||
"""
|
||||
frame = self._encode_websocket_frame(
|
||||
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
|
||||
data)
|
||||
await self.request.sock[1].awrite(frame)
|
||||
|
||||
async def close(self):
|
||||
"""Close the websocket connection."""
|
||||
if not self.closed: # pragma: no cover
|
||||
self.closed = True
|
||||
await self.send(b'', self.CLOSE)
|
||||
|
||||
def _handshake_response(self):
|
||||
connection = False
|
||||
upgrade = False
|
||||
websocket_key = None
|
||||
for header, value in self.request.headers.items():
|
||||
h = header.lower()
|
||||
if h == 'connection':
|
||||
connection = True
|
||||
if 'upgrade' not in value.lower():
|
||||
return self.request.app.abort(400)
|
||||
elif h == 'upgrade':
|
||||
upgrade = True
|
||||
if not value.lower() == 'websocket':
|
||||
return self.request.app.abort(400)
|
||||
elif h == 'sec-websocket-key':
|
||||
websocket_key = value
|
||||
if not connection or not upgrade or not websocket_key:
|
||||
return self.request.app.abort(400)
|
||||
d = hashlib.sha1(websocket_key.encode())
|
||||
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
||||
return binascii.b2a_base64(d.digest())[:-1]
|
||||
|
||||
@classmethod
|
||||
def _parse_frame_header(cls, header):
|
||||
fin = header[0] & 0x80
|
||||
opcode = header[0] & 0x0f
|
||||
if fin == 0 or opcode == cls.CONT: # pragma: no cover
|
||||
raise WebSocketError('Continuation frames not supported')
|
||||
has_mask = header[1] & 0x80
|
||||
length = header[1] & 0x7f
|
||||
if length == 126:
|
||||
length = -2
|
||||
elif length == 127:
|
||||
length = -8
|
||||
return fin, opcode, has_mask, length
|
||||
|
||||
def _process_websocket_frame(self, opcode, payload):
|
||||
if opcode == self.TEXT:
|
||||
payload = payload.decode()
|
||||
elif opcode == self.BINARY:
|
||||
pass
|
||||
elif opcode == self.CLOSE:
|
||||
raise WebSocketError('Websocket connection closed')
|
||||
elif opcode == self.PING:
|
||||
return self.PONG, payload
|
||||
elif opcode == self.PONG: # pragma: no branch
|
||||
return None, None
|
||||
return None, payload
|
||||
|
||||
@classmethod
|
||||
def _encode_websocket_frame(cls, opcode, payload):
|
||||
frame = bytearray()
|
||||
frame.append(0x80 | opcode)
|
||||
if opcode == cls.TEXT:
|
||||
payload = payload.encode()
|
||||
if len(payload) < 126:
|
||||
frame.append(len(payload))
|
||||
elif len(payload) < (1 << 16):
|
||||
frame.append(126)
|
||||
frame.extend(len(payload).to_bytes(2, 'big'))
|
||||
else:
|
||||
frame.append(127)
|
||||
frame.extend(len(payload).to_bytes(8, 'big'))
|
||||
frame.extend(payload)
|
||||
return frame
|
||||
|
||||
async def _read_frame(self):
|
||||
header = await self.request.sock[0].read(2)
|
||||
if len(header) != 2: # pragma: no cover
|
||||
raise WebSocketError('Websocket connection closed')
|
||||
fin, opcode, has_mask, length = self._parse_frame_header(header)
|
||||
if length == -2:
|
||||
length = await self.request.sock[0].read(2)
|
||||
length = int.from_bytes(length, 'big')
|
||||
elif length == -8:
|
||||
length = await self.request.sock[0].read(8)
|
||||
length = int.from_bytes(length, 'big')
|
||||
max_allowed_length = Request.max_body_length \
|
||||
if self.max_message_length == -1 else self.max_message_length
|
||||
if length > max_allowed_length:
|
||||
raise WebSocketError('Message too large')
|
||||
if has_mask: # pragma: no cover
|
||||
mask = await self.request.sock[0].read(4)
|
||||
payload = await self.request.sock[0].read(length)
|
||||
if has_mask: # pragma: no cover
|
||||
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
|
||||
return opcode, payload
|
||||
|
||||
|
||||
async def websocket_upgrade(request):
|
||||
"""Upgrade a request handler to a websocket connection.
|
||||
|
||||
This function can be called directly inside a route function to process a
|
||||
WebSocket upgrade handshake, for example after the user's credentials are
|
||||
verified. The function returns the websocket object::
|
||||
|
||||
@app.route('/echo')
|
||||
async def echo(request):
|
||||
if not authenticate_user(request):
|
||||
abort(401)
|
||||
ws = await websocket_upgrade(request)
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
await ws.send(message)
|
||||
"""
|
||||
ws = WebSocket(request)
|
||||
await ws.handshake()
|
||||
|
||||
@request.after_request
|
||||
async def after_request(request, response):
|
||||
return Response.already_handled
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
def websocket_wrapper(f, upgrade_function):
|
||||
@wraps(f)
|
||||
async def wrapper(request, *args, **kwargs):
|
||||
ws = await upgrade_function(request)
|
||||
try:
|
||||
await f(request, ws, *args, **kwargs)
|
||||
except OSError as exc:
|
||||
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
|
||||
raise
|
||||
except WebSocketError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
print_exception(exc)
|
||||
finally: # pragma: no cover
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
return Response.already_handled
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_websocket(f):
|
||||
"""Decorator to make a route a WebSocket endpoint.
|
||||
|
||||
This decorator is used to define a route that accepts websocket
|
||||
connections. The route then receives a websocket object as a second
|
||||
argument that it can use to send and receive messages::
|
||||
|
||||
@app.route('/echo')
|
||||
@with_websocket
|
||||
async def echo(request, ws):
|
||||
while True:
|
||||
message = await ws.receive()
|
||||
await ws.send(message)
|
||||
"""
|
||||
return websocket_wrapper(f, websocket_upgrade)
|
||||
@@ -1,14 +0,0 @@
|
||||
class Loader:
|
||||
|
||||
def __init__(self, pkg, dir):
|
||||
if dir == ".":
|
||||
dir = ""
|
||||
else:
|
||||
dir = dir.replace("/", ".") + "."
|
||||
if pkg and pkg != "__main__":
|
||||
dir = pkg + "." + dir
|
||||
self.p = dir
|
||||
|
||||
def load(self, name):
|
||||
name = name.replace(".", "_")
|
||||
return __import__(self.p + name, None, None, (name,)).render
|
||||
@@ -1,21 +0,0 @@
|
||||
# (c) 2014-2020 Paul Sokolovsky. MIT license.
|
||||
try:
|
||||
from uos import stat, remove
|
||||
except:
|
||||
from os import stat, remove
|
||||
from . import source
|
||||
|
||||
|
||||
class Loader(source.Loader):
|
||||
|
||||
def load(self, name):
|
||||
o_path = self.pkg_path + self.compiled_path(name)
|
||||
i_path = self.pkg_path + self.dir + "/" + name
|
||||
try:
|
||||
o_stat = stat(o_path)
|
||||
i_stat = stat(i_path)
|
||||
if i_stat[8] > o_stat[8]:
|
||||
# input file is newer, remove output to force recompile
|
||||
remove(o_path)
|
||||
finally:
|
||||
return super().load(name)
|
||||
@@ -1,188 +0,0 @@
|
||||
# (c) 2014-2019 Paul Sokolovsky. MIT license.
|
||||
from . import compiled
|
||||
|
||||
|
||||
class Compiler:
|
||||
|
||||
START_CHAR = "{"
|
||||
STMNT = "%"
|
||||
STMNT_END = "%}"
|
||||
EXPR = "{"
|
||||
EXPR_END = "}}"
|
||||
|
||||
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
|
||||
self.file_in = file_in
|
||||
self.file_out = file_out
|
||||
self.loader = loader
|
||||
self.seq = seq
|
||||
self._indent = indent
|
||||
self.stack = []
|
||||
self.in_literal = False
|
||||
self.flushed_header = False
|
||||
self.args = "*a, **d"
|
||||
|
||||
def indent(self, adjust=0):
|
||||
if not self.flushed_header:
|
||||
self.flushed_header = True
|
||||
self.indent()
|
||||
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
|
||||
self.stack.append("def")
|
||||
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
|
||||
|
||||
def literal(self, s):
|
||||
if not s:
|
||||
return
|
||||
if not self.in_literal:
|
||||
self.indent()
|
||||
self.file_out.write('yield """')
|
||||
self.in_literal = True
|
||||
self.file_out.write(s.replace('"', '\\"'))
|
||||
|
||||
def close_literal(self):
|
||||
if self.in_literal:
|
||||
self.file_out.write('"""\n')
|
||||
self.in_literal = False
|
||||
|
||||
def render_expr(self, e):
|
||||
self.indent()
|
||||
self.file_out.write('yield str(' + e + ')\n')
|
||||
|
||||
def parse_statement(self, stmt):
|
||||
tokens = stmt.split(None, 1)
|
||||
if tokens[0] == "args":
|
||||
if len(tokens) > 1:
|
||||
self.args = tokens[1]
|
||||
else:
|
||||
self.args = ""
|
||||
elif tokens[0] == "set":
|
||||
self.indent()
|
||||
self.file_out.write(stmt[3:].strip() + "\n")
|
||||
elif tokens[0] == "include":
|
||||
if not self.flushed_header:
|
||||
# If there was no other output, we still need a header now
|
||||
self.indent()
|
||||
tokens = tokens[1].split(None, 1)
|
||||
args = ""
|
||||
if len(tokens) > 1:
|
||||
args = tokens[1]
|
||||
if tokens[0][0] == "{":
|
||||
self.indent()
|
||||
# "1" as fromlist param is uPy hack
|
||||
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
|
||||
self.indent()
|
||||
self.file_out.write("yield from _.render(%s)\n" % args)
|
||||
return
|
||||
|
||||
with self.loader.input_open(tokens[0][1:-1]) as inc:
|
||||
self.seq += 1
|
||||
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
|
||||
inc_id = self.seq
|
||||
self.seq = c.compile()
|
||||
self.indent()
|
||||
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
|
||||
elif len(tokens) > 1:
|
||||
if tokens[0] == "elif":
|
||||
assert self.stack[-1] == "if"
|
||||
self.indent(-1)
|
||||
self.file_out.write(stmt + ":\n")
|
||||
else:
|
||||
self.indent()
|
||||
self.file_out.write(stmt + ":\n")
|
||||
self.stack.append(tokens[0])
|
||||
else:
|
||||
if stmt.startswith("end"):
|
||||
assert self.stack[-1] == stmt[3:]
|
||||
self.stack.pop(-1)
|
||||
elif stmt == "else":
|
||||
assert self.stack[-1] == "if"
|
||||
self.indent(-1)
|
||||
self.file_out.write("else:\n")
|
||||
else:
|
||||
assert False
|
||||
|
||||
def parse_line(self, l):
|
||||
while l:
|
||||
start = l.find(self.START_CHAR)
|
||||
if start == -1:
|
||||
self.literal(l)
|
||||
return
|
||||
self.literal(l[:start])
|
||||
self.close_literal()
|
||||
sel = l[start + 1]
|
||||
#print("*%s=%s=" % (sel, EXPR))
|
||||
if sel == self.STMNT:
|
||||
end = l.find(self.STMNT_END)
|
||||
assert end > 0
|
||||
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
|
||||
self.parse_statement(stmt)
|
||||
end += len(self.STMNT_END)
|
||||
l = l[end:]
|
||||
if not self.in_literal and l == "\n":
|
||||
break
|
||||
elif sel == self.EXPR:
|
||||
# print("EXPR")
|
||||
end = l.find(self.EXPR_END)
|
||||
assert end > 0
|
||||
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
|
||||
self.render_expr(expr)
|
||||
end += len(self.EXPR_END)
|
||||
l = l[end:]
|
||||
else:
|
||||
self.literal(l[start])
|
||||
l = l[start + 1:]
|
||||
|
||||
def header(self):
|
||||
self.file_out.write("# Autogenerated file\n")
|
||||
|
||||
def compile(self):
|
||||
self.header()
|
||||
for l in self.file_in:
|
||||
self.parse_line(l)
|
||||
self.close_literal()
|
||||
return self.seq
|
||||
|
||||
|
||||
class Loader(compiled.Loader):
|
||||
|
||||
def __init__(self, pkg, dir):
|
||||
super().__init__(pkg, dir)
|
||||
self.dir = dir
|
||||
if pkg == "__main__":
|
||||
# if pkg isn't really a package, don't bother to use it
|
||||
# it means we're running from "filesystem directory", not
|
||||
# from a package.
|
||||
pkg = None
|
||||
|
||||
self.pkg_path = ""
|
||||
if pkg:
|
||||
p = __import__(pkg)
|
||||
if isinstance(p.__path__, str):
|
||||
# uPy
|
||||
self.pkg_path = p.__path__
|
||||
else:
|
||||
# CPy
|
||||
self.pkg_path = p.__path__[0]
|
||||
self.pkg_path += "/"
|
||||
|
||||
def input_open(self, template):
|
||||
path = self.pkg_path + self.dir + "/" + template
|
||||
return open(path)
|
||||
|
||||
def compiled_path(self, template):
|
||||
return self.dir + "/" + template.replace(".", "_") + ".py"
|
||||
|
||||
def load(self, name):
|
||||
try:
|
||||
return super().load(name)
|
||||
except (OSError, ImportError):
|
||||
pass
|
||||
|
||||
compiled_path = self.pkg_path + self.compiled_path(name)
|
||||
|
||||
f_in = self.input_open(name)
|
||||
f_out = open(compiled_path, "w")
|
||||
c = Compiler(f_in, f_out, loader=self)
|
||||
c.compile()
|
||||
f_in.close()
|
||||
f_out.close()
|
||||
return super().load(name)
|
||||
@@ -13,4 +13,8 @@ if [ -n "${pids}" ]; then
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/src"
|
||||
exec python main.py
|
||||
exec env LED_CONTROLLER_LIVE_RELOAD=1 python -m uvicorn fastapi_app:app \
|
||||
--host 0.0.0.0 --port "$PORT" --reload --reload-dir . \
|
||||
--reload-include '**/*.html' --reload-include '**/*.css' --reload-include '**/*.js' \
|
||||
--reload-exclude '**/db/**' --reload-exclude '**/settings.json' \
|
||||
--reload-exclude '**/settings.json.*'
|
||||
|
||||
146
scripts/migrate_controllers_native_fastapi.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Migrate Microdot controllers to native FastAPI (no compat layer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONTROLLERS = Path(__file__).resolve().parents[1] / "src" / "controllers"
|
||||
|
||||
IMPORT_BLOCK = """from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
"""
|
||||
|
||||
_JSON_HEADERS = re.compile(
|
||||
r"return json\.dumps\(([\s\S]*?)\),\s*(\d+)\s*,\s*\{\s*"
|
||||
r"['\"]Content-Type['\"]\s*:\s*['\"]application/json['\"]\s*,?\s*\}",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_JSON_PLAIN = re.compile(
|
||||
r"^(\s*)return json\.dumps\((.+)\),\s*(\d+)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_HTML_LINE = re.compile(
|
||||
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_PLAIN_LINE = re.compile(
|
||||
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/plain[^'\"]*['\"]\}\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_PAREN_JSON = re.compile(
|
||||
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,\s*"
|
||||
r"\{['\"]Content-Type['\"]: ['\"]application/json['\"]\}\s*,?\s*\)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_PAREN_JSON_NOHDR = re.compile(
|
||||
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,?\s*\)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_PAREN_HTML = re.compile(
|
||||
r"return \(\s*([^,]+?)\s*,\s*(\d+)\s*,\s*"
|
||||
r"\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*,?\s*\)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _insert_imports(text: str) -> str:
|
||||
if "from fastapi import APIRouter" in text:
|
||||
return text
|
||||
fut = re.search(r"^from __future__ import annotations\n", text, re.M)
|
||||
if fut:
|
||||
return text[: fut.end()] + "\n" + IMPORT_BLOCK + text[fut.end() :]
|
||||
doc = re.match(r'("""[\s\S]*?"""\n+|\'\'\'[\s\S]*?\'\'\'\n+)', text)
|
||||
if doc:
|
||||
return text[: doc.end()] + "\n" + IMPORT_BLOCK + text[doc.end() :]
|
||||
return IMPORT_BLOCK + text
|
||||
|
||||
|
||||
def _strip_microdot(text: str) -> str:
|
||||
text = re.sub(r"from microdot import Microdot(?:, send_file)?\n", "", text)
|
||||
text = re.sub(r"from microdot\.session import with_session\n", "", text)
|
||||
text = re.sub(r"from microdot import send_file\n", "", text)
|
||||
text = text.replace("controller = Microdot()", "router = APIRouter()")
|
||||
text = text.replace("@controller.", "@router.")
|
||||
return text
|
||||
|
||||
|
||||
def _convert_paths(text: str) -> str:
|
||||
def fix(m: re.Match) -> str:
|
||||
method, path = m.group(1), m.group(2)
|
||||
if path == "":
|
||||
path = "/"
|
||||
path = re.sub(r"<(\w+)>", r"{\1}", path)
|
||||
return f'@router.{method}("{path}")'
|
||||
|
||||
return re.sub(
|
||||
r"@router\.(get|post|put|delete|patch)\(['\"]([^'\"]*)['\"]\)",
|
||||
fix,
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
def _convert_request_access(text: str) -> str:
|
||||
text = text.replace("request.json or {}", "await read_json(request)")
|
||||
text = re.sub(r"(?<![.\w])request\.json(?!\w)", "await read_json(request)", text)
|
||||
text = text.replace("request.args.get", "request.query_params.get")
|
||||
return text
|
||||
|
||||
|
||||
def _convert_request_annotations(text: str) -> str:
|
||||
text = re.sub(r"async def (\w+)\(request,", r"async def \1(request: Request,", text)
|
||||
text = re.sub(r"async def (\w+)\(request\)", r"async def \1(request: Request)", text)
|
||||
return text
|
||||
|
||||
|
||||
def _convert_returns(text: str) -> str:
|
||||
text = _PAREN_JSON.sub(r"return J(\1, \2)", text)
|
||||
text = _PAREN_JSON_NOHDR.sub(r"return J(\1, \2)", text)
|
||||
text = _PAREN_HTML.sub(r"return html_response(\1, \2)", text)
|
||||
text = _JSON_HEADERS.sub(r"return J(\1, \2)", text)
|
||||
text = _JSON_PLAIN.sub(r"\1return J(\2, \3)", text)
|
||||
text = _HTML_LINE.sub(r"\1return html_response(\2, \3)", text)
|
||||
text = _PLAIN_LINE.sub(r"\1return plain(\2, \3)", text)
|
||||
text = re.sub(
|
||||
r'^(\s*)return "([^"]+)",\s*(\d+)\s*$',
|
||||
r'\1return plain("\2", \3)',
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def convert_file(path: Path) -> str:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "Microdot" not in text:
|
||||
return text
|
||||
text = _strip_microdot(text)
|
||||
text = _insert_imports(text)
|
||||
text = _convert_paths(text)
|
||||
text = _convert_request_access(text)
|
||||
text = _convert_request_annotations(text)
|
||||
text = _convert_returns(text)
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for path in sorted(CONTROLLERS.glob("*.py")):
|
||||
if path.name == "__init__.py":
|
||||
continue
|
||||
path.write_text(convert_file(path), encoding="utf-8")
|
||||
print(path.name)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -35,4 +35,4 @@ if [ -z "$PYTHON" ]; then
|
||||
fi
|
||||
|
||||
cd "$ROOT/src"
|
||||
exec "$PYTHON" -u main.py
|
||||
exec "$PYTHON" -u -m uvicorn fastapi_app:app --host 0.0.0.0 --port "$PORT"
|
||||
|
||||
320
src/app_factory.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Application factory: FastAPI routes and shared runtime startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
import controllers.wifi_bridge as wifi_bridge_controller
|
||||
from http_responses import send_file, send_html_file
|
||||
from http_session import SessionMiddleware
|
||||
from models.transport import (
|
||||
BridgeSerialTransport,
|
||||
BridgeWsTransport,
|
||||
get_bridge,
|
||||
set_bridge,
|
||||
)
|
||||
from models.device import Device
|
||||
from models.bridge_serial_client import init_bridge_serial_client
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_bridge_uplink
|
||||
from util.bridge_runtime import set_bridge_uplink_handler
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
from util.wifi_driver_runtime import start_wifi_driver_runtime, stop_wifi_driver_runtime
|
||||
|
||||
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
_dev_build_id: Optional[str] = None
|
||||
|
||||
|
||||
def dev_build_id() -> Optional[str]:
|
||||
global _dev_build_id
|
||||
if not live_reload_enabled():
|
||||
return None
|
||||
if _dev_build_id is None:
|
||||
_dev_build_id = secrets.token_hex(12)
|
||||
return _dev_build_id
|
||||
|
||||
|
||||
def dev_client_revision() -> Optional[str]:
|
||||
"""Revision of static/template assets (changes when UI files are saved)."""
|
||||
if not live_reload_enabled():
|
||||
return None
|
||||
base = _SRC_DIR
|
||||
parts: list[str] = []
|
||||
for sub in ("static", "templates"):
|
||||
root = os.path.join(base, sub)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for name in sorted(files):
|
||||
if not name.endswith((".js", ".css", ".html")):
|
||||
continue
|
||||
path = os.path.join(dirpath, name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
parts.append(f"{path}:{st.st_mtime_ns}:{st.st_size}")
|
||||
if not parts:
|
||||
return "0"
|
||||
digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
|
||||
return digest[:16]
|
||||
|
||||
|
||||
def mount_controller_routers(app: FastAPI) -> None:
|
||||
"""Register all controller API routers."""
|
||||
app.include_router(preset.router, prefix="/presets", tags=["presets"])
|
||||
app.include_router(profile.router, prefix="/profiles", tags=["profiles"])
|
||||
app.include_router(group.router, prefix="/groups", tags=["groups"])
|
||||
app.include_router(sequence.router, prefix="/sequences", tags=["sequences"])
|
||||
app.include_router(zone.router, prefix="/zones", tags=["zones"])
|
||||
app.include_router(palette.router, prefix="/palettes", tags=["palettes"])
|
||||
app.include_router(scene.router, prefix="/scenes", tags=["scenes"])
|
||||
app.include_router(pattern.router, prefix="/patterns", tags=["patterns"])
|
||||
app.include_router(settings_controller.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(
|
||||
wifi_bridge_controller.router, prefix="/settings/wifi", tags=["wifi"]
|
||||
)
|
||||
app.include_router(device_controller.router, prefix="/devices", tags=["devices"])
|
||||
app.include_router(led_tool_controller.router, prefix="/led-tool", tags=["led-tool"])
|
||||
|
||||
|
||||
def mount_static_routes(app: FastAPI, *, inject_live_reload: bool = False) -> None:
|
||||
"""Index page, favicon, and static assets."""
|
||||
build_id = dev_build_id() if inject_live_reload else None
|
||||
live_tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
if build_id:
|
||||
return send_html_file("templates/index.html", inject=live_tag)
|
||||
return send_file("templates/index.html")
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
return PlainTextResponse("", status_code=204)
|
||||
|
||||
static_dir = os.path.join(_SRC_DIR, "static")
|
||||
if os.path.isdir(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
class AppRuntime:
|
||||
"""Holds long-lived services started with the HTTP server."""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.audio_detector = AudioBeatDetector()
|
||||
self.bridge = None
|
||||
|
||||
async def startup(self, *, test_mode: bool = False) -> None:
|
||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||
|
||||
if test_mode:
|
||||
return
|
||||
|
||||
self.bridge = get_bridge(self.settings)
|
||||
set_bridge(self.bridge)
|
||||
|
||||
bridge_mode = str(self.settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
if bridge_mode == "wifi":
|
||||
ws_url = str(self.settings.get("bridge_ws_url") or "").strip()
|
||||
if ws_url:
|
||||
try:
|
||||
ch = int(self.settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = WIFI_CHANNEL_DEFAULT
|
||||
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||
ws_client.start()
|
||||
set_bridge(BridgeWsTransport())
|
||||
elif bridge_mode == "serial":
|
||||
serial_port = str(self.settings.get("bridge_serial_port") or "").strip()
|
||||
if serial_port:
|
||||
baud = 115200
|
||||
for prof in self.settings.get("bridges") or []:
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||
continue
|
||||
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||
continue
|
||||
try:
|
||||
baud = int(prof.get("serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
try:
|
||||
baud = int(self.settings.get("bridge_serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||
serial_client.start()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
|
||||
try:
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
audio_detector_module.set_shared_beat_detector(self.audio_detector)
|
||||
except Exception as e:
|
||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
persisted = read_audio_run_state()
|
||||
if persisted.get("enabled"):
|
||||
sel = persisted.get("device_select") or persisted.get("device")
|
||||
dev = coerce_audio_device(sel)
|
||||
self.audio_detector.start(device=dev)
|
||||
print("[startup] audio beat detector started from saved run state")
|
||||
except Exception as e:
|
||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
from util import beat_status_broadcaster as beat_sse
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
beat_sse.configure(
|
||||
loop=loop,
|
||||
status_builder=lambda: audio_status_payload(
|
||||
self.audio_detector, self.settings
|
||||
),
|
||||
)
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
Device()
|
||||
if not test_mode:
|
||||
await start_wifi_driver_runtime(self.settings)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
try:
|
||||
await stop_wifi_driver_runtime()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import beat_status_broadcaster as beat_sse
|
||||
|
||||
await beat_sse.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.stop()
|
||||
t = getattr(seq_pb, "_background_beat_task", None)
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def audio_status_payload(
|
||||
audio_detector: AudioBeatDetector, settings: Any
|
||||
) -> dict:
|
||||
from util import beat_driver_route
|
||||
from util import sequence_playback
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st = audio_detector.status()
|
||||
st["sequence"] = sequence_playback.playback_status()
|
||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||
seq = st.get("sequence")
|
||||
running = bool(st.get("running"))
|
||||
beat_readout = ""
|
||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||
if not beat_readout:
|
||||
tail = sequence_playback.last_completed_beat_readout()
|
||||
if tail:
|
||||
beat_readout = tail
|
||||
if not beat_readout and st.get("running"):
|
||||
mb = st.get("manual_beat_stride")
|
||||
if isinstance(mb, dict) and mb.get("active"):
|
||||
try:
|
||||
n = int(mb.get("stride_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
try:
|
||||
bi = int(mb.get("beat_in_stride") or 1)
|
||||
except (TypeError, ValueError):
|
||||
bi = 1
|
||||
pos = min(n, max(1, bi))
|
||||
beat_readout = f"{pos}/{n}"
|
||||
else:
|
||||
try:
|
||||
bs = int(st.get("beat_seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bs = 0
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||
try:
|
||||
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||
except (TypeError, ValueError):
|
||||
st["input_volume"] = 100
|
||||
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||
if seq_wait not in ("beat", "downbeat"):
|
||||
seq_wait = "beat"
|
||||
st["sequence_switch_wait_saved"] = seq_wait
|
||||
from util.sequence_playback import effective_sequence_switch_wait
|
||||
|
||||
st["sequence_switch_wait"] = effective_sequence_switch_wait()
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
sim_bpm = int(clamp_bpm(settings.get("audio_simulated_bpm")))
|
||||
st["audio_simulated_bpm"] = sim_bpm
|
||||
st["sequence_pending"] = sequence_playback.pending_play_status()
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
st["bpm_simulated"] = not audio_detector_module.shared_beat_detector_timing_sequences()
|
||||
if running and st.get("bpm") is not None:
|
||||
st["bpm"] = float(clamp_bpm(st["bpm"]))
|
||||
if not running:
|
||||
st["bpm"] = float(sim_bpm)
|
||||
st["simulated_beat_tick"] = sequence_playback.simulated_beat_tick()
|
||||
if not running:
|
||||
phase = sequence_playback.simulated_beat_phase_snapshot()
|
||||
st["bar_beat"] = phase.get("bar_beat")
|
||||
st["is_downbeat"] = bool(phase.get("is_downbeat"))
|
||||
st["bar_phase_readout"] = str(phase.get("bar_phase_readout") or "")
|
||||
st["phase_confidence"] = 0.0
|
||||
return st
|
||||
@@ -1,4 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.device import (
|
||||
Device,
|
||||
derive_device_mac,
|
||||
@@ -46,7 +49,7 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
|
||||
body["save"] = True
|
||||
if select is not None:
|
||||
body["select"] = select
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
return J(body, separators=(",", ":"))
|
||||
|
||||
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
|
||||
IDENTIFY_OFF_DELAY_S = 2.0
|
||||
@@ -69,7 +72,7 @@ def _brightness_save_message_json(b_val: int) -> str:
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
devices = Device()
|
||||
_group_registry = Group()
|
||||
_pi_settings = get_settings()
|
||||
@@ -246,38 +249,34 @@ async def send_identify_to_group_devices(
|
||||
return len(seen), errors
|
||||
|
||||
|
||||
@controller.get("")
|
||||
async def list_devices(request):
|
||||
@router.get("/")
|
||||
async def list_devices(request: Request):
|
||||
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
devices_data = {}
|
||||
for dev_id in devices.list():
|
||||
d = devices.read(dev_id)
|
||||
if d:
|
||||
devices_data[dev_id] = _device_json_with_live_status(d)
|
||||
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/resolve-brightness")
|
||||
async def resolve_brightness_batch(request):
|
||||
return J(devices_data, 200)
|
||||
@router.post("/resolve-brightness")
|
||||
async def resolve_brightness_batch(request: Request):
|
||||
"""
|
||||
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0–255 }``.
|
||||
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
data = {}
|
||||
macs = data.get("macs")
|
||||
if not isinstance(macs, list):
|
||||
return json.dumps({"error": "macs must be an array"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "macs must be an array"}, 400)
|
||||
zb = None
|
||||
if isinstance(data, dict) and data.get("zone_brightness") is not None:
|
||||
try:
|
||||
zb = _validate_output_brightness(data.get("zone_brightness"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
values = {}
|
||||
for raw in macs:
|
||||
m = normalize_mac(str(raw))
|
||||
@@ -290,47 +289,37 @@ async def resolve_brightness_batch(request):
|
||||
m,
|
||||
zone_brightness=zb,
|
||||
)
|
||||
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
|
||||
return J({"values": values}, 200)
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
@router.get("/{id}")
|
||||
async def get_device(request: Request, id):
|
||||
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
dev = devices.read(id)
|
||||
if dev:
|
||||
return json.dumps(_device_json_with_live_status(dev)), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J(_device_json_with_live_status(dev), 200)
|
||||
return J({"error": "Device not found"}, 404)
|
||||
|
||||
|
||||
@controller.post("")
|
||||
async def create_device(request):
|
||||
@router.post("/")
|
||||
async def create_device(request: Request):
|
||||
"""Create a new device."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
name = data.get("name", "").strip()
|
||||
if not name:
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "name is required"}, 400)
|
||||
try:
|
||||
device_type = validate_device_type(data.get("type", "led"))
|
||||
transport = validate_device_transport(data.get("transport", "espnow"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": str(e)}, 400)
|
||||
address = data.get("address")
|
||||
mac = data.get("mac")
|
||||
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
}, 400)
|
||||
default_pattern = data.get("default_pattern")
|
||||
zl = data.get("zones")
|
||||
if isinstance(zl, list):
|
||||
@@ -347,20 +336,20 @@ async def create_device(request):
|
||||
transport=transport,
|
||||
)
|
||||
dev = devices.read(dev_id)
|
||||
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||
return J({dev_id: dev}, 201)
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 409 if "already exists" in msg.lower() else 400
|
||||
return json.dumps({"error": msg}), code, {"Content-Type": "application/json"}
|
||||
return J({"error": msg}, code)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_device(request, id):
|
||||
@router.put("/{id}")
|
||||
async def update_device(request: Request, id):
|
||||
"""Update a device."""
|
||||
try:
|
||||
raw = request.json or {}
|
||||
raw = await read_json(request)
|
||||
data = dict(raw)
|
||||
data.pop("id", None)
|
||||
data.pop("addresses", None)
|
||||
@@ -368,9 +357,7 @@ async def update_device(request, id):
|
||||
if "name" in data:
|
||||
n = (data.get("name") or "").strip()
|
||||
if not n:
|
||||
return json.dumps({"error": "name cannot be empty"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "name cannot be empty"}, 400)
|
||||
data["name"] = n
|
||||
if "type" in data:
|
||||
data["type"] = validate_device_type(data.get("type"))
|
||||
@@ -389,32 +376,24 @@ async def update_device(request, id):
|
||||
from util.beat_driver_route import remap_beat_route_device_name
|
||||
|
||||
remap_beat_route_device_name(on, nn)
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J(devices.read(id), 200)
|
||||
return J({"error": "Device not found"}, 404)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
async def delete_device(request, id):
|
||||
@router.delete("/{id}")
|
||||
async def delete_device(request: Request, id):
|
||||
"""Delete a device."""
|
||||
if devices.delete(id):
|
||||
return (
|
||||
json.dumps({"message": "Device deleted successfully"}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"message": "Device deleted successfully"}, 200)
|
||||
return J({"error": "Device not found"}, 404)
|
||||
|
||||
|
||||
@controller.post("/groups")
|
||||
async def update_device_groups(request):
|
||||
@router.post("/groups")
|
||||
async def update_device_groups(request: Request):
|
||||
"""Push current group membership to all ESP-NOW drivers in the registry."""
|
||||
_ = request
|
||||
from util.espnow_registry import push_groups_all_espnow_devices
|
||||
@@ -422,16 +401,12 @@ async def update_device_groups(request):
|
||||
result = await push_groups_all_espnow_devices()
|
||||
status = 200 if result.get("ok") else 503
|
||||
if not result.get("total"):
|
||||
return (
|
||||
json.dumps({"ok": False, "error": "No ESP-NOW devices in registry"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return json.dumps(result), status, {"Content-Type": "application/json"}
|
||||
return J({"ok": False, "error": "No ESP-NOW devices in registry"}, 400)
|
||||
return J(result, status)
|
||||
|
||||
|
||||
@controller.post("/ping")
|
||||
async def ping_devices(request):
|
||||
@router.post("/ping")
|
||||
async def ping_devices(request: Request):
|
||||
"""
|
||||
Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s).
|
||||
JSON body: ``{"timeout_s": 3.0}`` (optional).
|
||||
@@ -440,21 +415,19 @@ async def ping_devices(request):
|
||||
|
||||
timeout_s = 3.0
|
||||
try:
|
||||
body = request.json or {}
|
||||
body = await read_json(request)
|
||||
if isinstance(body, dict) and body.get("timeout_s") is not None:
|
||||
timeout_s = float(body["timeout_s"])
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"error": "Invalid timeout_s"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Invalid timeout_s"}, 400)
|
||||
timeout_s = max(0.5, min(30.0, timeout_s))
|
||||
result = await run_ping(timeout_s=timeout_s)
|
||||
status = 200 if result.get("ok") else 503
|
||||
return json.dumps(result), status, {"Content-Type": "application/json"}
|
||||
return J(result, status)
|
||||
|
||||
|
||||
@controller.post("/<id>/identify")
|
||||
async def identify_device(request, id):
|
||||
@router.post("/{id}/identify")
|
||||
async def identify_device(request: Request, id):
|
||||
"""
|
||||
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
|
||||
this device name — same combined shape as profile sends the driver already accepts over TCP
|
||||
@@ -462,30 +435,26 @@ async def identify_device(request, id):
|
||||
"""
|
||||
status, err = await send_identify_to_device(id)
|
||||
if status == 200:
|
||||
return json.dumps({"message": "Identify sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
|
||||
return J({"message": "Identify sent"}, 200)
|
||||
return J({"error": err}, status)
|
||||
|
||||
|
||||
@controller.post("/<id>/brightness")
|
||||
async def push_device_output_brightness(request, id):
|
||||
@router.post("/{id}/brightness")
|
||||
async def push_device_output_brightness(request: Request, id):
|
||||
"""
|
||||
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
|
||||
in JSON body — single ``b`` (``v``/``b``/``save``). Wi‑Fi or ESP‑NOW.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = request.json or {}
|
||||
return J({"error": "Device not found"}, 404)
|
||||
body = await read_json(request)
|
||||
zb = None
|
||||
if isinstance(body, dict) and body.get("zone_brightness") is not None:
|
||||
try:
|
||||
zb = _validate_output_brightness(body.get("zone_brightness"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
b_val = effective_brightness_for_mac(
|
||||
_pi_settings,
|
||||
_group_registry,
|
||||
@@ -496,40 +465,30 @@ async def push_device_output_brightness(request, id):
|
||||
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
try:
|
||||
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Send failed"}, 503)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 503)
|
||||
|
||||
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"message": "brightness sent", "brightness": b_val}, 200)
|
||||
|
||||
|
||||
@controller.post("/<id>/driver-config")
|
||||
async def push_driver_config(request, id):
|
||||
@router.post("/{id}/driver-config")
|
||||
async def push_driver_config(request: Request, id):
|
||||
"""
|
||||
Push ``device_config`` to an ESP-NOW LED driver.
|
||||
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Device not found"}, 404)
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = request.json or {}
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
body = await read_json(request)
|
||||
dc = {}
|
||||
if isinstance(body.get("name"), str) and body["name"].strip():
|
||||
dc["name"] = body["name"].strip()
|
||||
@@ -549,31 +508,21 @@ async def push_driver_config(request, id):
|
||||
if sm in ("default", "last", "off"):
|
||||
dc["startup_mode"] = sm
|
||||
if not dc:
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
}, 400)
|
||||
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"message": "driver-config sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Send failed"}, 503)
|
||||
return J({"message": "driver-config sent"}, 200)
|
||||
|
||||
|
||||
@controller.post("/<id>/patterns/push")
|
||||
async def push_patterns_ota(request, id):
|
||||
@router.post("/{id}/patterns/push")
|
||||
async def push_patterns_ota(request: Request, id):
|
||||
"""
|
||||
Pattern OTA over HTTP is not available for ESP-NOW drivers.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps(
|
||||
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": "Device not found"}, 404)
|
||||
return J({"error": "Pattern OTA push is not supported for ESP-NOW devices"}, 400)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
import asyncio
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
@@ -9,7 +11,7 @@ from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
groups = Group()
|
||||
devices = Device()
|
||||
_pi_settings = get_settings()
|
||||
@@ -41,27 +43,25 @@ def _filtered_groups_dict(session):
|
||||
return out
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@router.get("/")
|
||||
@with_session
|
||||
async def list_groups(request, session):
|
||||
async def list_groups(request: Request, session):
|
||||
"""List groups visible for the current profile (shared + profile-scoped)."""
|
||||
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
|
||||
return J(_filtered_groups_dict(session), 200)
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@router.get("/{id}")
|
||||
@with_session
|
||||
async def get_group(request, session, id):
|
||||
async def get_group(request: Request, session, id):
|
||||
"""Get a specific group by ID (404 if scoped to another profile)."""
|
||||
group = groups.read(id)
|
||||
if not group or not isinstance(group, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return J({"error": "Group not found"}, 404)
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return json.dumps(group), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
return J({"error": "Group not found"}, 404)
|
||||
return J(group, 200)
|
||||
def _sanitize_group_bridge_id_write(data):
|
||||
"""Per-group bridge assignment is disabled; ignore writes."""
|
||||
if isinstance(data, dict) and "bridge_id" in data:
|
||||
@@ -89,12 +89,12 @@ def _sanitize_group_profile_id_write(data, session):
|
||||
data.pop("profile_id", None)
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@router.post("/")
|
||||
@with_session
|
||||
async def create_group(request, session):
|
||||
async def create_group(request: Request, session):
|
||||
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
data = dict(await read_json(request))
|
||||
name = data.get("name", "")
|
||||
profile_scoped = bool(data.pop("profile_scoped", False))
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
@@ -111,19 +111,17 @@ async def create_group(request, session):
|
||||
g = groups.read(group_id)
|
||||
if g:
|
||||
await push_groups_for_group_devices(g)
|
||||
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
|
||||
return J(groups.read(group_id), 201)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{id}")
|
||||
@with_session
|
||||
async def update_group(request, session, id):
|
||||
async def update_group(request: Request, session, id):
|
||||
"""Update an existing group."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
if not isinstance(data, dict):
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
data = dict(data)
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
_sanitize_group_bridge_id_write(data)
|
||||
@@ -131,29 +129,26 @@ async def update_group(request, session, id):
|
||||
g = groups.read(id)
|
||||
if g:
|
||||
await push_groups_for_group_devices(g)
|
||||
return json.dumps(g), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return J(g, 200)
|
||||
return J({"error": "Group not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete("/<id>")
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
@with_session
|
||||
async def delete_group(request, session, id):
|
||||
async def delete_group(request: Request, session, id):
|
||||
"""Delete a group (not allowed for another profile's scoped group)."""
|
||||
g = groups.read(id)
|
||||
if not g or not isinstance(g, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return J({"error": "Group not found"}, 404)
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return J({"error": "Group not found"}, 404)
|
||||
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
|
||||
if groups.delete(id):
|
||||
await push_groups_for_group_devices({"devices": macs})
|
||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
|
||||
return J({"message": "Group deleted successfully"}, 200)
|
||||
return J({"error": "Group not found"}, 404)
|
||||
def _group_driver_config_payload(doc):
|
||||
"""Build ``device_config`` dict from stored group Wi‑Fi defaults (non-empty only)."""
|
||||
dc = {}
|
||||
@@ -194,18 +189,17 @@ def _read_group_for_session(session, id):
|
||||
return g
|
||||
|
||||
|
||||
@controller.post("/<id>/driver-config")
|
||||
@router.post("/{id}/driver-config")
|
||||
@with_session
|
||||
async def push_group_driver_config(request, session, id):
|
||||
async def push_group_driver_config(request: Request, session, id):
|
||||
"""
|
||||
Push group driver defaults to every ESP-NOW device listed in the group.
|
||||
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
||||
"""
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
body = request.json or {}
|
||||
return J({"error": "Group not found"}, 404)
|
||||
body = await read_json(request)
|
||||
merged = dict(gdoc)
|
||||
if isinstance(body, dict):
|
||||
for k in (
|
||||
@@ -218,16 +212,19 @@ async def push_group_driver_config(request, session, id):
|
||||
merged[k] = body[k]
|
||||
dc = _group_driver_config_payload(merged)
|
||||
if not dc:
|
||||
return json.dumps(
|
||||
{"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
return J(
|
||||
{
|
||||
"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"
|
||||
},
|
||||
400,
|
||||
)
|
||||
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
sent = 0
|
||||
errors = []
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
payload = {"v": "1", "device_config": dc, "save": True}
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
@@ -245,9 +242,7 @@ async def push_group_driver_config(request, session, id):
|
||||
except Exception as e:
|
||||
errors.append({"mac": m, "error": str(e)})
|
||||
|
||||
return json.dumps(
|
||||
{"message": "driver-config sent", "sent": sent, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
return J({"message": "driver-config sent", "sent": sent, "errors": errors}, 200)
|
||||
|
||||
|
||||
def _brightness_save_message_json(b_val: int) -> str:
|
||||
@@ -255,16 +250,15 @@ def _brightness_save_message_json(b_val: int) -> str:
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
@controller.post("/<id>/brightness")
|
||||
@router.post("/{id}/brightness")
|
||||
@with_session
|
||||
async def push_group_output_brightness(request, session, id):
|
||||
async def push_group_output_brightness(request: Request, session, id):
|
||||
"""
|
||||
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
||||
"""
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
return J({"error": "Group not found"}, 404)
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
sent = 0
|
||||
errors = []
|
||||
@@ -309,14 +303,12 @@ async def push_group_output_brightness(request, session, id):
|
||||
elif err:
|
||||
errors.append({"mac": m, "error": err})
|
||||
|
||||
return json.dumps(
|
||||
{"message": "brightness sent", "sent": sent, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
return J({"message": "brightness sent", "sent": sent, "errors": errors}, 200)
|
||||
|
||||
|
||||
@controller.post("/<id>/identify")
|
||||
@router.post("/{id}/identify")
|
||||
@with_session
|
||||
async def identify_group_devices(request, session, id):
|
||||
async def identify_group_devices(request: Request, session, id):
|
||||
"""
|
||||
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
|
||||
in parallel so all drivers in the group blink together.
|
||||
@@ -324,11 +316,11 @@ async def identify_group_devices(request, session, id):
|
||||
_ = request
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
|
||||
return J({"error": "Group not found"}, 404)
|
||||
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
if not mac_list:
|
||||
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": "Group has no devices"}, 400)
|
||||
|
||||
from controllers.device import send_identify_to_group_devices
|
||||
|
||||
@@ -342,15 +334,11 @@ async def identify_group_devices(request, session, id):
|
||||
normalized.append(m)
|
||||
|
||||
if not normalized:
|
||||
return json.dumps(
|
||||
{"message": "identify group done", "sent": 0, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
return J({"message": "identify group done", "sent": 0, "errors": errors}, 200)
|
||||
|
||||
sent, batch_errors = await send_identify_to_group_devices(
|
||||
normalized, group_ids=[str(id)]
|
||||
)
|
||||
errors.extend(batch_errors)
|
||||
|
||||
return json.dumps(
|
||||
{"message": "identify group done", "sent": sent, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
return J({"message": "identify group done", "sent": sent, "errors": errors}, 200)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
from serial.tools import list_ports
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
|
||||
_STATIC_ALLOWED = frozenset(
|
||||
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
|
||||
@@ -74,31 +77,17 @@ def _run_led_cli_command(cmd, cli_path: str, timeout_s=180):
|
||||
cwd=os.path.dirname(cli_path),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return (
|
||||
json.dumps({"error": "led-tool command timed out after 180 seconds"}),
|
||||
504,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "led-tool command timed out after 180 seconds"}, 504)
|
||||
except Exception as exc:
|
||||
return (
|
||||
json.dumps({"error": str(exc)}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": str(exc)}, 500)
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
return J({
|
||||
"ok": result.returncode == 0,
|
||||
"returncode": result.returncode,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"command": cmd,
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
}, 200)
|
||||
|
||||
|
||||
def _extract_settings_from_stdout(stdout: str):
|
||||
@@ -112,31 +101,27 @@ def _extract_settings_from_stdout(stdout: str):
|
||||
return None
|
||||
|
||||
|
||||
@controller.get("/editor")
|
||||
async def settings_editor_page(request):
|
||||
@router.get("/editor")
|
||||
async def settings_editor_page(request: Request):
|
||||
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
|
||||
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
|
||||
if not os.path.isfile(path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "led-tool/static/settings_editor.html not found"}, 404)
|
||||
return send_file(path)
|
||||
|
||||
|
||||
@controller.get("/static/<path:filename>")
|
||||
async def led_tool_static(request, filename):
|
||||
@router.get("/static/<path:filename>")
|
||||
async def led_tool_static(request: Request, filename):
|
||||
if filename not in _STATIC_ALLOWED:
|
||||
return "Not found", 404
|
||||
return plain("Not found", 404)
|
||||
path = os.path.join(_led_tool_static_dir(), filename)
|
||||
if not os.path.isfile(path):
|
||||
return "Not found", 404
|
||||
return plain("Not found", 404)
|
||||
return send_file(path)
|
||||
|
||||
|
||||
@controller.get("/ports")
|
||||
async def list_serial_ports(request):
|
||||
@router.get("/ports")
|
||||
async def list_serial_ports(request: Request):
|
||||
ports = _filter_host_serial_ports(
|
||||
[
|
||||
{
|
||||
@@ -147,87 +132,57 @@ async def list_serial_ports(request):
|
||||
for info in list_ports.comports()
|
||||
]
|
||||
)
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
return J({
|
||||
"ports": ports,
|
||||
"led_cli_exists": os.path.exists(_led_cli_path()),
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
}, 200)
|
||||
|
||||
|
||||
@controller.post("/settings")
|
||||
async def apply_settings(request):
|
||||
data = request.json or {}
|
||||
@router.post("/settings")
|
||||
async def apply_settings(request: Request):
|
||||
data = await read_json(request)
|
||||
port = str(data.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "port is required"}, 400)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "led-tool/cli.py not found"}, 500)
|
||||
|
||||
cmd = _build_led_cli_command(port, data) + ["--follow"]
|
||||
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
|
||||
|
||||
|
||||
@controller.post("/reset")
|
||||
@controller.post("/reset/")
|
||||
async def reset_device(request):
|
||||
data = request.json or {}
|
||||
@router.post("/reset")
|
||||
@router.post("/reset/")
|
||||
async def reset_device(request: Request):
|
||||
data = await read_json(request)
|
||||
port = str(data.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "port is required"}, 400)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "led-tool/cli.py not found"}, 500)
|
||||
|
||||
cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"]
|
||||
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
|
||||
|
||||
|
||||
@controller.get("/settings")
|
||||
async def read_settings(request):
|
||||
port = str(request.args.get("port") or "").strip()
|
||||
@router.get("/settings")
|
||||
async def read_settings(request: Request):
|
||||
port = str(request.query_params.get("port") or "").strip()
|
||||
if not port:
|
||||
return (
|
||||
json.dumps({"error": "port is required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "port is required"}, 400)
|
||||
|
||||
cli_path = _led_cli_path()
|
||||
if not os.path.exists(cli_path):
|
||||
return (
|
||||
json.dumps({"error": "led-tool/cli.py not found"}),
|
||||
500,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "led-tool/cli.py not found"}, 500)
|
||||
|
||||
cmd = [sys.executable, cli_path, "--port", port, "--show"]
|
||||
body, status, headers = _run_led_cli_command(cmd, cli_path)
|
||||
if status != 200:
|
||||
return body, status, headers
|
||||
data = json.loads(body)
|
||||
result = _run_led_cli_command(cmd, cli_path)
|
||||
if result.status_code != 200:
|
||||
return result
|
||||
data = json.loads(result.body.decode())
|
||||
data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "")
|
||||
return json.dumps(data), status, headers
|
||||
return J(data, 200)
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
from microdot import Microdot
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.pallet import Palette
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
palettes = Palette()
|
||||
|
||||
@controller.get('')
|
||||
async def list_palettes(request):
|
||||
@router.get("/")
|
||||
async def list_palettes(request: Request):
|
||||
"""List all palettes."""
|
||||
data = {}
|
||||
for pid in palettes.list():
|
||||
colors = palettes.read(pid)
|
||||
data[pid] = colors
|
||||
return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
||||
return J(data, 200)
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_palette(request, id):
|
||||
@router.get("/{id}")
|
||||
async def get_palette(request: Request, id):
|
||||
"""Get a specific palette by ID."""
|
||||
if str(id) in palettes:
|
||||
palette = palettes.read(id)
|
||||
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_palette(request):
|
||||
return J({"colors": palette or [], "id": str(id)}, 200)
|
||||
return J({"error": "Palette not found"}, 404)
|
||||
@router.post("/")
|
||||
async def create_palette(request: Request):
|
||||
"""Create a new palette."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
colors = data.get("colors", None)
|
||||
# Palette no longer needs a name; only colors are stored.
|
||||
palette_id = palettes.create("", colors)
|
||||
created_colors = palettes.read(palette_id) or []
|
||||
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||
return J({"id": str(palette_id), "colors": created_colors}, 201)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_palette(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{id}")
|
||||
async def update_palette(request: Request, id):
|
||||
"""Update an existing palette."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
# Ignore any name field; only colors are relevant.
|
||||
if "name" in data:
|
||||
data.pop("name", None)
|
||||
if palettes.update(id, data):
|
||||
colors = palettes.read(id) or []
|
||||
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
return J({"id": str(id), "colors": colors}, 200)
|
||||
return J({"error": "Palette not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_palette(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
async def delete_palette(request: Request, id):
|
||||
"""Delete a palette."""
|
||||
if palettes.delete(id):
|
||||
return json.dumps({"message": "Palette deleted successfully"}), 200
|
||||
return json.dumps({"error": "Palette not found"}), 404
|
||||
return J({"message": "Palette deleted successfully"}, 200)
|
||||
return J({"error": "Palette not found"}, 404)
|
||||
@@ -1,4 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.pattern import Pattern
|
||||
from models.device import Device
|
||||
from util.driver_patterns import (
|
||||
@@ -12,7 +15,7 @@ import os
|
||||
import socket
|
||||
from urllib.parse import quote
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
patterns = Pattern()
|
||||
|
||||
|
||||
@@ -147,26 +150,24 @@ def build_runtime_pattern_map():
|
||||
result[name] = {}
|
||||
return result
|
||||
|
||||
@controller.get('/definitions')
|
||||
async def get_pattern_definitions(request):
|
||||
@router.get("/definitions")
|
||||
async def get_pattern_definitions(request: Request):
|
||||
"""Get definitions for patterns currently available on the driver."""
|
||||
definitions = build_runtime_pattern_map()
|
||||
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||
return J(definitions, 200)
|
||||
|
||||
|
||||
@controller.get('/ota/manifest')
|
||||
async def ota_manifest(request):
|
||||
@router.get("/ota/manifest")
|
||||
async def ota_manifest(request: Request):
|
||||
"""Manifest of driver pattern source files for OTA pulls."""
|
||||
base_dir = driver_patterns_dir()
|
||||
host = request.headers.get("Host", "")
|
||||
if not host:
|
||||
return json.dumps({"error": "Missing Host header"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Missing Host header"}, 400)
|
||||
try:
|
||||
names = sorted(os.listdir(base_dir))
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
files = []
|
||||
for name in names:
|
||||
@@ -177,97 +178,69 @@ async def ota_manifest(request):
|
||||
"url": "http://%s/patterns/ota/file/%s" % (host, name),
|
||||
})
|
||||
|
||||
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
|
||||
return J({"files": files}, 200)
|
||||
|
||||
|
||||
@controller.get('/ota/file/<name>')
|
||||
async def ota_pattern_file(request, name):
|
||||
@router.get("/ota/file/{name}")
|
||||
async def ota_pattern_file(request: Request, name):
|
||||
"""Serve one driver pattern source file for OTA pulls."""
|
||||
fname = normalize_pattern_py_filename(name)
|
||||
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
|
||||
return json.dumps({"error": "Invalid filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Invalid filename"}, 400)
|
||||
if is_firmware_builtin_pattern_module(fname):
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "on and off are built into the driver firmware; there is no module file to serve.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}, 400)
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, fname)
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
|
||||
@controller.post('/<name>/send')
|
||||
async def send_pattern_to_device(request, name):
|
||||
}, 404)
|
||||
return plain(content, 200)
|
||||
@router.post("/{name}/send")
|
||||
async def send_pattern_to_device(request: Request, name):
|
||||
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
|
||||
if not isinstance(name, str):
|
||||
return json.dumps({"error": "Invalid pattern name"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Invalid pattern name"}, 400)
|
||||
filename = normalize_pattern_py_filename(name)
|
||||
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "Invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Invalid pattern filename"}, 400)
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "on and off are built into the driver firmware; send does not apply.",
|
||||
}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}, 400)
|
||||
|
||||
devices = Device()
|
||||
body = request.json or {}
|
||||
body = await read_json(request)
|
||||
requested_device_id = str(body.get("device_id") or "").strip()
|
||||
|
||||
base = driver_patterns_dir()
|
||||
path = os.path.join(base, filename)
|
||||
if not os.path.exists(path):
|
||||
return json.dumps(
|
||||
{
|
||||
return J({
|
||||
"error": "Pattern file not found",
|
||||
"path": path,
|
||||
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
|
||||
}
|
||||
), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}, 404)
|
||||
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
source = f.read()
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
target_ids = []
|
||||
if requested_device_id:
|
||||
dev = devices.read(requested_device_id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Device not found"}, 404)
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "Pattern send is only supported for Wi-Fi devices"}, 400)
|
||||
target_ids = [requested_device_id]
|
||||
else:
|
||||
for did in devices.list():
|
||||
@@ -275,9 +248,7 @@ async def send_pattern_to_device(request, name):
|
||||
if (dev.get("transport") or "").lower() == "wifi":
|
||||
target_ids.append(str(did))
|
||||
if not target_ids:
|
||||
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "No Wi-Fi devices found"}, 404)
|
||||
|
||||
sent_ids = []
|
||||
for did in target_ids:
|
||||
@@ -290,16 +261,12 @@ async def send_pattern_to_device(request, name):
|
||||
sent_ids.append(did)
|
||||
|
||||
if not sent_ids:
|
||||
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "No Wi-Fi drivers accepted pattern upload"}, 503)
|
||||
return J({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}, 200)
|
||||
|
||||
|
||||
@controller.post('/upload')
|
||||
async def upload_pattern_file(request):
|
||||
@router.post("/upload")
|
||||
async def upload_pattern_file(request: Request):
|
||||
"""
|
||||
Upload a pattern source file to led-controller local storage.
|
||||
|
||||
@@ -310,56 +277,44 @@ async def upload_pattern_file(request):
|
||||
"overwrite": true | false # optional, default true
|
||||
}
|
||||
"""
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
raw_name = data.get("name") or data.get("filename")
|
||||
code = data.get("code")
|
||||
overwrite = data.get("overwrite", True)
|
||||
overwrite = bool(overwrite)
|
||||
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
return json.dumps({"error": "name is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "name is required"}, 400)
|
||||
filename = raw_name.strip()
|
||||
if not filename.endswith(".py"):
|
||||
filename += ".py"
|
||||
if not _safe_pattern_filename(filename) or filename == "__init__.py":
|
||||
return json.dumps({"error": "invalid pattern filename"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "invalid pattern filename"}, 400)
|
||||
if is_firmware_builtin_pattern_module(filename):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "code is required"}, 400)
|
||||
|
||||
path = os.path.join(driver_patterns_dir(), filename)
|
||||
exists = os.path.exists(path)
|
||||
if exists and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "pattern file already exists", "name": filename}, 409)
|
||||
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"message": "Pattern uploaded",
|
||||
"name": filename,
|
||||
"overwrote": bool(exists),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
}, 201)
|
||||
|
||||
|
||||
@controller.post('/driver')
|
||||
async def create_driver_pattern(request):
|
||||
@router.post("/driver")
|
||||
async def create_driver_pattern(request: Request):
|
||||
"""
|
||||
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
|
||||
metadata in db/pattern.json (Pattern model).
|
||||
@@ -372,33 +327,25 @@ async def create_driver_pattern(request):
|
||||
n1..n8 (optional string labels),
|
||||
overwrite (optional, default true).
|
||||
"""
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
key = _normalize_pattern_key(data.get("name") or "")
|
||||
if not _valid_pattern_key(key):
|
||||
return json.dumps({
|
||||
return J({
|
||||
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
|
||||
}), 400, {"Content-Type": "application/json"}
|
||||
}, 400)
|
||||
if is_firmware_builtin_pattern_module(key):
|
||||
return json.dumps(
|
||||
{"error": "on and off are built into the driver firmware; use a different pattern name."}
|
||||
), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
|
||||
|
||||
code = data.get("code")
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "code is required (upload a .py file or paste source)"}, 400)
|
||||
|
||||
overwrite = bool(data.get("overwrite", True))
|
||||
|
||||
filename = key + ".py"
|
||||
py_path = os.path.join(driver_patterns_dir(), filename)
|
||||
if os.path.exists(py_path) and not overwrite:
|
||||
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "pattern file already exists", "name": filename}, 409)
|
||||
|
||||
meta = {}
|
||||
for fld in ("min_delay", "max_delay", "max_colors"):
|
||||
@@ -407,9 +354,7 @@ async def create_driver_pattern(request):
|
||||
try:
|
||||
meta[fld] = int(data[fld])
|
||||
except (TypeError, ValueError):
|
||||
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"error": "%s must be an integer" % fld}, 400)
|
||||
|
||||
if "has_background" in data:
|
||||
meta["has_background"] = bool(data.get("has_background"))
|
||||
@@ -432,41 +377,39 @@ async def create_driver_pattern(request):
|
||||
with open(py_path, "w") as f:
|
||||
f.write(code)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
if patterns.read(key):
|
||||
patterns.update(key, meta)
|
||||
else:
|
||||
patterns.create(key, meta)
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"message": "Pattern created",
|
||||
"name": key,
|
||||
"file": filename,
|
||||
"metadata": patterns.read(key),
|
||||
}), 201, {"Content-Type": "application/json"}
|
||||
}, 201)
|
||||
|
||||
|
||||
@controller.get('')
|
||||
async def list_patterns(request):
|
||||
@router.get("/")
|
||||
async def list_patterns(request: Request):
|
||||
"""List patterns for UI (DB metadata + local driver additions)."""
|
||||
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
|
||||
return J(build_runtime_pattern_map(), 200)
|
||||
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_pattern(request, id):
|
||||
@router.get("/{id}")
|
||||
async def get_pattern(request: Request, id):
|
||||
"""Get a specific pattern by ID."""
|
||||
pattern = patterns.read(id)
|
||||
if pattern is not None:
|
||||
return json.dumps(pattern), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Pattern not found"}), 404
|
||||
|
||||
|
||||
@controller.post('')
|
||||
async def create_pattern(request):
|
||||
return J(pattern, 200)
|
||||
return J({"error": "Pattern not found"}, 404)
|
||||
@router.post("/")
|
||||
async def create_pattern(request: Request):
|
||||
"""Create a new pattern."""
|
||||
try:
|
||||
payload = request.json or {}
|
||||
payload = await read_json(request)
|
||||
name = payload.get("name", "")
|
||||
pattern_data = payload.get("data", {})
|
||||
|
||||
@@ -483,26 +426,22 @@ async def create_pattern(request):
|
||||
extra.pop("data", None)
|
||||
if extra:
|
||||
patterns.update(pattern_id, extra)
|
||||
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
|
||||
return J(patterns.read(pattern_id), 201)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_pattern(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{id}")
|
||||
async def update_pattern(request: Request, id):
|
||||
"""Update an existing pattern."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
if patterns.update(id, data):
|
||||
return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Pattern not found"}), 404
|
||||
return J(patterns.read(id), 200)
|
||||
return J({"error": "Pattern not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_pattern(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
async def delete_pattern(request: Request, id):
|
||||
"""Delete a pattern."""
|
||||
if patterns.delete(id):
|
||||
return json.dumps({"message": "Pattern deleted successfully"}), 200
|
||||
return json.dumps({"error": "Pattern not found"}), 404
|
||||
return J({"message": "Pattern deleted successfully"}, 200)
|
||||
return J({"error": "Pattern not found"}, 404)
|
||||
@@ -1,5 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.pallet import Palette
|
||||
@@ -13,7 +15,7 @@ from util.espnow_message import build_message, build_preset_dict
|
||||
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
presets = Preset()
|
||||
profiles = Profile()
|
||||
|
||||
@@ -41,76 +43,75 @@ def get_current_profile_id(session=None):
|
||||
return profile_list[0]
|
||||
return None
|
||||
|
||||
@controller.get('')
|
||||
@router.get("/")
|
||||
@with_session
|
||||
async def list_presets(request, session):
|
||||
async def list_presets(request: Request, session):
|
||||
"""List presets for the current profile."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({}), 200, {'Content-Type': 'application/json'}
|
||||
return J({}, 200)
|
||||
scoped = {
|
||||
pid: pdata for pid, pdata in presets.items()
|
||||
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
|
||||
}
|
||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||
return J(scoped, 200)
|
||||
|
||||
@controller.get('/<preset_id>/export')
|
||||
@router.get("/{preset_id}/export")
|
||||
@with_session
|
||||
async def export_preset(request, session, preset_id):
|
||||
async def export_preset(request: Request, session, preset_id):
|
||||
"""Export one preset as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
preset = presets.read(preset_id)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
try:
|
||||
bundle = export_preset_bundle(preset_id, presets)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
return J(bundle, 200)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 404)
|
||||
|
||||
|
||||
@controller.post('/import')
|
||||
@router.post("/import")
|
||||
@with_session
|
||||
async def import_preset(request, session):
|
||||
async def import_preset(request: Request, session):
|
||||
"""Import a preset bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||
body = request.json or {}
|
||||
return J({"error": "No profile available"}, 404)
|
||||
body = await read_json(request)
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Expected JSON bundle"}, 400)
|
||||
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||
return J({new_id: preset_data}, 201)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.get('/<preset_id>')
|
||||
@router.get("/{preset_id}")
|
||||
@with_session
|
||||
async def get_preset(request, session, preset_id):
|
||||
async def get_preset(request: Request, session, preset_id):
|
||||
"""Get a specific preset by ID (current profile only)."""
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
return J(preset, 200)
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
@router.post("/")
|
||||
@with_session
|
||||
async def create_preset(request, session):
|
||||
async def create_preset(request: Request, session):
|
||||
"""Create a new preset for the current profile."""
|
||||
try:
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
return J({"error": "No profile available"}, 404)
|
||||
preset_id = presets.create(current_profile_id)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
@@ -118,65 +119,46 @@ async def create_preset(request, session):
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if presets.update(preset_id, data):
|
||||
preset_data = presets.read(preset_id)
|
||||
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Failed to create preset"}), 400
|
||||
return J({preset_id: preset_data}, 201)
|
||||
return J({"error": "Failed to create preset"}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<preset_id>')
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{preset_id}")
|
||||
@with_session
|
||||
async def update_preset(request, session, preset_id):
|
||||
async def update_preset(request: Request, session, preset_id):
|
||||
"""Update an existing preset (current profile only)."""
|
||||
try:
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
data = dict(data)
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if presets.update(preset_id, data):
|
||||
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
return J(presets.read(preset_id), 200)
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<preset_id>')
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{preset_id}")
|
||||
@with_session
|
||||
async def delete_preset(request, *args, **kwargs):
|
||||
async def delete_preset(request: Request, session, preset_id):
|
||||
"""Delete a preset (current profile only)."""
|
||||
# Be tolerant of wrapper/arg-order variations.
|
||||
session = None
|
||||
preset_id = None
|
||||
if len(args) > 0:
|
||||
session = args[0]
|
||||
if len(args) > 1:
|
||||
preset_id = args[1]
|
||||
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||
session = kwargs.get('session')
|
||||
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||
preset_id = kwargs.get('preset_id')
|
||||
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||
preset_id = kwargs.get('id')
|
||||
if preset_id is None:
|
||||
return json.dumps({"error": "Preset ID is required"}), 400
|
||||
preset = presets.read(preset_id)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
if presets.delete(preset_id):
|
||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||
return json.dumps({"error": "Preset not found"}), 404
|
||||
|
||||
|
||||
@controller.post('/send')
|
||||
return J({"message": "Preset deleted successfully"}, 200)
|
||||
return J({"error": "Preset not found"}, 404)
|
||||
@router.post("/send")
|
||||
@with_session
|
||||
async def send_presets(request, session):
|
||||
async def send_presets(request: Request, session):
|
||||
"""
|
||||
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
|
||||
|
||||
@@ -191,13 +173,12 @@ async def send_presets(request, session):
|
||||
Optional "destination_mac" / "to": single MAC when targets is omitted.
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
preset_ids = data.get('preset_ids') or data.get('ids')
|
||||
if not isinstance(preset_ids, list) or not preset_ids:
|
||||
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "preset_ids must be a non-empty list"}, 400)
|
||||
save_flag = data.get('save', True)
|
||||
save_flag = bool(save_flag)
|
||||
default_id = data.get('default')
|
||||
@@ -219,14 +200,14 @@ async def send_presets(request, session):
|
||||
presets_by_name[preset_key] = preset_payload
|
||||
|
||||
if not presets_by_name:
|
||||
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
|
||||
return J({"error": "No matching presets found"}, 404)
|
||||
|
||||
if default_id is not None and str(default_id) not in presets_by_name:
|
||||
default_id = None
|
||||
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
|
||||
send_delay_s = 0.1
|
||||
total_presets = len(presets_by_name)
|
||||
@@ -300,18 +281,18 @@ async def send_presets(request, session):
|
||||
delay_s=send_delay_s,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Send failed"}, 503)
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"message": "Presets sent",
|
||||
"presets_sent": total_presets,
|
||||
"messages_sent": deliveries,
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
}, 200)
|
||||
|
||||
|
||||
@controller.post('/push')
|
||||
@router.post("/push")
|
||||
@with_session
|
||||
async def push_driver_messages(request, session):
|
||||
async def push_driver_messages(request: Request, session):
|
||||
"""
|
||||
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
|
||||
|
||||
@@ -320,15 +301,15 @@ async def push_driver_messages(request, session):
|
||||
or a single {"payload": {...}, "targets": [...]}.
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
|
||||
seq = data.get("sequence")
|
||||
if not seq and data.get("payload") is not None:
|
||||
seq = [data["payload"]]
|
||||
if not isinstance(seq, list) or not seq:
|
||||
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "sequence or payload required"}, 400)
|
||||
|
||||
raw_targets = data.get("targets")
|
||||
target_list = None
|
||||
@@ -344,7 +325,7 @@ async def push_driver_messages(request, session):
|
||||
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
|
||||
messages = []
|
||||
i = 0
|
||||
@@ -355,7 +336,7 @@ async def push_driver_messages(request, session):
|
||||
messages.append(item)
|
||||
i += 1
|
||||
continue
|
||||
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "sequence items must be objects or strings"}, 400)
|
||||
nxt = seq[i + 1] if i + 1 < len(seq) else None
|
||||
if (
|
||||
isinstance(nxt, dict)
|
||||
@@ -392,7 +373,7 @@ async def push_driver_messages(request, session):
|
||||
unicast=unicast,
|
||||
)
|
||||
except Exception:
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Send failed"}, 503)
|
||||
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
@@ -405,8 +386,8 @@ async def push_driver_messages(request, session):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"message": "Delivered",
|
||||
"deliveries": deliveries,
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
}, 200)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.profile import Profile
|
||||
from models.zone import Zone
|
||||
from models.preset import Preset
|
||||
@@ -7,15 +9,15 @@ from models.sequence import Sequence
|
||||
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
profiles = Profile()
|
||||
zones = Zone()
|
||||
presets = Preset()
|
||||
sequences = Sequence()
|
||||
|
||||
@controller.get('')
|
||||
@router.get("/")
|
||||
@with_session
|
||||
async def list_profiles(request, session):
|
||||
async def list_profiles(request: Request, session):
|
||||
"""List all profiles with current profile info."""
|
||||
profile_list = profiles.list()
|
||||
current_id = session.get('current_profile')
|
||||
@@ -35,14 +37,14 @@ async def list_profiles(request, session):
|
||||
if profile_data:
|
||||
profiles_data[profile_id] = profile_data
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"profiles": profiles_data,
|
||||
"current_profile_id": current_id
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
}, 200)
|
||||
|
||||
@controller.get('/current')
|
||||
@router.get("/current")
|
||||
@with_session
|
||||
async def get_current_profile(request, session):
|
||||
async def get_current_profile(request: Request, session):
|
||||
"""Get the current profile ID from session (or fallback)."""
|
||||
profile_list = profiles.list()
|
||||
current_id = session.get('current_profile')
|
||||
@@ -54,19 +56,17 @@ async def get_current_profile(request, session):
|
||||
session.save()
|
||||
if current_id:
|
||||
profile = profiles.read(current_id)
|
||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
|
||||
|
||||
@controller.post('/import')
|
||||
return J({"id": current_id, "profile": profile}, 200)
|
||||
return J({"error": "No profile available"}, 404)
|
||||
@router.post("/import")
|
||||
@with_session
|
||||
async def import_profile(request, session):
|
||||
async def import_profile(request: Request, session):
|
||||
"""Import a profile bundle (optionally apply as current profile)."""
|
||||
try:
|
||||
body = request.json or {}
|
||||
body = await read_json(request)
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": "Expected JSON bundle"}, 400)
|
||||
name = body.get("name") if isinstance(body, dict) else None
|
||||
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
|
||||
if isinstance(apply_raw, str):
|
||||
@@ -86,19 +86,15 @@ async def import_profile(request, session):
|
||||
if apply:
|
||||
session['current_profile'] = str(new_profile_id)
|
||||
session.save()
|
||||
return (
|
||||
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
|
||||
201,
|
||||
{'Content-Type': 'application/json'},
|
||||
)
|
||||
return J({new_profile_id: profile_data, "id": new_profile_id}, 201)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.get('/<id>/export')
|
||||
async def export_profile(request, id):
|
||||
@router.get("/{id}/export")
|
||||
async def export_profile(request: Request, id):
|
||||
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
|
||||
try:
|
||||
bundle = export_profile_bundle(
|
||||
@@ -109,33 +105,32 @@ async def export_profile(request, id):
|
||||
sequences,
|
||||
profiles._palette_model,
|
||||
)
|
||||
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||
return J(bundle, 200)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.post('/<id>/apply')
|
||||
@router.post("/{id}/apply")
|
||||
@with_session
|
||||
async def apply_profile(request, session, id):
|
||||
async def apply_profile(request: Request, session, id):
|
||||
"""Apply a profile by saving it to session."""
|
||||
if not profiles.read(id):
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
session['current_profile'] = str(id)
|
||||
session.save()
|
||||
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||
return J({"message": "Profile applied", "id": str(id)}, 200)
|
||||
|
||||
|
||||
@controller.post('/<id>/clone')
|
||||
async def clone_profile(request, id):
|
||||
@router.post("/{id}/clone")
|
||||
async def clone_profile(request: Request, id):
|
||||
"""Clone an existing profile along with its tabs and palette."""
|
||||
try:
|
||||
source = profiles.read(id)
|
||||
if not source:
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
data = await read_json(request)
|
||||
source_name = source.get("name") or f"Profile {id}"
|
||||
new_name = data.get("name") or source_name
|
||||
profile_type = source.get("type", "zones")
|
||||
@@ -235,14 +230,12 @@ async def clone_profile(request, id):
|
||||
zones.save()
|
||||
profiles.save()
|
||||
|
||||
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
return J({new_profile_id: new_profile_data}, 201)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.get('/<id>')
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.get("/{id}")
|
||||
@with_session
|
||||
async def get_profile(request, id, session):
|
||||
async def get_profile(request: Request, id, session):
|
||||
"""Get a specific profile by ID."""
|
||||
# Handle 'current' as a special case
|
||||
if id == 'current':
|
||||
@@ -250,14 +243,13 @@ async def get_profile(request, id, session):
|
||||
|
||||
profile = profiles.read(id)
|
||||
if profile:
|
||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_profile(request):
|
||||
return J(profile, 200)
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
@router.post("/")
|
||||
async def create_profile(request: Request):
|
||||
"""Create a new profile."""
|
||||
try:
|
||||
data = dict(request.json or {})
|
||||
data = dict(await read_json(request))
|
||||
name = data.get("name", "")
|
||||
seed_raw = data.get("seed_dj_zone", False)
|
||||
if isinstance(seed_raw, str):
|
||||
@@ -413,16 +405,15 @@ async def create_profile(request):
|
||||
profiles.update(profile_id, {"zones": profile_tabs})
|
||||
|
||||
profile_data = profiles.read(profile_id)
|
||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||
return J({profile_id: profile_data}, 201)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/current')
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/current")
|
||||
@with_session
|
||||
async def update_current_profile(request, session):
|
||||
async def update_current_profile(request: Request, session):
|
||||
"""Update the current profile using session (or fallback)."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
profile_list = profiles.list()
|
||||
current_id = session.get('current_profile')
|
||||
if not current_id and profile_list:
|
||||
@@ -430,27 +421,25 @@ async def update_current_profile(request, session):
|
||||
session['current_profile'] = str(current_id)
|
||||
session.save()
|
||||
if not current_id:
|
||||
return json.dumps({"error": "No profile available"}), 404
|
||||
return J({"error": "No profile available"}, 404)
|
||||
if profiles.update(current_id, data):
|
||||
return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
return J(profiles.read(current_id), 200)
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_profile(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{id}")
|
||||
async def update_profile(request: Request, id):
|
||||
"""Update an existing profile."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
if profiles.update(id, data):
|
||||
return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
return J(profiles.read(id), 200)
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_profile(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
async def delete_profile(request: Request, id):
|
||||
"""Delete a profile."""
|
||||
if profiles.delete(id):
|
||||
return json.dumps({"message": "Profile deleted successfully"}), 200
|
||||
return json.dumps({"error": "Profile not found"}), 404
|
||||
return J({"message": "Profile deleted successfully"}, 200)
|
||||
return J({"error": "Profile not found"}, 404)
|
||||
@@ -1,49 +1,49 @@
|
||||
from microdot import Microdot
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.scene import Scene
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
scenes = Scene()
|
||||
|
||||
@controller.get('')
|
||||
async def list_scenes(request):
|
||||
@router.get("/")
|
||||
async def list_scenes(request: Request):
|
||||
"""List all scenes."""
|
||||
return json.dumps(scenes), 200, {'Content-Type': 'application/json'}
|
||||
return J(scenes, 200)
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_scene(request, id):
|
||||
@router.get("/{id}")
|
||||
async def get_scene(request: Request, id):
|
||||
"""Get a specific scene by ID."""
|
||||
scene = scenes.read(id)
|
||||
if scene:
|
||||
return json.dumps(scene), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Scene not found"}), 404
|
||||
|
||||
@controller.post('')
|
||||
async def create_scene(request):
|
||||
return J(scene, 200)
|
||||
return J({"error": "Scene not found"}, 404)
|
||||
@router.post("/")
|
||||
async def create_scene(request: Request):
|
||||
"""Create a new scene."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
scene_id = scenes.create()
|
||||
if scenes.update(scene_id, data):
|
||||
return json.dumps(scenes.read(scene_id)), 201, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Failed to create scene"}), 400
|
||||
return J(scenes.read(scene_id), 201)
|
||||
return J({"error": "Failed to create scene"}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_scene(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.put("/{id}")
|
||||
async def update_scene(request: Request, id):
|
||||
"""Update an existing scene."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
if scenes.update(id, data):
|
||||
return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Scene not found"}), 404
|
||||
return J(scenes.read(id), 200)
|
||||
return J({"error": "Scene not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_scene(request, id):
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
async def delete_scene(request: Request, id):
|
||||
"""Delete a scene."""
|
||||
if scenes.delete(id):
|
||||
return json.dumps({"message": "Scene deleted successfully"}), 200
|
||||
return json.dumps({"error": "Scene not found"}), 404
|
||||
return J({"message": "Scene deleted successfully"}, 200)
|
||||
return J({"error": "Scene not found"}, 404)
|
||||
@@ -1,5 +1,7 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.sequence import Sequence
|
||||
from models.profile import Profile
|
||||
from models.transport import get_current_bridge
|
||||
@@ -7,7 +9,7 @@ from models.preset import Preset
|
||||
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
sequences = Sequence()
|
||||
profiles = Profile()
|
||||
presets = Preset()
|
||||
@@ -26,30 +28,30 @@ def get_current_profile_id(session=None):
|
||||
return None
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@router.get("/")
|
||||
@with_session
|
||||
async def list_sequences(request, session):
|
||||
async def list_sequences(request: Request, session):
|
||||
"""List sequences for the current profile."""
|
||||
sequences.load()
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||
return J({}, 200)
|
||||
scoped = {
|
||||
sid: sdata
|
||||
for sid, sdata in sequences.items()
|
||||
if isinstance(sdata, dict)
|
||||
and str(sdata.get("profile_id")) == str(current_profile_id)
|
||||
}
|
||||
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||
return J(scoped, 200)
|
||||
|
||||
|
||||
@controller.get("/<id>/export")
|
||||
@router.get("/{id}/export")
|
||||
@with_session
|
||||
async def export_sequence(request, session, id):
|
||||
async def export_sequence(request: Request, session, id):
|
||||
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||
return J({"error": "No profile available"}, 404)
|
||||
try:
|
||||
bundle = export_sequence_bundle(
|
||||
id,
|
||||
@@ -57,46 +59,34 @@ async def export_sequence(request, session, id):
|
||||
presets,
|
||||
profile_id=current_profile_id,
|
||||
)
|
||||
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||
return J(bundle, 200)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 404)
|
||||
|
||||
|
||||
@controller.post("/import")
|
||||
@router.post("/import")
|
||||
@with_session
|
||||
async def import_sequence(request, session):
|
||||
async def import_sequence(request: Request, session):
|
||||
"""Import a sequence bundle into the current profile."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return (
|
||||
json.dumps({"error": "No profile available"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
body = request.json or {}
|
||||
return J({"error": "No profile available"}, 404)
|
||||
body = await read_json(request)
|
||||
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||
if not isinstance(bundle, dict):
|
||||
return (
|
||||
json.dumps({"error": "Expected JSON bundle"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "Expected JSON bundle"}, 400)
|
||||
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||
return (
|
||||
json.dumps({new_id: seq_data}),
|
||||
201,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({new_id: seq_data}, 201)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@router.get("/{id}")
|
||||
@with_session
|
||||
async def get_sequence(request, session, id):
|
||||
async def get_sequence(request: Request, session, id):
|
||||
"""Get a specific sequence by ID (current profile only)."""
|
||||
sequences.load()
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
@@ -106,30 +96,20 @@ async def get_sequence(request, session, id):
|
||||
and current_profile_id
|
||||
and str(seq.get("profile_id")) == str(current_profile_id)
|
||||
):
|
||||
return json.dumps(seq), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
|
||||
|
||||
@controller.post("")
|
||||
return J(seq, 200)
|
||||
return J({"error": "Sequence not found"}, 404)
|
||||
@router.post("/")
|
||||
@with_session
|
||||
async def create_sequence(request, session):
|
||||
async def create_sequence(request: Request, session):
|
||||
"""Create a new sequence for the current profile."""
|
||||
try:
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
return (
|
||||
json.dumps({"error": "Invalid JSON"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return (
|
||||
json.dumps({"error": "No profile available"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "No profile available"}, 404)
|
||||
sequence_id = sequences.create(current_profile_id)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
@@ -137,36 +117,24 @@ async def create_sequence(request, session):
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if sequences.update(sequence_id, data):
|
||||
seq_data = sequences.read(sequence_id)
|
||||
return (
|
||||
json.dumps({sequence_id: seq_data}),
|
||||
201,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"error": "Failed to create sequence"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({sequence_id: seq_data}, 201)
|
||||
return J({"error": "Failed to create sequence"}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
@router.put("/{id}")
|
||||
@with_session
|
||||
async def update_sequence(request, session, id):
|
||||
async def update_sequence(request: Request, session, id):
|
||||
"""Update an existing sequence (current profile only)."""
|
||||
try:
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
seq = sequences.read(id)
|
||||
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
data = request.json
|
||||
return J({"error": "Sequence not found"}, 404)
|
||||
data = await read_json(request)
|
||||
if not isinstance(data, dict):
|
||||
return (
|
||||
json.dumps({"error": "Invalid JSON"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "Invalid JSON"}, 400)
|
||||
data = dict(data)
|
||||
data["profile_id"] = str(current_profile_id)
|
||||
if sequences.update(id, data):
|
||||
@@ -176,20 +144,20 @@ async def update_sequence(request, session, id):
|
||||
stop_if_playing_sequence(str(id))
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
return J(sequences.read(id), 200)
|
||||
return J({"error": "Sequence not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
@router.delete("/{id}")
|
||||
@with_session
|
||||
async def delete_sequence(request, session, id):
|
||||
async def delete_sequence(request: Request, session, id):
|
||||
"""Delete a sequence (current profile only)."""
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
seq = sequences.read(id)
|
||||
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
return J({"error": "Sequence not found"}, 404)
|
||||
try:
|
||||
from util.sequence_playback import stop_if_playing_sequence
|
||||
|
||||
@@ -197,21 +165,15 @@ async def delete_sequence(request, session, id):
|
||||
except Exception:
|
||||
pass
|
||||
if sequences.delete(id):
|
||||
return (
|
||||
json.dumps({"message": "Sequence deleted successfully"}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
|
||||
|
||||
@controller.post("/sync-phase")
|
||||
return J({"message": "Sequence deleted successfully"}, 200)
|
||||
return J({"error": "Sequence not found"}, 404)
|
||||
@router.post("/sync-phase")
|
||||
@with_session
|
||||
async def sync_sequence_beat_phase(request, session):
|
||||
async def sync_sequence_beat_phase(request: Request, session):
|
||||
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
|
||||
_ = session
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
@@ -221,65 +183,47 @@ async def sync_sequence_beat_phase(request, session):
|
||||
from util.sequence_playback import sync_beat_phase
|
||||
|
||||
if not await sync_beat_phase(str(mode)):
|
||||
return (
|
||||
json.dumps({"error": "No sequence is playing"}),
|
||||
409,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "No sequence is playing"}, 409)
|
||||
from util.audio_detector import anchor_shared_bar_phase
|
||||
|
||||
anchor_shared_bar_phase()
|
||||
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
return J({"ok": True, "mode": str(mode).strip().lower()}, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.post("/stop")
|
||||
@router.post("/stop")
|
||||
@with_session
|
||||
async def stop_sequence_playback(request, session):
|
||||
async def stop_sequence_playback(request: Request, session):
|
||||
"""Stop server-driven zone sequence playback."""
|
||||
_ = request
|
||||
try:
|
||||
from util.sequence_playback import stop_playback
|
||||
|
||||
await stop_playback(clear_devices=True)
|
||||
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||
return J({"ok": True}, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.post("/<id>/play")
|
||||
@router.post("/{id}/play")
|
||||
@with_session
|
||||
async def play_sequence(request, session, id):
|
||||
async def play_sequence(request: Request, session, id):
|
||||
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
|
||||
if not get_current_bridge():
|
||||
return (
|
||||
json.dumps({"error": "Transport not configured"}),
|
||||
503,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "Transport not configured"}, 503)
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return (
|
||||
json.dumps({"error": "No profile available"}),
|
||||
404,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "No profile available"}, 404)
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
except Exception:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
zone_id = data.get("zone_id") or data.get("zoneId")
|
||||
if zone_id is None or str(zone_id).strip() == "":
|
||||
return (
|
||||
json.dumps({"error": "zone_id required"}),
|
||||
400,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"error": "zone_id required"}, 400)
|
||||
zone_id = str(zone_id).strip()
|
||||
try:
|
||||
from util.sequence_playback import start
|
||||
@@ -289,10 +233,10 @@ async def play_sequence(request, session, id):
|
||||
from util.sequence_playback import pending_play_status
|
||||
|
||||
body = {"ok": True, **pending_play_status()}
|
||||
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
||||
return J(body, 200)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 400)
|
||||
except RuntimeError as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 503)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
|
||||
from settings import get_settings
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
@controller.get('')
|
||||
async def get_settings(request):
|
||||
@router.get("/")
|
||||
async def get_settings(request: Request):
|
||||
"""Get all settings."""
|
||||
# Settings is already a dict subclass; avoid dict() wrapper which can
|
||||
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||
return J(settings, 200)
|
||||
|
||||
@controller.get('/wifi/ap')
|
||||
async def get_ap_config(request):
|
||||
@router.get("/wifi/ap")
|
||||
async def get_ap_config(request: Request):
|
||||
"""Get saved AP configuration (Pi: no in-device AP)."""
|
||||
config = {
|
||||
'saved_ssid': settings.get('wifi_ap_ssid'),
|
||||
@@ -24,40 +27,37 @@ async def get_ap_config(request):
|
||||
'saved_channel': settings.get('wifi_ap_channel'),
|
||||
'active': False,
|
||||
}
|
||||
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||
return J(config, 200)
|
||||
|
||||
@controller.post('/wifi/ap')
|
||||
async def configure_ap(request):
|
||||
@router.post("/wifi/ap")
|
||||
async def configure_ap(request: Request):
|
||||
"""Save AP configuration to settings (Pi: no in-device AP)."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
ssid = data.get('ssid')
|
||||
password = data.get('password', '')
|
||||
channel = data.get('channel')
|
||||
|
||||
if not ssid:
|
||||
return json.dumps({"error": "SSID is required"}), 400
|
||||
|
||||
return J({"error": "SSID is required"}, 400)
|
||||
# Validate channel (1-11 for 2.4GHz)
|
||||
if channel is not None:
|
||||
channel = int(channel)
|
||||
if channel < 1 or channel > 11:
|
||||
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
||||
|
||||
return J({"error": "Channel must be between 1 and 11"}, 400)
|
||||
settings['wifi_ap_ssid'] = ssid
|
||||
settings['wifi_ap_password'] = password
|
||||
if channel is not None:
|
||||
settings['wifi_ap_channel'] = channel
|
||||
settings.save()
|
||||
|
||||
return json.dumps({
|
||||
return J({
|
||||
"message": "AP settings saved",
|
||||
"ssid": ssid,
|
||||
"channel": channel
|
||||
}), 200, {'Content-Type': 'application/json'}
|
||||
"channel": channel,
|
||||
}, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500
|
||||
|
||||
return J({"error": str(e)}, 500)
|
||||
def _validate_wifi_channel(value):
|
||||
"""Return int 1–11 or raise ValueError."""
|
||||
ch = int(value)
|
||||
@@ -95,11 +95,17 @@ def _validate_audio_input_volume(value):
|
||||
return v
|
||||
|
||||
|
||||
@controller.put('')
|
||||
async def update_settings(request):
|
||||
def _validate_audio_simulated_bpm(value):
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
return int(clamp_bpm(value))
|
||||
|
||||
|
||||
@router.put("/")
|
||||
async def update_settings(request: Request):
|
||||
"""Update general settings."""
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
global_brightness_changed = False
|
||||
for key, value in data.items():
|
||||
if key == 'wifi_channel' and value is not None:
|
||||
@@ -113,17 +119,18 @@ async def update_settings(request):
|
||||
settings[key] = _validate_audio_beat_phase_ms(value)
|
||||
elif key == 'audio_input_volume' and value is not None:
|
||||
settings[key] = _validate_audio_input_volume(value)
|
||||
elif key == 'audio_simulated_bpm' and value is not None:
|
||||
settings[key] = _validate_audio_simulated_bpm(value)
|
||||
else:
|
||||
settings[key] = value
|
||||
settings.save()
|
||||
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||
return J({"message": "Settings updated successfully"}, 200)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
return J({"error": str(e)}, 400)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500
|
||||
|
||||
@controller.get('/page')
|
||||
async def settings_page(request):
|
||||
return J({"error": str(e)}, 500)
|
||||
@router.get("/page")
|
||||
async def settings_page(request: Request):
|
||||
"""Serve the settings page."""
|
||||
return send_file('templates/settings.html')
|
||||
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from microdot import Microdot
|
||||
|
||||
from settings import get_settings
|
||||
from util.bridge_profiles import find_bridge_profile, normalise_bridges
|
||||
@@ -20,7 +24,7 @@ from util.bridge_runtime import (
|
||||
)
|
||||
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _bridge_transport(settings) -> str:
|
||||
@@ -44,55 +48,39 @@ def _bridges_payload(settings) -> dict:
|
||||
}
|
||||
|
||||
|
||||
@controller.get("/interfaces")
|
||||
async def wifi_interfaces(request):
|
||||
@router.get("/interfaces")
|
||||
async def wifi_interfaces(request: Request):
|
||||
_ = request
|
||||
if not nmcli_available():
|
||||
return (
|
||||
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
|
||||
503,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503)
|
||||
return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200)
|
||||
|
||||
|
||||
@controller.get("/scan")
|
||||
async def wifi_scan(request):
|
||||
device = (request.args.get("device") or "").strip()
|
||||
@router.get("/scan")
|
||||
async def wifi_scan(request: Request):
|
||||
device = (request.query_params.get("device") or "").strip()
|
||||
if not device:
|
||||
return json.dumps({"error": "device query param required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "device query param required"}, 400)
|
||||
if not nmcli_available():
|
||||
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": "nmcli not found"}, 503)
|
||||
try:
|
||||
networks = await scan_wifi(device)
|
||||
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": True, "device": device, "networks": networks}, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.get("/bridges")
|
||||
async def get_bridges(request):
|
||||
@router.get("/bridges")
|
||||
async def get_bridges(request: Request):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
|
||||
return J(_bridges_payload(settings), 200)
|
||||
|
||||
|
||||
@controller.put("/bridges")
|
||||
async def put_bridges(request):
|
||||
@router.put("/bridges")
|
||||
async def put_bridges(request: Request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
settings = get_settings()
|
||||
if "wifi_interface" in data:
|
||||
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
|
||||
@@ -109,62 +97,50 @@ async def put_bridges(request):
|
||||
if "bridges" in data:
|
||||
settings["bridges"] = normalise_bridges(data.get("bridges"))
|
||||
settings.save()
|
||||
return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": True, "message": "Bridge profiles saved"}, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": str(e)}, 400)
|
||||
|
||||
|
||||
@controller.delete("/bridges/<bridge_id>")
|
||||
async def delete_bridge_profile(request, bridge_id):
|
||||
@router.delete("/bridges/{bridge_id}")
|
||||
async def delete_bridge_profile(request: Request, bridge_id):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
bid = str(bridge_id or "").strip()
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
kept = [b for b in bridges if str(b.get("id") or "") != bid]
|
||||
if len(kept) == len(bridges):
|
||||
return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": "Bridge profile not found"}, 404)
|
||||
settings["bridges"] = kept
|
||||
settings.save()
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = "Bridge profile deleted"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
return J(payload, 200)
|
||||
|
||||
|
||||
@controller.post("/bridges/<bridge_id>/connect")
|
||||
async def connect_saved_bridge(request, bridge_id):
|
||||
@router.post("/bridges/{bridge_id}/connect")
|
||||
async def connect_saved_bridge(request: Request, bridge_id):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
profile = find_bridge_profile(settings, bridge_id)
|
||||
if not profile:
|
||||
return json.dumps({"error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Bridge profile not found"}, 404)
|
||||
try:
|
||||
ok, err = await connect_bridge_profile(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": err or "Connect failed"}, 400)
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = f"Connected to {profile.get('label')}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
return J(payload, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.post("/connect")
|
||||
async def wifi_connect_bridge(request):
|
||||
@router.post("/connect")
|
||||
async def wifi_connect_bridge(request: Request):
|
||||
"""Join a bridge AP and open its WebSocket."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
settings = get_settings()
|
||||
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
|
||||
ssid = str(data.get("ssid") or "").strip()
|
||||
@@ -177,13 +153,9 @@ async def wifi_connect_bridge(request):
|
||||
label = str(data.get("label") or ssid).strip() or ssid
|
||||
save_profile = bool(data.get("save_profile", True))
|
||||
if not device:
|
||||
return json.dumps({"error": "Wi‑Fi interface (device) is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Wi‑Fi interface (device) is required"}, 400)
|
||||
if not ssid:
|
||||
return json.dumps({"error": "ssid is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "ssid is required"}, 400)
|
||||
settings["wifi_interface"] = device
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
profile_id = None
|
||||
@@ -217,23 +189,19 @@ async def wifi_connect_bridge(request):
|
||||
}
|
||||
ok, err = await connect_bridge_wifi(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": err or "Connect failed"}, 400)
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected to {ssid}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
return J(payload, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.post("/serial/connect")
|
||||
async def serial_connect_bridge(request):
|
||||
@router.post("/serial/connect")
|
||||
async def serial_connect_bridge(request: Request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
port = str(data.get("port") or data.get("serial_port") or "").strip()
|
||||
save_profile = bool(data.get("save_profile", True))
|
||||
label = str(data.get("label") or port).strip() or port
|
||||
@@ -242,9 +210,7 @@ async def serial_connect_bridge(request):
|
||||
except (TypeError, ValueError):
|
||||
baud = 921600
|
||||
if not port:
|
||||
return json.dumps({"error": "port is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "port is required"}, 400)
|
||||
settings = get_settings()
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
profile_id = None
|
||||
@@ -269,14 +235,10 @@ async def serial_connect_bridge(request):
|
||||
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
|
||||
ok, err = await connect_bridge_serial(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": err}, 500)
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected on {port}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
return J(payload, 200)
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"ok": False, "error": str(e)}, 500)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.session import with_session
|
||||
from fastapi import APIRouter, Request
|
||||
from http_responses import J, J_cookie, html_response, plain, read_json, send_file
|
||||
from http_session import with_session
|
||||
|
||||
from models.zone import Zone
|
||||
from models.profile import Profile
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
router = APIRouter()
|
||||
zones = Zone()
|
||||
profiles = Profile()
|
||||
|
||||
@@ -69,11 +71,7 @@ def _render_zones_list_fragment(request, session):
|
||||
"""Render zone strip HTML for HTMX / JS."""
|
||||
profile_id = get_current_profile_id(session)
|
||||
if not profile_id:
|
||||
return (
|
||||
'<div class="zones-list">No profile selected</div>',
|
||||
200,
|
||||
{"Content-Type": "text/html"},
|
||||
)
|
||||
return html_response('<div class="zones-list">No profile selected</div>', 200)
|
||||
|
||||
zone_order = get_profile_zone_order(profile_id)
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
@@ -96,9 +94,7 @@ def _render_zones_list_fragment(request, session):
|
||||
+ "</button>"
|
||||
)
|
||||
html += "</div>"
|
||||
return html, 200, {"Content-Type": "text/html"}
|
||||
|
||||
|
||||
return html_response(html, 200)
|
||||
def _render_zone_content_fragment(request, session, id):
|
||||
if id == "current":
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
@@ -106,18 +102,13 @@ def _render_zone_content_fragment(request, session, id):
|
||||
accept_header = request.headers.get("Accept", "")
|
||||
wants_html = "text/html" in accept_header
|
||||
if wants_html:
|
||||
return (
|
||||
'<div class="error">No current zone set</div>',
|
||||
404,
|
||||
{"Content-Type": "text/html"},
|
||||
)
|
||||
return json.dumps({"error": "No current zone set"}), 404
|
||||
return html_response('<div class="error">No current zone set</div>', 404)
|
||||
return J({"error": "No current zone set"}, 404)
|
||||
id = current_zone_id
|
||||
|
||||
z = zones.read(id)
|
||||
if not z:
|
||||
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
|
||||
|
||||
return html_response('<div>Zone not found</div>', 404)
|
||||
session["current_zone"] = str(id)
|
||||
session.save()
|
||||
|
||||
@@ -133,18 +124,16 @@ def _render_zone_content_fragment(request, session, id):
|
||||
"</div>"
|
||||
"</div>"
|
||||
)
|
||||
return html, 200, {"Content-Type": "text/html"}
|
||||
|
||||
|
||||
@controller.get("/<id>/content-fragment")
|
||||
return html_response(html, 200)
|
||||
@router.get("/{id}/content-fragment")
|
||||
@with_session
|
||||
async def zone_content_fragment(request, session, id):
|
||||
async def zone_content_fragment(request: Request, session, id):
|
||||
return _render_zone_content_fragment(request, session, id)
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@router.get("/")
|
||||
@with_session
|
||||
async def list_zones(request, session):
|
||||
async def list_zones(request: Request, session):
|
||||
zones.load()
|
||||
profile_id = get_current_profile_id(session)
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
@@ -156,93 +145,66 @@ async def list_zones(request, session):
|
||||
if zdata:
|
||||
zones_data[zid] = zdata
|
||||
|
||||
return (
|
||||
json.dumps(
|
||||
{
|
||||
return J({
|
||||
"zones": zones_data,
|
||||
"zone_order": zone_order,
|
||||
"current_zone_id": current_zone_id,
|
||||
"profile_id": profile_id,
|
||||
}
|
||||
),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
}, 200)
|
||||
|
||||
|
||||
@controller.get("/current")
|
||||
@router.get("/current")
|
||||
@with_session
|
||||
async def get_current_zone(request, session):
|
||||
async def get_current_zone(request: Request, session):
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if not current_zone_id:
|
||||
return (
|
||||
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
|
||||
404,
|
||||
)
|
||||
return J({"error": "No current zone set", "zone": None, "zone_id": None}, 404)
|
||||
|
||||
z = zones.read(current_zone_id)
|
||||
if z:
|
||||
return (
|
||||
json.dumps({"zone": z, "zone_id": current_zone_id}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
|
||||
404,
|
||||
)
|
||||
return J({"zone": z, "zone_id": current_zone_id}, 200)
|
||||
return J({"error": "Zone not found", "zone": None, "zone_id": None}, 404)
|
||||
|
||||
|
||||
@controller.post("/<id>/set-current")
|
||||
async def set_current_zone(request, id):
|
||||
@router.post("/{id}/set-current")
|
||||
async def set_current_zone(request: Request, id):
|
||||
z = zones.read(id)
|
||||
if not z:
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
|
||||
return (
|
||||
response_data,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": (
|
||||
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
|
||||
),
|
||||
},
|
||||
return J({"error": "Zone not found"}, 404)
|
||||
return J_cookie(
|
||||
{"message": "Current zone set", "zone_id": id},
|
||||
name="current_zone",
|
||||
value=str(id),
|
||||
max_age=31536000,
|
||||
)
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_zone(request, id):
|
||||
@router.get("/{id}")
|
||||
async def get_zone(request: Request, id):
|
||||
zones.load()
|
||||
z = zones.read(id)
|
||||
if z:
|
||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
|
||||
@controller.put("/<id>")
|
||||
async def update_zone(request, id):
|
||||
return J(z, 200)
|
||||
return J({"error": "Zone not found"}, 404)
|
||||
@router.put("/{id}")
|
||||
async def update_zone(request: Request, id):
|
||||
try:
|
||||
data = request.json
|
||||
data = await read_json(request)
|
||||
if zones.update(id, data):
|
||||
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
return J(zones.read(id), 200)
|
||||
return J({"error": "Zone not found"}, 404)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.delete("/<id>")
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.delete("/{id}")
|
||||
@with_session
|
||||
async def delete_zone(request, session, id):
|
||||
async def delete_zone(request: Request, session, id):
|
||||
try:
|
||||
if id == "current":
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if current_zone_id:
|
||||
id = current_zone_id
|
||||
else:
|
||||
return json.dumps({"error": "No current zone to delete"}), 404
|
||||
|
||||
return J({"error": "No current zone to delete"}, 404)
|
||||
if zones.delete(id):
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
@@ -256,23 +218,15 @@ async def delete_zone(request, session, id):
|
||||
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
if current_zone_id == id:
|
||||
response_data = json.dumps({"message": "Zone deleted successfully"})
|
||||
return (
|
||||
response_data,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": (
|
||||
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
|
||||
),
|
||||
},
|
||||
return J_cookie(
|
||||
{"message": "Zone deleted successfully"},
|
||||
name="current_zone",
|
||||
value="",
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
return json.dumps({"message": "Zone deleted successfully"}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
return J({"message": "Zone deleted successfully"}, 200)
|
||||
return J({"error": "Zone not found"}, 404)
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
@@ -280,22 +234,24 @@ async def delete_zone(request, session, id):
|
||||
sys.print_exception(e)
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
return J({"error": str(e)}, 500)
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@router.post("/")
|
||||
@with_session
|
||||
async def create_zone(request, session):
|
||||
async def create_zone(request: Request, session):
|
||||
try:
|
||||
if request.form:
|
||||
name = request.form.get("name", "").strip()
|
||||
ids_str = request.form.get("ids", "1").strip()
|
||||
ct = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
if ct in ("application/x-www-form-urlencoded", "multipart/form-data"):
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()
|
||||
ids_str = (form.get("ids") or "1").strip()
|
||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||
preset_ids = None
|
||||
group_ids = []
|
||||
content_kind = None
|
||||
else:
|
||||
data = request.json or {}
|
||||
data = await read_json(request)
|
||||
name = data.get("name", "")
|
||||
names = data.get("names")
|
||||
if names is None:
|
||||
@@ -312,8 +268,7 @@ async def create_zone(request, session):
|
||||
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
|
||||
|
||||
if not name:
|
||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||
|
||||
return J({"error": "Zone name cannot be empty"}, 400)
|
||||
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
@@ -327,23 +282,20 @@ async def create_zone(request, session):
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
zdata = zones.read(zid)
|
||||
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
|
||||
return J({zid: zdata}, 201)
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
sys.print_exception(e)
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
|
||||
@controller.post("/<id>/clone")
|
||||
return J({"error": str(e)}, 400)
|
||||
@router.post("/{id}/clone")
|
||||
@with_session
|
||||
async def clone_zone(request, session, id):
|
||||
async def clone_zone(request: Request, session, id):
|
||||
try:
|
||||
source = zones.read(id)
|
||||
if not source:
|
||||
return json.dumps({"error": "Zone not found"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
return J({"error": "Zone not found"}, 404)
|
||||
data = await read_json(request)
|
||||
source_name = source.get("name") or f"Zone {id}"
|
||||
new_name = data.get("name") or f"{source_name} Copy"
|
||||
clone_id = zones.create(
|
||||
@@ -368,7 +320,7 @@ async def clone_zone(request, session, id):
|
||||
profiles.update(profile_id, profile)
|
||||
|
||||
zdata = zones.read(clone_id)
|
||||
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
|
||||
return J({clone_id: zdata}, 201)
|
||||
except Exception as e:
|
||||
import sys
|
||||
|
||||
@@ -376,5 +328,4 @@ async def clone_zone(request, session, id):
|
||||
sys.print_exception(e)
|
||||
except Exception:
|
||||
pass
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
return J({"error": str(e)}, 400)
|
||||
281
src/fastapi_app.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""FastAPI application entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||
|
||||
from app_factory import (
|
||||
AppRuntime,
|
||||
audio_status_payload,
|
||||
dev_build_id,
|
||||
dev_client_revision,
|
||||
live_reload_enabled,
|
||||
mount_controller_routers,
|
||||
mount_static_routes,
|
||||
)
|
||||
from http_session import SessionMiddleware
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
_runtime: Optional[AppRuntime] = None
|
||||
_test_mode = False
|
||||
|
||||
|
||||
class _SuppressDevAccessLogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "/__dev/" not in record.getMessage()
|
||||
|
||||
|
||||
logging.getLogger("uvicorn.access").addFilter(_SuppressDevAccessLogFilter())
|
||||
|
||||
|
||||
def _bridge():
|
||||
return get_current_bridge()
|
||||
|
||||
|
||||
def _notify_audio_status_sse() -> None:
|
||||
try:
|
||||
from util.beat_status_broadcaster import request_status_broadcast
|
||||
|
||||
request_status_broadcast()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI):
|
||||
global _runtime
|
||||
_runtime = AppRuntime()
|
||||
await _runtime.startup(test_mode=_test_mode)
|
||||
if live_reload_enabled() and not _test_mode:
|
||||
print(
|
||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when uvicorn reloads"
|
||||
)
|
||||
yield
|
||||
await _runtime.shutdown()
|
||||
|
||||
|
||||
def create_application(*, test_mode: bool = False) -> FastAPI:
|
||||
global _test_mode
|
||||
_test_mode = test_mode
|
||||
|
||||
api = FastAPI(title="LED Controller", lifespan=_lifespan)
|
||||
api.add_middleware(SessionMiddleware)
|
||||
|
||||
mount_controller_routers(api)
|
||||
mount_static_routes(api, inject_live_reload=live_reload_enabled())
|
||||
|
||||
@api.get("/__dev/build-id", response_class=PlainTextResponse)
|
||||
async def dev_build_id_route():
|
||||
bid = dev_build_id()
|
||||
if not bid:
|
||||
return PlainTextResponse("", status_code=404)
|
||||
return PlainTextResponse(
|
||||
bid,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
|
||||
async def dev_client_rev_route():
|
||||
rev = dev_client_revision()
|
||||
if not rev:
|
||||
return PlainTextResponse("", status_code=404)
|
||||
return PlainTextResponse(
|
||||
rev,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
@api.get("/api/audio/devices")
|
||||
async def audio_devices():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||
try:
|
||||
return {
|
||||
"devices": _runtime.audio_detector.list_input_devices(),
|
||||
"diagnostics": _runtime.audio_detector.diagnostics(),
|
||||
}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@api.post("/api/audio/start")
|
||||
async def audio_start(payload: dict | None = None):
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device = body.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
if not device_select and device not in ("", None):
|
||||
device_select = str(device).strip()
|
||||
try:
|
||||
from util.pulse_audio_devices import resolve_capture_device
|
||||
|
||||
device = resolve_capture_device(device)
|
||||
_runtime.audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(
|
||||
enabled=True,
|
||||
device=device,
|
||||
device_override=str(body.get("device_override") or ""),
|
||||
device_select=device_select,
|
||||
)
|
||||
_notify_audio_status_sse()
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
|
||||
|
||||
@api.put("/api/audio/device")
|
||||
async def audio_set_device(payload: dict | None = None):
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
device_override = str(body.get("device_override") or "").strip()
|
||||
raw = device_override if device_override else device_select
|
||||
device = raw if raw else None
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||
|
||||
prev = read_audio_run_state()
|
||||
write_audio_run_state(
|
||||
enabled=bool(prev.get("enabled")),
|
||||
device=device if raw else None,
|
||||
device_override=device_override,
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||
|
||||
@api.post("/api/audio/stop")
|
||||
async def audio_stop():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
_runtime.audio_detector.stop()
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=False)
|
||||
_notify_audio_status_sse()
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.post("/api/audio/reset")
|
||||
async def audio_reset():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
ok = _runtime.audio_detector.reset_tracking()
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Audio detector is not running"},
|
||||
status_code=409,
|
||||
)
|
||||
_notify_audio_status_sse()
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.post("/api/audio/anchor-bar")
|
||||
async def audio_anchor_bar():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"ok": False, "error": "not ready"}, status_code=503)
|
||||
ok = _runtime.audio_detector.anchor_bar_phase()
|
||||
if not ok:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Audio detector is not running"},
|
||||
status_code=409,
|
||||
)
|
||||
_notify_audio_status_sse()
|
||||
return {"ok": True, "status": _runtime.audio_detector.status()}
|
||||
|
||||
@api.get("/api/audio/status")
|
||||
async def audio_status():
|
||||
if _runtime is None:
|
||||
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
|
||||
|
||||
@api.get("/api/audio/events")
|
||||
async def audio_events(request: Request):
|
||||
if _runtime is None:
|
||||
return JSONResponse({"error": "not ready"}, status_code=503)
|
||||
from util.beat_status_broadcaster import (
|
||||
initial_sse_line,
|
||||
register_sse_client,
|
||||
unregister_sse_client,
|
||||
)
|
||||
|
||||
async def stream():
|
||||
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=8)
|
||||
await register_sse_client(queue)
|
||||
try:
|
||||
yield await initial_sse_line()
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
line = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
yield line
|
||||
finally:
|
||||
await unregister_sse_client(queue)
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@api.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
from util.device_status_broadcaster import (
|
||||
broadcast_device_tcp_snapshot_to,
|
||||
register_device_status_ws,
|
||||
unregister_device_status_ws,
|
||||
)
|
||||
|
||||
await websocket.accept()
|
||||
await register_device_status_ws(websocket)
|
||||
await broadcast_device_tcp_snapshot_to(websocket)
|
||||
bridge = _bridge()
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive()
|
||||
if data.get("type") == "websocket.disconnect":
|
||||
break
|
||||
if "bytes" in data and data["bytes"] is not None:
|
||||
await bridge.send(bytes(data["bytes"]))
|
||||
continue
|
||||
text = data.get("text")
|
||||
if text is None:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
addr = parsed.pop("to", None)
|
||||
await bridge.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await websocket.send_text(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await unregister_device_status_ws(websocket)
|
||||
|
||||
return api
|
||||
|
||||
|
||||
app = create_application()
|
||||
84
src/http_responses.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Response helpers for FastAPI controllers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
|
||||
|
||||
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def J(
|
||||
data: Any,
|
||||
status_code: int = 200,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> Response:
|
||||
"""JSON response (accepts dict or JSON string)."""
|
||||
if isinstance(data, str):
|
||||
return Response(
|
||||
content=data,
|
||||
status_code=status_code,
|
||||
media_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
return JSONResponse(content=data, status_code=status_code, headers=headers)
|
||||
|
||||
|
||||
async def read_json(request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def send_file(relative_path: str) -> FileResponse:
|
||||
path = os.path.join(_SRC_DIR, relative_path)
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
def send_html_file(relative_path: str, *, inject: str | None = None) -> HTMLResponse:
|
||||
path = os.path.join(_SRC_DIR, relative_path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
if inject and "</body>" in html:
|
||||
html = html.replace("</body>", inject + "\n</body>", 1)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
def html_response(content: str, status_code: int = 200) -> HTMLResponse:
|
||||
return HTMLResponse(content=content, status_code=status_code)
|
||||
|
||||
|
||||
def plain(content: str, status_code: int = 200) -> Response:
|
||||
return Response(
|
||||
content=content,
|
||||
status_code=status_code,
|
||||
media_type="text/plain; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
def empty(status_code: int = 204) -> Response:
|
||||
return Response(status_code=status_code)
|
||||
|
||||
|
||||
def J_cookie(
|
||||
data: Any,
|
||||
status_code: int = 200,
|
||||
*,
|
||||
name: str,
|
||||
value: str,
|
||||
max_age: int | None = None,
|
||||
path: str = "/",
|
||||
samesite: str = "lax",
|
||||
) -> Response:
|
||||
resp = J(data, status_code)
|
||||
kwargs: dict[str, Any] = {"path": path, "samesite": samesite}
|
||||
if max_age is not None:
|
||||
kwargs["max_age"] = max_age
|
||||
resp.set_cookie(name, value, **kwargs)
|
||||
return resp
|
||||
117
src/http_session.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Signed-cookie sessions for the web UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from settings import get_settings
|
||||
|
||||
_COOKIE = "session"
|
||||
_ALGORITHM = "HS256"
|
||||
|
||||
|
||||
class SessionDict(dict):
|
||||
"""Session mapping with ``save()`` / ``delete()`` for cookie persistence."""
|
||||
|
||||
def __init__(self, request: Request, data: dict | None = None):
|
||||
super().__init__(data or {})
|
||||
self._request = request
|
||||
self._save = False
|
||||
self._delete = False
|
||||
|
||||
def save(self) -> None:
|
||||
self._save = True
|
||||
self._delete = False
|
||||
|
||||
def delete(self) -> None:
|
||||
self._delete = True
|
||||
self._save = False
|
||||
|
||||
|
||||
def _secret_key() -> str:
|
||||
return str(
|
||||
get_settings().get(
|
||||
"session_secret_key",
|
||||
"led-controller-secret-key-change-in-production",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def encode_session(payload: dict) -> str:
|
||||
return jwt.encode(payload, _secret_key(), algorithm=_ALGORITHM)
|
||||
|
||||
|
||||
def decode_session(token: str) -> dict:
|
||||
try:
|
||||
data = jwt.decode(token, _secret_key(), algorithms=[_ALGORITHM])
|
||||
return data if isinstance(data, dict) else {}
|
||||
except jwt.PyJWTError:
|
||||
return {}
|
||||
|
||||
|
||||
def get_session(request: Request) -> SessionDict:
|
||||
session = getattr(request.state, "session", None)
|
||||
if session is None:
|
||||
cookie = request.cookies.get(_COOKIE)
|
||||
data = decode_session(cookie) if cookie else {}
|
||||
session = SessionDict(request, data)
|
||||
request.state.session = session
|
||||
return session
|
||||
|
||||
|
||||
def with_session(handler: Callable) -> Callable:
|
||||
sig = inspect.signature(handler)
|
||||
public_params = [
|
||||
p
|
||||
for name, p in sig.parameters.items()
|
||||
if name not in ("request", "session")
|
||||
]
|
||||
if "request" in sig.parameters:
|
||||
req_param = sig.parameters["request"].replace(annotation=Request)
|
||||
else:
|
||||
req_param = inspect.Parameter(
|
||||
"request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
|
||||
)
|
||||
wrapper_sig = inspect.Signature([req_param, *public_params])
|
||||
|
||||
async def wrapper(request: Request, *args: Any, **kwargs: Any):
|
||||
session = get_session(request)
|
||||
return await handler(request, session, *args, **kwargs)
|
||||
|
||||
wrapper.__name__ = handler.__name__
|
||||
wrapper.__doc__ = handler.__doc__
|
||||
wrapper.__module__ = handler.__module__
|
||||
wrapper.__qualname__ = handler.__qualname__
|
||||
wrapper.__signature__ = wrapper_sig # type: ignore[attr-defined]
|
||||
return wrapper
|
||||
|
||||
|
||||
def _apply_session_cookie(request: Request, response: Response) -> Response:
|
||||
session: SessionDict | None = getattr(request.state, "session", None)
|
||||
if session is None:
|
||||
return response
|
||||
if session._delete:
|
||||
response.delete_cookie(_COOKIE, path="/", httponly=True)
|
||||
elif session._save:
|
||||
response.set_cookie(
|
||||
_COOKIE,
|
||||
encode_session(dict(session)),
|
||||
path="/",
|
||||
httponly=True,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SessionMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
cookie = request.cookies.get(_COOKIE)
|
||||
data = decode_session(cookie) if cookie else {}
|
||||
request.state.session = SessionDict(request, data)
|
||||
response = await call_next(request)
|
||||
return _apply_session_cookie(request, response)
|
||||
456
src/main.py
@@ -1,456 +0,0 @@
|
||||
import asyncio
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import signal
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
import controllers.group as group
|
||||
import controllers.sequence as sequence
|
||||
import controllers.zone as zone
|
||||
import controllers.palette as palette
|
||||
import controllers.scene as scene
|
||||
import controllers.pattern as pattern
|
||||
import controllers.settings as settings_controller
|
||||
import controllers.device as device_controller
|
||||
import controllers.led_tool as led_tool_controller
|
||||
from models.transport import (
|
||||
get_bridge,
|
||||
set_bridge,
|
||||
get_current_bridge,
|
||||
BridgeSerialTransport,
|
||||
BridgeWsTransport,
|
||||
)
|
||||
from models.device import Device
|
||||
from models.bridge_serial_client import init_bridge_serial_client
|
||||
from models.bridge_ws_client import init_bridge_client
|
||||
from util.espnow_registry import handle_bridge_uplink
|
||||
from util.bridge_runtime import set_bridge_uplink_handler
|
||||
import controllers.wifi_bridge as wifi_bridge_controller
|
||||
from util.audio_detector import AudioBeatDetector
|
||||
|
||||
|
||||
def _live_reload_enabled() -> bool:
|
||||
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||
return v not in ("", "0", "false", "no")
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = get_settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||
|
||||
bridge = get_bridge(settings)
|
||||
set_bridge(bridge)
|
||||
|
||||
bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
if bridge_mode == "wifi":
|
||||
ws_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if ws_url:
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = WIFI_CHANNEL_DEFAULT
|
||||
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||
ws_client.start()
|
||||
set_bridge(BridgeWsTransport())
|
||||
elif bridge_mode == "serial":
|
||||
serial_port = str(settings.get("bridge_serial_port") or "").strip()
|
||||
if serial_port:
|
||||
baud = 115200
|
||||
for prof in settings.get("bridges") or []:
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||
continue
|
||||
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||
continue
|
||||
try:
|
||||
baud = int(prof.get("serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
else:
|
||||
try:
|
||||
baud = int(settings.get("bridge_serial_baudrate") or baud)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||
serial_client.start()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
|
||||
app = Microdot()
|
||||
audio_detector = AudioBeatDetector()
|
||||
try:
|
||||
from util import audio_detector as audio_detector_module
|
||||
|
||||
audio_detector_module.set_shared_beat_detector(audio_detector)
|
||||
except Exception as e:
|
||||
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||
try:
|
||||
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||
|
||||
persisted = read_audio_run_state()
|
||||
if persisted.get("enabled"):
|
||||
sel = persisted.get("device_select") or persisted.get("device")
|
||||
dev = coerce_audio_device(sel)
|
||||
audio_detector.start(device=dev)
|
||||
print("[startup] audio beat detector started from saved run state")
|
||||
except Exception as e:
|
||||
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||
from util import beat_driver_route
|
||||
|
||||
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
|
||||
# Initialize sessions with a secret key from settings
|
||||
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
# Mount model controllers as subroutes
|
||||
# Verify controllers are Microdot instances before mounting
|
||||
controllers_to_mount = [
|
||||
('/presets', preset, 'preset'),
|
||||
('/profiles', profile, 'profile'),
|
||||
('/groups', group, 'group'),
|
||||
('/sequences', sequence, 'sequence'),
|
||||
('/zones', zone, 'zone'),
|
||||
('/palettes', palette, 'palette'),
|
||||
('/scenes', scene, 'scene'),
|
||||
]
|
||||
|
||||
# Mount model controllers as subroutes
|
||||
app.mount(preset.controller, '/presets')
|
||||
app.mount(profile.controller, '/profiles')
|
||||
app.mount(group.controller, '/groups')
|
||||
app.mount(sequence.controller, '/sequences')
|
||||
app.mount(zone.controller, '/zones')
|
||||
app.mount(palette.controller, '/palettes')
|
||||
app.mount(scene.controller, '/scenes')
|
||||
app.mount(pattern.controller, '/patterns')
|
||||
app.mount(settings_controller.controller, '/settings')
|
||||
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
|
||||
app.mount(device_controller.controller, '/devices')
|
||||
app.mount(led_tool_controller.controller, '/led-tool')
|
||||
|
||||
live_reload = _live_reload_enabled()
|
||||
dev_build_id = secrets.token_hex(12) if live_reload else None
|
||||
if live_reload:
|
||||
print(
|
||||
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
|
||||
)
|
||||
|
||||
if dev_build_id:
|
||||
|
||||
@app.route("/__dev/build-id")
|
||||
def dev_build_id_route(request):
|
||||
_ = request
|
||||
return (
|
||||
dev_build_id,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
"""Serve the main web UI."""
|
||||
if dev_build_id:
|
||||
try:
|
||||
with open("templates/index.html", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
tag = '<script src="/static/dev-live-reload.js" defer></script>'
|
||||
if "</body>" in html:
|
||||
html = html.replace("</body>", tag + "\n</body>", 1)
|
||||
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
|
||||
except OSError:
|
||||
pass
|
||||
return send_file("templates/index.html")
|
||||
|
||||
# Favicon: avoid 404 in browser console (no file needed)
|
||||
@app.route('/favicon.ico')
|
||||
def favicon(request):
|
||||
return '', 204
|
||||
|
||||
@app.route('/api/audio/devices')
|
||||
async def audio_devices(request):
|
||||
_ = request
|
||||
try:
|
||||
return {
|
||||
"devices": audio_detector.list_input_devices(),
|
||||
"diagnostics": audio_detector.diagnostics(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/start', methods=['POST'])
|
||||
async def audio_start(request):
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device = payload.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
if not device_select and device not in ("", None):
|
||||
device_select = str(device).strip()
|
||||
try:
|
||||
from util.pulse_audio_devices import resolve_capture_device
|
||||
|
||||
device = resolve_capture_device(device)
|
||||
audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(
|
||||
enabled=True,
|
||||
device=device,
|
||||
device_override=str(payload.get("device_override") or ""),
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
|
||||
@app.route('/api/audio/device', methods=['PUT'])
|
||||
async def audio_set_device(request):
|
||||
"""Save preferred input device without toggling run state."""
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
device_override = str(payload.get("device_override") or "").strip()
|
||||
raw = device_override if device_override else device_select
|
||||
device = raw if raw else None
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||
|
||||
prev = read_audio_run_state()
|
||||
write_audio_run_state(
|
||||
enabled=bool(prev.get("enabled")),
|
||||
device=device if raw else None,
|
||||
device_override=device_override,
|
||||
device_select=device_select,
|
||||
)
|
||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||
|
||||
@app.route('/api/audio/stop', methods=['POST'])
|
||||
async def audio_stop(request):
|
||||
_ = request
|
||||
audio_detector.stop()
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=False)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/reset', methods=['POST'])
|
||||
async def audio_reset(request):
|
||||
"""Clear beat/BPM tracking state without stopping the detector."""
|
||||
_ = request
|
||||
ok = audio_detector.reset_tracking()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
||||
async def audio_anchor_bar(request):
|
||||
"""Mark the current moment as bar beat 1 (downbeat)."""
|
||||
_ = request
|
||||
ok = audio_detector.anchor_bar_phase()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/status')
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
from util import beat_driver_route
|
||||
from util import sequence_playback
|
||||
|
||||
st = audio_detector.status()
|
||||
st["sequence"] = sequence_playback.playback_status()
|
||||
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||
seq = st.get("sequence")
|
||||
beat_readout = ""
|
||||
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||
elif st.get("running"):
|
||||
mb = st.get("manual_beat_stride")
|
||||
if isinstance(mb, dict) and mb.get("active"):
|
||||
try:
|
||||
n = int(mb.get("stride_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
n = 1
|
||||
n = max(1, min(64, n))
|
||||
try:
|
||||
bi = int(mb.get("beat_in_stride") or 1)
|
||||
except (TypeError, ValueError):
|
||||
bi = 1
|
||||
pos = min(n, max(1, bi))
|
||||
beat_readout = f"{pos}/{n}"
|
||||
else:
|
||||
try:
|
||||
bs = int(st.get("beat_seq") or 0)
|
||||
except (TypeError, ValueError):
|
||||
bs = 0
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||
try:
|
||||
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||
except (TypeError, ValueError):
|
||||
st["input_volume"] = 100
|
||||
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||
if seq_wait not in ("beat", "downbeat"):
|
||||
seq_wait = "beat"
|
||||
st["sequence_switch_wait"] = seq_wait
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return {"status": st}
|
||||
|
||||
# Static file route
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
"""Serve static files."""
|
||||
if '..' in path:
|
||||
# Directory traversal is not allowed
|
||||
return 'Not found', 404
|
||||
return send_file('static/' + path)
|
||||
|
||||
@app.route('/ws')
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
await bridge.send(bytes(data))
|
||||
continue
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
await bridge.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
try:
|
||||
await ws.send(json.dumps({"error": "Send failed"}))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Device()
|
||||
loop = asyncio.get_running_loop()
|
||||
server_tasks: list[asyncio.Task] = []
|
||||
|
||||
def _graceful_shutdown(*_args):
|
||||
print("[server] shutting down...")
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.stop()
|
||||
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||
t = getattr(seq_pb, attr, None)
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
if getattr(app, "server", None) is not None:
|
||||
try:
|
||||
app.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
for t in server_tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
shutdown_handlers_registered = False
|
||||
try:
|
||||
try:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, _graceful_shutdown)
|
||||
shutdown_handlers_registered = True
|
||||
except (NotImplementedError, RuntimeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
server_tasks[:] = [
|
||||
asyncio.create_task(
|
||||
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||
),
|
||||
]
|
||||
await asyncio.gather(*server_tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
print(
|
||||
f"[server] bind failed (address already in use): {e!s}\n"
|
||||
f"[server] HTTP is configured for port {port} (env PORT). "
|
||||
f"Stop the other process or use a free port, e.g. PORT=8080 pipenv run run"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
srv = getattr(app, "server", None)
|
||||
if srv is not None:
|
||||
try:
|
||||
srv.close()
|
||||
await srv.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
for t in list(server_tasks):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
if server_tasks:
|
||||
await asyncio.gather(*server_tasks, return_exceptions=True)
|
||||
pending = [
|
||||
t
|
||||
for t in asyncio.all_tasks(loop)
|
||||
if t is not asyncio.current_task() and not t.done()
|
||||
]
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if shutdown_handlers_registered:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.remove_signal_handler(sig)
|
||||
except (NotImplementedError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
port = int(os.environ.get("PORT", 80))
|
||||
try:
|
||||
asyncio.run(main(port=port))
|
||||
except KeyboardInterrupt:
|
||||
print("[server] interrupted")
|
||||
@@ -57,16 +57,6 @@ class Sequence(Model):
|
||||
if doc.get("advance_mode") != "beats":
|
||||
doc["advance_mode"] = "beats"
|
||||
changed = True
|
||||
if "simulated_bpm" not in doc:
|
||||
doc["simulated_bpm"] = 120
|
||||
changed = True
|
||||
else:
|
||||
try:
|
||||
sb = int(float(doc["simulated_bpm"]))
|
||||
doc["simulated_bpm"] = max(30, min(300, sb))
|
||||
except (TypeError, ValueError):
|
||||
doc["simulated_bpm"] = 120
|
||||
changed = True
|
||||
if "sequence_transition" not in doc:
|
||||
doc["sequence_transition"] = 500
|
||||
changed = True
|
||||
@@ -115,7 +105,6 @@ class Sequence(Model):
|
||||
"advance_mode": "beats",
|
||||
"steps": [],
|
||||
"step_duration_ms": 3000,
|
||||
"simulated_bpm": 120,
|
||||
"sequence_transition": 500,
|
||||
"loop": True,
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ def _maybe_migrate_tab_json_to_zone():
|
||||
class Zone(Model):
|
||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
|
||||
|
||||
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
|
||||
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
|
||||
Optional legacy ``content_kind`` (``\"presets\"`` / ``\"sequences\"``) is kept for older data;
|
||||
zones may hold both preset tiles and ``sequence_ids``.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -95,13 +95,8 @@ class Zone(Model):
|
||||
return "presets"
|
||||
|
||||
def _enforce_content_kind_invariants(self, doc):
|
||||
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
||||
kind = self._normalized_content_kind(doc)
|
||||
if kind == "presets":
|
||||
doc["sequence_ids"] = []
|
||||
elif kind == "sequences":
|
||||
doc["presets"] = []
|
||||
doc["presets_flat"] = []
|
||||
"""No-op: presets and sequences may coexist on one zone."""
|
||||
_ = doc
|
||||
|
||||
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
|
||||
next_id = self.get_next_id()
|
||||
@@ -135,13 +130,7 @@ class Zone(Model):
|
||||
if id_str not in self:
|
||||
return False
|
||||
patch = dict(data) if isinstance(data, dict) else {}
|
||||
doc = self[id_str]
|
||||
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
|
||||
if "content_kind" in patch:
|
||||
patch["content_kind"] = locked_kind
|
||||
self[id_str].update(patch)
|
||||
if "content_kind" in patch:
|
||||
self._enforce_content_kind_invariants(self[id_str])
|
||||
self.save()
|
||||
return True
|
||||
|
||||
|
||||
@@ -67,6 +67,21 @@ class Settings(dict):
|
||||
self['bridge_serial_port'] = ''
|
||||
if 'bridge_serial_baudrate' not in self:
|
||||
self['bridge_serial_baudrate'] = 115200
|
||||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
||||
if 'wifi_driver_ws_port' not in self:
|
||||
self['wifi_driver_ws_port'] = 80
|
||||
if 'wifi_driver_ws_path' not in self:
|
||||
self['wifi_driver_ws_path'] = '/ws'
|
||||
if 'wifi_driver_hello_interval_s' not in self:
|
||||
self['wifi_driver_hello_interval_s'] = 10.0
|
||||
if 'wifi_driver_connect_retry_window_s' not in self:
|
||||
self['wifi_driver_connect_retry_window_s'] = 120.0
|
||||
if 'wifi_driver_connect_stagger_max_s' not in self:
|
||||
self['wifi_driver_connect_stagger_max_s'] = 2.5
|
||||
if 'wifi_driver_ws_open_timeout' not in self:
|
||||
self['wifi_driver_ws_open_timeout'] = 45.0
|
||||
if 'wifi_driver_connect_retry_interval_s' not in self:
|
||||
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||
if 'global_brightness' not in self:
|
||||
self['global_brightness'] = 255
|
||||
@@ -81,6 +96,13 @@ class Settings(dict):
|
||||
# Input gain for beat detection (percent, 0–200).
|
||||
if 'audio_input_volume' not in self:
|
||||
self['audio_input_volume'] = 100
|
||||
# BPM used for sequences when the audio detector is not running.
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
if 'audio_simulated_bpm' not in self:
|
||||
self['audio_simulated_bpm'] = int(clamp_bpm(120))
|
||||
else:
|
||||
self['audio_simulated_bpm'] = int(clamp_bpm(self['audio_simulated_bpm']))
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -872,7 +872,7 @@ class LightingController {
|
||||
this.selectTab(this.state.zone_order[0]);
|
||||
} else {
|
||||
this.currentTab = null;
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1010,7 +1010,7 @@ class LightingController {
|
||||
this.state.lights = {};
|
||||
this.state.zone_order = [];
|
||||
this.renderTabs();
|
||||
document.getElementById('zone-content').innerHTML = '<p>No tabs available. Create a new zone to get started.</p>';
|
||||
document.getElementById('zone-content').innerHTML = '<p>No zones available. Create a new zone to get started.</p>';
|
||||
this.updateCurrentProfileDisplay();
|
||||
}
|
||||
} else {
|
||||
@@ -1382,7 +1382,7 @@ class LightingController {
|
||||
|
||||
const presetNames = Object.keys(this.state.presets);
|
||||
if (presetNames.length === 0) {
|
||||
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found. Create one to get started.</p>';
|
||||
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found.</p>';
|
||||
} else {
|
||||
presetNames.forEach(presetName => {
|
||||
const preset = this.state.presets[presetName];
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
(() => {
|
||||
let pollTimer = null;
|
||||
let beatEventSource = null;
|
||||
let beatEventsReconnectTimer = null;
|
||||
let audioDetectorRunning = false;
|
||||
let lastBeatSeq = 0;
|
||||
let lastSimulatedBeatTick = 0;
|
||||
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
|
||||
let prevZoneSequencePlaybackActive = false;
|
||||
/**
|
||||
@@ -14,26 +16,51 @@
|
||||
let cachedBeatPhaseMs = 0;
|
||||
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
|
||||
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
|
||||
/** True after client starts sequence playback until server reports stop. */
|
||||
let clientSequenceUiActive = false;
|
||||
/** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */
|
||||
let stickySequenceBeatReadout = "";
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function resolveBeatReadoutText(status) {
|
||||
let text = String((status && status.beat_readout) || "").trim();
|
||||
if (text) return text;
|
||||
const seq = /** @type {Record<string, unknown>|undefined} */ (
|
||||
status && status.sequence
|
||||
);
|
||||
if (seq && seq.active) {
|
||||
text = String(seq.beat_readout || "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
if (stickySequenceBeatReadout) {
|
||||
return stickySequenceBeatReadout;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function updateBeatReadoutDisplays(status) {
|
||||
const text = String((status && status.beat_readout) || "").trim();
|
||||
const text = resolveBeatReadoutText(status);
|
||||
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
|
||||
const n = el(id);
|
||||
if (n) n.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBpmDisplay(bpm) {
|
||||
function updateBpmDisplay(bpm, simulated = false) {
|
||||
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
|
||||
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
|
||||
const node = el(id);
|
||||
if (node) node.textContent = text;
|
||||
}
|
||||
for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) {
|
||||
const node = el(id);
|
||||
if (node) node.classList.toggle("audio-simulated", !!simulated);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
|
||||
@@ -44,6 +71,43 @@
|
||||
return !!(seq && seq.active);
|
||||
}
|
||||
|
||||
/** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */
|
||||
function sequenceBeatUiActiveFromStatus(status) {
|
||||
if (sequencePlaybackActiveFromStatus(status)) return true;
|
||||
const pending = /** @type {Record<string, unknown>|undefined} */ (
|
||||
status && status.sequence_pending
|
||||
);
|
||||
return !!(pending && pending.pending);
|
||||
}
|
||||
|
||||
function resolveSeqUiActive(status) {
|
||||
return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive;
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function updateTopIndicatorFromStatus(status) {
|
||||
const running = !!(status && status.running);
|
||||
const bpmSimulated = !!(status && status.bpm_simulated);
|
||||
const seqUiActive = resolveSeqUiActive(status);
|
||||
const show = running || seqUiActive || bpmSimulated;
|
||||
setTopBpmVisible(show);
|
||||
if (!show || running) return;
|
||||
const simBpm =
|
||||
status && status.audio_simulated_bpm != null
|
||||
? Number(status.audio_simulated_bpm)
|
||||
: getSimulatedBpmPercent();
|
||||
updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true);
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function shouldKeepStatusPolling(status) {
|
||||
return (
|
||||
!!(status && status.running) ||
|
||||
resolveSeqUiActive(status) ||
|
||||
!!(status && status.bpm_simulated)
|
||||
);
|
||||
}
|
||||
|
||||
function updateHitTypeDisplay(hitType, confidence) {
|
||||
const node = el("audio-hit-type-value");
|
||||
if (!node) return;
|
||||
@@ -57,6 +121,8 @@
|
||||
const readout = String((status && status.bar_phase_readout) || "").trim();
|
||||
const phaseConf = Number((status && status.phase_confidence) || 0);
|
||||
const downbeat = !!(status && status.is_downbeat);
|
||||
const simulated = !!(status && status.bpm_simulated);
|
||||
const showPhase = !!(status && status.running) || simulated;
|
||||
let text = readout || "--";
|
||||
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
|
||||
text = `${text} (${Math.round(phaseConf * 100)}%)`;
|
||||
@@ -64,8 +130,8 @@
|
||||
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
|
||||
const node = el(id);
|
||||
if (!node) continue;
|
||||
node.textContent = status && status.running ? text : "";
|
||||
node.classList.toggle("is-downbeat", downbeat && !!readout);
|
||||
node.textContent = showPhase ? text : "";
|
||||
node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +141,50 @@
|
||||
top.classList.toggle("audio-running", !!on);
|
||||
}
|
||||
|
||||
function closeBeatEvents() {
|
||||
if (beatEventsReconnectTimer != null) {
|
||||
clearTimeout(beatEventsReconnectTimer);
|
||||
beatEventsReconnectTimer = null;
|
||||
}
|
||||
if (beatEventSource) {
|
||||
beatEventSource.close();
|
||||
beatEventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBeatEventsReconnect() {
|
||||
if (beatEventsReconnectTimer != null) return;
|
||||
beatEventsReconnectTimer = setTimeout(() => {
|
||||
beatEventsReconnectTimer = null;
|
||||
void fetchAudioStatusOnce()
|
||||
.then((status) => {
|
||||
applyAudioStatus(status);
|
||||
if (shouldKeepStatusPolling(status)) ensureBeatEvents();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("audio status reconnect fetch failed", e);
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function ensureBeatEvents() {
|
||||
if (beatEventSource) return;
|
||||
const es = new EventSource("/api/audio/events");
|
||||
beatEventSource = es;
|
||||
es.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || ""));
|
||||
if (data && data.status) applyAudioStatus(data.status);
|
||||
} catch (e) {
|
||||
console.warn("audio beat event parse failed", e);
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
closeBeatEvents();
|
||||
scheduleBeatEventsReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function setResetDetectorEnabled(on) {
|
||||
const btn = el("audio-reset-btn");
|
||||
if (btn) btn.disabled = !on;
|
||||
@@ -150,21 +260,25 @@
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function flashBeatSyncButton(btn) {
|
||||
function flashBeatSyncButton(btn, simulated = false) {
|
||||
if (!btn) return;
|
||||
btn.classList.add("flash");
|
||||
setTimeout(() => btn.classList.remove("flash"), 90);
|
||||
btn.classList.add(simulated ? "flash-simulated" : "flash");
|
||||
setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90);
|
||||
}
|
||||
|
||||
function flashBeat() {
|
||||
function flashBeat(simulated = false) {
|
||||
const top = el("audio-top-indicator");
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync && top && top.classList.contains("audio-running")) {
|
||||
flashBeatSyncButton(topSync);
|
||||
if (
|
||||
topSync &&
|
||||
top &&
|
||||
(top.classList.contains("audio-running") || simulated)
|
||||
) {
|
||||
flashBeatSyncButton(topSync, simulated);
|
||||
}
|
||||
const modalSync = el("audio-modal-beat-sync");
|
||||
if (modalSync && audioDetectorRunning) {
|
||||
flashBeatSyncButton(modalSync);
|
||||
if (modalSync && (audioDetectorRunning || simulated)) {
|
||||
flashBeatSyncButton(modalSync, simulated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +328,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
const SIMULATED_BPM_MIN = 60;
|
||||
const SIMULATED_BPM_MAX = 200;
|
||||
|
||||
function clampSimulatedBpm(n) {
|
||||
if (!Number.isFinite(n)) return 120;
|
||||
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n)));
|
||||
}
|
||||
|
||||
function clampLiveBpm(n) {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n));
|
||||
}
|
||||
|
||||
function getSimulatedBpmPercent() {
|
||||
const inp = el("audio-simulated-bpm");
|
||||
if (!inp) return 120;
|
||||
return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10));
|
||||
}
|
||||
|
||||
async function persistSimulatedBpm() {
|
||||
const bpm = getSimulatedBpmPercent();
|
||||
try {
|
||||
await fetch("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ audio_simulated_bpm: bpm }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("simulated bpm save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistInputVolume() {
|
||||
const vol = getInputVolumePercent();
|
||||
updateInputVolumeReadout();
|
||||
@@ -228,11 +374,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBeatPhaseFire(seq, delayMs) {
|
||||
function scheduleBeatPhaseFire(seq, delayMs, simulated = false) {
|
||||
let tid = null;
|
||||
const run = () => {
|
||||
if (tid != null) pendingBeatPhaseTimers.delete(tid);
|
||||
flashBeat();
|
||||
flashBeat(simulated);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
|
||||
@@ -252,23 +398,23 @@
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
audioDetectorRunning = false;
|
||||
setTopBpmVisible(false);
|
||||
setResetDetectorEnabled(false);
|
||||
clearBeatPhaseTimers();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
lastBeatSeq = 0;
|
||||
lastSimulatedBeatTick = 0;
|
||||
prevZoneSequencePlaybackActive = false;
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
updateBeatReadoutDisplays({});
|
||||
updateInputLevelDisplay(0);
|
||||
setTopBpmVisible(true);
|
||||
updateBpmDisplay(getSimulatedBpmPercent(), true);
|
||||
try {
|
||||
await fetch("/api/audio/stop", { method: "POST" });
|
||||
} catch (e) {
|
||||
console.warn("audio stop failed", e);
|
||||
}
|
||||
ensureBeatEvents();
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
/** User-initiated stop (run intent cleared on server). */
|
||||
@@ -276,11 +422,24 @@
|
||||
await stopAudioOnly();
|
||||
}
|
||||
|
||||
async function fetchAudioStatusOnce() {
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
return data?.status || {};
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
const status = await fetchAudioStatusOnce();
|
||||
applyAudioStatus(status);
|
||||
} catch (e) {
|
||||
console.warn("audio status fetch failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function applyAudioStatus(status) {
|
||||
try {
|
||||
if (status.error && String(status.error).trim()) {
|
||||
const node = el("audio-hit-type-value");
|
||||
if (node) {
|
||||
@@ -288,28 +447,41 @@
|
||||
}
|
||||
updateBeatReadoutDisplays({});
|
||||
audioDetectorRunning = !!status.running;
|
||||
updateBpmDisplay(null);
|
||||
updateInputLevelDisplay(0);
|
||||
setTopBpmVisible(!!status.running);
|
||||
updateTopIndicatorFromStatus(status);
|
||||
setResetDetectorEnabled(!!status.running);
|
||||
if (!status.running && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
if (!shouldKeepStatusPolling(status)) closeBeatEvents();
|
||||
return;
|
||||
}
|
||||
audioDetectorRunning = !!status.running;
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||
const seqUiActive = resolveSeqUiActive(status);
|
||||
const bpmSimulated = !!status.bpm_simulated;
|
||||
if (sequenceBeatUiActiveFromStatus(status)) {
|
||||
clientSequenceUiActive = false;
|
||||
}
|
||||
updateTopIndicatorFromStatus(status);
|
||||
setResetDetectorEnabled(!!status.running);
|
||||
updateSequenceSyncControls(zoneSeqActive);
|
||||
updateBpmDisplay(status.bpm);
|
||||
updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive);
|
||||
const displayBpm =
|
||||
bpmSimulated && status.audio_simulated_bpm != null
|
||||
? clampSimulatedBpm(Number(status.audio_simulated_bpm))
|
||||
: status.bpm != null
|
||||
? clampLiveBpm(Number(status.bpm))
|
||||
: null;
|
||||
updateBpmDisplay(
|
||||
Number.isFinite(displayBpm) ? displayBpm : null,
|
||||
bpmSimulated,
|
||||
);
|
||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||
updateBarPhaseDisplay(status);
|
||||
updateInputLevelDisplay(
|
||||
status.running ? Number(status.input_level) : 0,
|
||||
);
|
||||
applyServerAudioUiFields(status);
|
||||
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
|
||||
window.setSequenceSwitchSimulatedMode(bpmSimulated);
|
||||
}
|
||||
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
|
||||
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
|
||||
}
|
||||
@@ -319,30 +491,56 @@
|
||||
* `sequence` on each poll.
|
||||
*/
|
||||
const beatSeq = Number(status.beat_seq || 0);
|
||||
const simTick = Number(status.simulated_beat_tick || 0);
|
||||
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||
if (startedSeq) {
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
stickySequenceBeatReadout = "";
|
||||
if (bpmSimulated) {
|
||||
lastSimulatedBeatTick = Math.max(0, simTick - 1);
|
||||
}
|
||||
}
|
||||
if (zoneSeqActive) {
|
||||
const liveReadout = String((status.beat_readout || "") || "").trim()
|
||||
|| String((status.sequence && status.sequence.beat_readout) || "").trim();
|
||||
if (liveReadout) {
|
||||
stickySequenceBeatReadout = liveReadout;
|
||||
}
|
||||
}
|
||||
if (endedSeq) {
|
||||
clientSequenceUiActive = false;
|
||||
headerBeatStickyIdleAfterSeq = true;
|
||||
clearBeatPhaseTimers();
|
||||
lastBeatSeq = beatSeq;
|
||||
lastSimulatedBeatTick = simTick;
|
||||
if (!stickySequenceBeatReadout) {
|
||||
const tail = String((status.beat_readout || "") || "").trim();
|
||||
if (tail) stickySequenceBeatReadout = tail;
|
||||
}
|
||||
}
|
||||
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||
if (bpmSimulated && simTick > lastSimulatedBeatTick) {
|
||||
lastSimulatedBeatTick = simTick;
|
||||
scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true);
|
||||
} else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||
if (beatSeq > lastBeatSeq) {
|
||||
lastBeatSeq = beatSeq;
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
|
||||
headerBeatStickyIdleAfterSeq = false;
|
||||
}
|
||||
} else if (beatSeq > lastBeatSeq) {
|
||||
} else if (!bpmSimulated && beatSeq > lastBeatSeq) {
|
||||
lastBeatSeq = beatSeq;
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
|
||||
}
|
||||
updateBeatReadoutDisplays(status);
|
||||
if (shouldKeepStatusPolling(status)) {
|
||||
ensureBeatEvents();
|
||||
} else {
|
||||
closeBeatEvents();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio status poll failed", e);
|
||||
console.warn("audio status apply failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +677,7 @@
|
||||
setSelectedDeviceId(selected);
|
||||
updateBpmDisplay(null);
|
||||
updateHitTypeDisplay("unknown", NaN);
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
ensureBeatEvents();
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
@@ -594,6 +792,17 @@
|
||||
});
|
||||
updateInputVolumeReadout();
|
||||
}
|
||||
const simBpmInp = el("audio-simulated-bpm");
|
||||
if (simBpmInp) {
|
||||
const onSimBpmChange = () => {
|
||||
void persistSimulatedBpm();
|
||||
if (!audioDetectorRunning) {
|
||||
updateBpmDisplay(getSimulatedBpmPercent(), true);
|
||||
}
|
||||
};
|
||||
simBpmInp.addEventListener("input", onSimBpmChange);
|
||||
simBpmInp.addEventListener("change", onSimBpmChange);
|
||||
}
|
||||
|
||||
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
|
||||
const btn = el(id);
|
||||
@@ -614,22 +823,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function resumePollingIfDetectorRunning() {
|
||||
async function resumeBeatEventsIfNeeded() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
const status = data?.status || {};
|
||||
const status = await fetchAudioStatusOnce();
|
||||
audioDetectorRunning = !!status.running;
|
||||
if (status.running && !pollTimer) {
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
updateTopIndicatorFromStatus(status);
|
||||
if (shouldKeepStatusPolling(status)) {
|
||||
lastBeatSeq = Number(status.beat_seq || 0);
|
||||
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
|
||||
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||
await pollStatus();
|
||||
applyAudioStatus(status);
|
||||
ensureBeatEvents();
|
||||
} else {
|
||||
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
|
||||
updateSequenceSyncControls(
|
||||
sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio resume poll check failed", e);
|
||||
console.warn("audio resume status check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +873,17 @@
|
||||
updateInputVolumeReadout();
|
||||
}
|
||||
}
|
||||
const simBpmInp = el("audio-simulated-bpm");
|
||||
if (
|
||||
simBpmInp &&
|
||||
status.audio_simulated_bpm != null &&
|
||||
document.activeElement !== simBpmInp
|
||||
) {
|
||||
const bpm = parseInt(String(status.audio_simulated_bpm), 10);
|
||||
if (Number.isFinite(bpm)) {
|
||||
simBpmInp.value = String(clampSimulatedBpm(bpm));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerAudioUiFields() {
|
||||
@@ -677,27 +899,38 @@
|
||||
setSelectedDeviceId(saved);
|
||||
}
|
||||
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
|
||||
updateTopIndicatorFromStatus(status);
|
||||
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
|
||||
window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated);
|
||||
}
|
||||
if (!status.running) {
|
||||
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
|
||||
applyAudioStatus(status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("audio status load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from sequences.js when server playback starts/stops without audio polling. */
|
||||
/** Called from sequences.js when server playback starts/stops. */
|
||||
window.ledControllerSequencePlaybackChanged = (active) => {
|
||||
clientSequenceUiActive = !!active;
|
||||
updateSequenceSyncControls(!!active);
|
||||
if (active) {
|
||||
setTopBpmVisible(true);
|
||||
if (!audioDetectorRunning) {
|
||||
updateBpmDisplay(getSimulatedBpmPercent(), true);
|
||||
}
|
||||
ensureBeatEvents();
|
||||
void pollStatus();
|
||||
return;
|
||||
}
|
||||
if (!pollTimer) {
|
||||
setTopBpmVisible(false);
|
||||
updateSequenceSyncControls(false);
|
||||
}
|
||||
void pollStatus();
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bind();
|
||||
await loadServerAudioUiFields();
|
||||
await resumePollingIfDetectorRunning();
|
||||
await resumeBeatEventsIfNeeded();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,25 +1,43 @@
|
||||
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
|
||||
/* Reload when uvicorn restarts (build-id) or static/template files change (client-rev). */
|
||||
(function () {
|
||||
var prev = null;
|
||||
function tick() {
|
||||
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
|
||||
var prevBuild = null;
|
||||
var prevRev = null;
|
||||
|
||||
function fetchText(url) {
|
||||
return fetch(url, { cache: 'no-store', credentials: 'same-origin' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.text() : '';
|
||||
})
|
||||
.then(function (id) {
|
||||
id = (id || '').trim();
|
||||
if (!id) return;
|
||||
if (prev === null) {
|
||||
prev = id;
|
||||
return;
|
||||
}
|
||||
if (id !== prev) {
|
||||
prev = id;
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
.catch(function () {
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
function tick() {
|
||||
Promise.all([
|
||||
fetchText('/__dev/build-id'),
|
||||
fetchText('/__dev/client-rev'),
|
||||
]).then(function (parts) {
|
||||
var buildId = (parts[0] || '').trim();
|
||||
var clientRev = (parts[1] || '').trim();
|
||||
if (!buildId && !clientRev) return;
|
||||
|
||||
if (prevBuild === null && prevRev === null) {
|
||||
prevBuild = buildId;
|
||||
prevRev = clientRev;
|
||||
return;
|
||||
}
|
||||
|
||||
var buildChanged = buildId && buildId !== prevBuild;
|
||||
var revChanged = clientRev && clientRev !== prevRev;
|
||||
if (buildChanged || revChanged) {
|
||||
if (buildId) prevBuild = buildId;
|
||||
if (clientRev) prevRev = clientRev;
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInterval(tick, 750);
|
||||
tick();
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
|
||||
let lastTcpSnapshotIps = null;
|
||||
@@ -290,75 +289,51 @@ function mergeTcpSnapshotPresence(ip, connected) {
|
||||
lastTcpSnapshotIps = Array.from(set);
|
||||
}
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'hex-addr-box';
|
||||
input.maxLength = 1;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-index', i);
|
||||
input.setAttribute('inputmode', 'numeric');
|
||||
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||
input.addEventListener('input', (e) => {
|
||||
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||
e.target.value = v;
|
||||
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||
e.target.nextElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||
e.target.previousElementSibling.focus();
|
||||
}
|
||||
});
|
||||
input.addEventListener('paste', (e) => {
|
||||
e.preventDefault();
|
||||
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||
boxes[j].value = pasted[j];
|
||||
}
|
||||
if (pasted.length > 0) {
|
||||
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||
boxes[nextIdx].focus();
|
||||
}
|
||||
});
|
||||
container.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function setAddressToBoxes(container, addrStr) {
|
||||
if (!container) return;
|
||||
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||
boxes.forEach((b, i) => {
|
||||
b.value = s[i] || '';
|
||||
});
|
||||
}
|
||||
|
||||
function applyTransportVisibility(transport) {
|
||||
const isWifi = transport === 'wifi';
|
||||
const esp = document.getElementById('edit-device-address-espnow');
|
||||
const espDrv = document.getElementById('edit-device-espnow-driver-wrap');
|
||||
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
|
||||
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
|
||||
if (esp) esp.hidden = isWifi;
|
||||
if (espDrv) espDrv.hidden = isWifi;
|
||||
if (wifiWrap) wifiWrap.hidden = !isWifi;
|
||||
if (drvWrap) drvWrap.hidden = !isWifi;
|
||||
}
|
||||
|
||||
function getDriverConfigPushFields(transport, registryName) {
|
||||
const push = {};
|
||||
if (registryName) push.name = registryName;
|
||||
if (transport === 'wifi') {
|
||||
const nl = document.getElementById('edit-device-wifi-num-leds');
|
||||
const co = document.getElementById('edit-device-wifi-color-order');
|
||||
const ws = document.getElementById('edit-device-wifi-startup-mode');
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
|
||||
}
|
||||
if (co && co.value) push.color_order = co.value;
|
||||
if (ws && ws.value) push.startup_mode = ws.value;
|
||||
} else {
|
||||
const nl = document.getElementById('edit-device-espnow-num-leds');
|
||||
const co = document.getElementById('edit-device-espnow-color-order');
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
|
||||
}
|
||||
if (co && co.value) push.color_order = co.value;
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
function getAddressForPayload(transport) {
|
||||
if (transport === 'wifi') {
|
||||
const el = document.getElementById('edit-device-address-wifi');
|
||||
const v = (el && el.value.trim()) || '';
|
||||
return v || null;
|
||||
}
|
||||
const boxEl = document.getElementById('edit-device-address-boxes');
|
||||
if (!boxEl) return null;
|
||||
const boxes = boxEl.querySelectorAll('.hex-addr-box');
|
||||
const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||
const macEl = document.getElementById('edit-device-address-mac');
|
||||
const hex = normalizeMacInput(macEl && macEl.value);
|
||||
return hex || null;
|
||||
}
|
||||
|
||||
@@ -395,30 +370,18 @@ function collectDeviceEditPayload() {
|
||||
}
|
||||
if (co && co.value) payload.wifi_color_order = co.value;
|
||||
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||||
} else {
|
||||
const nl = document.getElementById('edit-device-espnow-num-leds');
|
||||
const co = document.getElementById('edit-device-espnow-color-order');
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) payload.num_leds = n;
|
||||
}
|
||||
if (co && co.value) payload.color_order = co.value;
|
||||
}
|
||||
return { devId, payload };
|
||||
}
|
||||
|
||||
function refreshEditDeviceDebug() {
|
||||
const ta = document.getElementById('edit-device-debug');
|
||||
if (!ta) return;
|
||||
try {
|
||||
const { devId, payload } = collectDeviceEditPayload();
|
||||
const loaded = window.__editDeviceLoadedSnapshot;
|
||||
ta.value = JSON.stringify(
|
||||
{
|
||||
device_id: devId || null,
|
||||
loaded_from_server: loaded != null ? loaded : null,
|
||||
save_payload_preview: payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (e) {
|
||||
ta.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
@@ -508,7 +471,7 @@ function renderDevicesList(devices) {
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.';
|
||||
p.textContent = 'No devices yet.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
@@ -622,18 +585,13 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
try {
|
||||
window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null;
|
||||
} catch (e) {
|
||||
window.__editDeviceLoadedSnapshot = dev || null;
|
||||
}
|
||||
const modal = document.getElementById('edit-device-modal');
|
||||
const idInput = document.getElementById('edit-device-id');
|
||||
const storageLabel = document.getElementById('edit-device-storage-id');
|
||||
const nameInput = document.getElementById('edit-device-name');
|
||||
const typeSel = document.getElementById('edit-device-type');
|
||||
const transportSel = document.getElementById('edit-device-transport');
|
||||
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||
const macInput = document.getElementById('edit-device-address-mac');
|
||||
const wifiInput = document.getElementById('edit-device-address-wifi');
|
||||
if (!modal || !idInput) return;
|
||||
idInput.value = devId;
|
||||
@@ -643,8 +601,22 @@ function openEditDeviceModal(devId, dev) {
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
if (transportSel) transportSel.value = tr;
|
||||
applyTransportVisibility(tr);
|
||||
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
||||
if (macInput) macInput.value = tr === 'espnow' ? ((dev && dev.address) || '') : '';
|
||||
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
||||
const eLeds = document.getElementById('edit-device-espnow-num-leds');
|
||||
const eCo = document.getElementById('edit-device-espnow-color-order');
|
||||
if (eLeds) {
|
||||
eLeds.value =
|
||||
tr === 'espnow' && dev && dev.num_leds != null && dev.num_leds !== ''
|
||||
? String(dev.num_leds)
|
||||
: '';
|
||||
}
|
||||
if (eCo) {
|
||||
const co = (dev && dev.color_order) || 'rgb';
|
||||
eCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
|
||||
? String(co).toLowerCase()
|
||||
: 'rgb';
|
||||
}
|
||||
const wName = document.getElementById('edit-device-wifi-driver-name');
|
||||
const wLeds = document.getElementById('edit-device-wifi-num-leds');
|
||||
const wCo = document.getElementById('edit-device-wifi-color-order');
|
||||
@@ -689,35 +661,11 @@ function openEditDeviceModal(devId, dev) {
|
||||
obr.value = String(bv);
|
||||
if (obv) obv.textContent = String(bv);
|
||||
}
|
||||
refreshEditDeviceDebug();
|
||||
modal.classList.add('active');
|
||||
}
|
||||
|
||||
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
|
||||
async function updateDevice(devId, payload) {
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
type: type || 'led',
|
||||
transport: transport || 'espnow',
|
||||
address,
|
||||
};
|
||||
if (typeof outputBrightness === 'number') {
|
||||
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
|
||||
}
|
||||
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
|
||||
if (wifiDriverFields.wifi_driver_display_name != null) {
|
||||
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
|
||||
}
|
||||
if (wifiDriverFields.wifi_driver_num_leds != null) {
|
||||
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
|
||||
}
|
||||
if (wifiDriverFields.wifi_color_order != null) {
|
||||
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
|
||||
}
|
||||
if (wifiDriverFields.wifi_startup_mode != null) {
|
||||
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
|
||||
}
|
||||
}
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -796,8 +744,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshDevicesListQuiet();
|
||||
});
|
||||
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const devOutBr = document.getElementById('edit-device-output-brightness');
|
||||
const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
|
||||
if (devOutBr && devOutBrVal) {
|
||||
@@ -810,7 +756,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (transportEdit) {
|
||||
transportEdit.addEventListener('change', () => {
|
||||
applyTransportVisibility(transportEdit.value);
|
||||
refreshEditDeviceDebug();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -878,38 +823,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('input', () => refreshEditDeviceDebug());
|
||||
editForm.addEventListener('change', () => refreshEditDeviceDebug());
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const { devId, payload } = collectDeviceEditPayload();
|
||||
if (!devId) return;
|
||||
const transport = payload.transport || 'espnow';
|
||||
let wifiDriverFields = null;
|
||||
if (transport === 'wifi') {
|
||||
wifiDriverFields = {};
|
||||
if (payload.wifi_driver_display_name != null) {
|
||||
wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name;
|
||||
}
|
||||
if (payload.wifi_driver_num_leds != null) {
|
||||
wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds;
|
||||
}
|
||||
if (payload.wifi_color_order != null) {
|
||||
wifiDriverFields.wifi_color_order = payload.wifi_color_order;
|
||||
}
|
||||
if (payload.wifi_startup_mode != null) {
|
||||
wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode;
|
||||
}
|
||||
}
|
||||
const ok = await updateDevice(
|
||||
devId,
|
||||
payload.name,
|
||||
payload.type,
|
||||
transport,
|
||||
payload.address,
|
||||
wifiDriverFields,
|
||||
payload.output_brightness,
|
||||
);
|
||||
const ok = await updateDevice(devId, payload);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
|
||||
@@ -925,20 +843,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} catch (e) {
|
||||
console.warn('brightness push failed', e);
|
||||
}
|
||||
if (transport === 'wifi' && wifiDriverFields) {
|
||||
const dn = document.getElementById('edit-device-wifi-driver-name');
|
||||
const nl = document.getElementById('edit-device-wifi-num-leds');
|
||||
const co = document.getElementById('edit-device-wifi-color-order');
|
||||
const ws = document.getElementById('edit-device-wifi-startup-mode');
|
||||
const pushRes = await pushWifiDriverConfig(devId, {
|
||||
name: dn ? dn.value : '',
|
||||
num_leds: nl ? nl.value : '',
|
||||
color_order: co ? co.value : '',
|
||||
startup_mode: ws ? ws.value : '',
|
||||
});
|
||||
if (!pushRes.ok) return;
|
||||
}
|
||||
editDeviceModal.classList.remove('active');
|
||||
const pushRes = await pushWifiDriverConfig(
|
||||
devId,
|
||||
getDriverConfigPushFields(payload.transport || 'espnow', payload.name),
|
||||
);
|
||||
if (!pushRes.ok) return;
|
||||
await loadDevicesModal();
|
||||
});
|
||||
}
|
||||
if (editCloseBtn) {
|
||||
|
||||
@@ -85,35 +85,38 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||||
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
|
||||
const addWrap = document.createElement('div');
|
||||
addWrap.className = 'zone-devices-add profiles-actions';
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'zone-device-add-select';
|
||||
sel.appendChild(new Option('Add device…', ''));
|
||||
entries.forEach(([mac, d]) => {
|
||||
if (macsInRows.has(mac)) return;
|
||||
const labelName = d && d.name ? String(d.name).trim() : '';
|
||||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
||||
sel.appendChild(new Option(optLabel, mac));
|
||||
});
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-primary btn-small';
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.addEventListener('click', () => {
|
||||
const mac = sel.value;
|
||||
if (!mac || !devicesMap[mac]) return;
|
||||
const n = String((devicesMap[mac].name || '').trim() || mac);
|
||||
macRows.push({ mac, label: n });
|
||||
sel.value = '';
|
||||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
const picker =
|
||||
typeof window.createSearchableAddPicker === 'function'
|
||||
? window.createSearchableAddPicker({
|
||||
entries,
|
||||
excludeIds: macsInRows,
|
||||
labelFor: (mac, d) => {
|
||||
const labelName = d && d.name ? String(d.name).trim() : '';
|
||||
return labelName ? `${labelName} — ${mac}` : mac;
|
||||
},
|
||||
searchTextFor: (mac, d) => {
|
||||
const labelName = d && d.name ? String(d.name).trim() : '';
|
||||
return `${labelName} ${mac}`;
|
||||
},
|
||||
onPick: (mac, d) => {
|
||||
if (!mac || !devicesMap[mac]) return;
|
||||
const n = String((d.name || '').trim() || mac);
|
||||
macRows.push({ mac, label: n });
|
||||
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
|
||||
},
|
||||
placeholder: 'Search devices to add…',
|
||||
emptyMessage: 'No devices match your search.',
|
||||
noItemsMessage: 'All devices are already in this group.',
|
||||
})
|
||||
: null;
|
||||
if (picker) {
|
||||
addWrap.appendChild(picker);
|
||||
}
|
||||
if (panel) {
|
||||
panel.addSlot.appendChild(addWrap);
|
||||
} else {
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
refreshEditGroupDebug();
|
||||
}
|
||||
|
||||
function collectGroupEditPayload() {
|
||||
@@ -130,15 +133,17 @@ function collectGroupEditPayload() {
|
||||
const nl = document.getElementById('edit-group-wifi-num-leds');
|
||||
const co = document.getElementById('edit-group-wifi-color-order');
|
||||
const ws = document.getElementById('edit-group-wifi-startup-mode');
|
||||
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
|
||||
else payload.wifi_driver_display_name = null;
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
|
||||
else payload.wifi_driver_num_leds = null;
|
||||
} else payload.wifi_driver_num_leds = null;
|
||||
if (co && co.value) payload.wifi_color_order = co.value;
|
||||
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||||
if (dn || nl || co || ws) {
|
||||
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
|
||||
else if (dn) payload.wifi_driver_display_name = null;
|
||||
if (nl && nl.value !== '') {
|
||||
const n = parseInt(nl.value, 10);
|
||||
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
|
||||
else payload.wifi_driver_num_leds = null;
|
||||
} else if (nl) payload.wifi_driver_num_leds = null;
|
||||
if (co && co.value) payload.wifi_color_order = co.value;
|
||||
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||||
}
|
||||
const gob = document.getElementById('edit-group-output-brightness');
|
||||
if (gob && gob.value !== '') {
|
||||
const nb = parseInt(gob.value, 10);
|
||||
@@ -147,26 +152,6 @@ function collectGroupEditPayload() {
|
||||
return { gid, payload };
|
||||
}
|
||||
|
||||
function refreshEditGroupDebug() {
|
||||
const ta = document.getElementById('edit-group-debug');
|
||||
if (!ta) return;
|
||||
try {
|
||||
const { gid, payload } = collectGroupEditPayload();
|
||||
const loaded = window.__editGroupLoadedSnapshot;
|
||||
ta.value = JSON.stringify(
|
||||
{
|
||||
group_id: gid || null,
|
||||
loaded_from_server: loaded != null ? loaded : null,
|
||||
save_payload_preview: payload,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (e) {
|
||||
ta.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function syncGroupShareCheckboxFromDoc(g) {
|
||||
const cb = document.getElementById('edit-group-share-all-profiles');
|
||||
if (!cb) return;
|
||||
@@ -237,11 +222,6 @@ async function openEditGroupModal(groupId, groupDoc) {
|
||||
}
|
||||
}
|
||||
g = g || {};
|
||||
try {
|
||||
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
|
||||
} catch (e) {
|
||||
window.__editGroupLoadedSnapshot = g;
|
||||
}
|
||||
|
||||
if (idInput) idInput.value = groupId;
|
||||
if (nameInput) nameInput.value = g.name || '';
|
||||
@@ -259,7 +239,6 @@ async function openEditGroupModal(groupId, groupDoc) {
|
||||
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
|
||||
loadWifiFieldsFromGroup(g);
|
||||
syncGroupShareCheckboxFromDoc(g);
|
||||
refreshEditGroupDebug();
|
||||
if (modal) modal.classList.add('active');
|
||||
}
|
||||
|
||||
@@ -284,7 +263,7 @@ function renderGroupsList(groups) {
|
||||
if (ids.length === 0) {
|
||||
const p = document.createElement('p');
|
||||
p.className = 'muted-text';
|
||||
p.textContent = 'No groups yet. Create one to assign devices and Wi‑Fi defaults.';
|
||||
p.textContent = 'No groups yet.';
|
||||
container.appendChild(p);
|
||||
return;
|
||||
}
|
||||
@@ -292,22 +271,27 @@ function renderGroupsList(groups) {
|
||||
ids.forEach((gid) => {
|
||||
const g = groups[gid];
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
row.className = 'group-list-row';
|
||||
|
||||
const label = document.createElement('span');
|
||||
const info = document.createElement('div');
|
||||
info.className = 'group-list-row-info';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'group-list-row-title';
|
||||
const devs = Array.isArray(g.devices) ? g.devices : [];
|
||||
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.8em';
|
||||
meta.className = 'group-list-row-meta muted-text';
|
||||
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
|
||||
const scoped = rawPid != null && String(rawPid).trim() !== '';
|
||||
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
|
||||
|
||||
info.appendChild(label);
|
||||
info.appendChild(meta);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'group-list-row-actions';
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
@@ -392,17 +376,13 @@ function renderGroupsList(groups) {
|
||||
}
|
||||
});
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.style.flex = '1';
|
||||
left.style.minWidth = '0';
|
||||
left.appendChild(label);
|
||||
left.appendChild(meta);
|
||||
row.appendChild(left);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(brightBtn);
|
||||
row.appendChild(applyBtn);
|
||||
row.appendChild(identifyBtn);
|
||||
row.appendChild(delBtn);
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(brightBtn);
|
||||
actions.appendChild(applyBtn);
|
||||
actions.appendChild(identifyBtn);
|
||||
actions.appendChild(delBtn);
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -503,8 +483,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
editForm.addEventListener('input', () => refreshEditGroupDebug());
|
||||
editForm.addEventListener('change', () => refreshEditGroupDebug());
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const { gid, payload } = collectGroupEditPayload();
|
||||
@@ -540,7 +518,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} catch (_) {
|
||||
/* ignore push errors after save */
|
||||
}
|
||||
if (editModal) editModal.classList.remove('active');
|
||||
await loadGroupsModal();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -7,9 +7,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
|
||||
|
||||
if (helpBtn && helpModal) {
|
||||
helpBtn.addEventListener('click', () => {
|
||||
const openHelp = () => {
|
||||
helpModal.classList.add('active');
|
||||
});
|
||||
switchHelpTab('overview');
|
||||
};
|
||||
helpBtn.addEventListener('click', openHelp);
|
||||
}
|
||||
|
||||
if (helpCloseBtn && helpModal) {
|
||||
@@ -18,10 +20,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
const helpTabButtons = document.querySelectorAll('[data-help-tab]');
|
||||
const helpTabPanels = document.querySelectorAll('[data-help-panel]');
|
||||
|
||||
function switchHelpTab(tabId) {
|
||||
if (!tabId) tabId = 'overview';
|
||||
for (const btn of helpTabButtons) {
|
||||
const on = btn.getAttribute('data-help-tab') === tabId;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
}
|
||||
for (const panel of helpTabPanels) {
|
||||
const on = panel.getAttribute('data-help-panel') === tabId;
|
||||
panel.classList.toggle('active', on);
|
||||
panel.hidden = !on;
|
||||
}
|
||||
}
|
||||
|
||||
for (const btn of helpTabButtons) {
|
||||
btn.addEventListener('click', () => {
|
||||
switchHelpTab(btn.getAttribute('data-help-tab'));
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile main menu: forward clicks to existing header buttons
|
||||
if (mainMenuBtn && mainMenuDropdown) {
|
||||
mainMenuBtn.addEventListener('click', () => {
|
||||
mainMenuDropdown.classList.toggle('open');
|
||||
const zonesMenuDropdown = document.getElementById('zones-menu-dropdown');
|
||||
const zonesMenuBtn = document.getElementById('zones-menu-btn');
|
||||
if (zonesMenuDropdown) zonesMenuDropdown.classList.remove('open');
|
||||
if (zonesMenuBtn) zonesMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
mainMenuDropdown.addEventListener('click', (event) => {
|
||||
|
||||
15
src/static/images/help/audio.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
|
||||
<title>Audio beat detection</title>
|
||||
<rect width="520" height="200" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Audio Beat Detection</text>
|
||||
<text x="60" y="72" fill="#aaa" font-family="sans-serif" font-size="10">Input device</text>
|
||||
<rect x="60" y="78" width="200" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="94" fill="#bbb" font-family="sans-serif" font-size="10">monitor of Built-in</text>
|
||||
<rect x="60" y="112" width="120" height="36" rx="4" fill="#2a4a2a" stroke="#5a8f5a" stroke-width="2"/>
|
||||
<text x="72" y="128" fill="#afa" font-family="sans-serif" font-size="10">BPM</text>
|
||||
<text x="72" y="142" fill="#fff" font-family="sans-serif" font-size="14" font-weight="600">128</text>
|
||||
<rect x="200" y="120" width="200" height="8" rx="4" fill="#444"/>
|
||||
<rect x="200" y="120" width="140" height="8" rx="4" fill="#6a9ee2"/>
|
||||
<text x="200" y="148" fill="#888" font-family="sans-serif" font-size="9">Volume - live level meter - tap S to sync sequence</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
14
src/static/images/help/colour-palette.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
|
||||
<title>Colour Palette modal (concept)</title>
|
||||
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
|
||||
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
|
||||
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
|
||||
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
|
||||
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
|
||||
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
|
||||
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
|
||||
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
|
||||
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
|
||||
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
|
||||
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
18
src/static/images/help/devices.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
|
||||
<title>Devices modal</title>
|
||||
<rect width="520" height="200" fill="#1e1e1e"/>
|
||||
<rect x="40" y="20" width="440" height="160" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="48" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Devices</text>
|
||||
<rect x="60" y="58" width="72" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="72" y="74" fill="#eee" font-family="sans-serif" font-size="10">Identify</text>
|
||||
<rect x="140" y="58" width="96" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="150" y="74" fill="#eee" font-family="sans-serif" font-size="10">Update groups</text>
|
||||
<text x="60" y="104" fill="#aaa" font-family="sans-serif" font-size="10">MAC</text>
|
||||
<text x="200" y="104" fill="#aaa" font-family="sans-serif" font-size="10">Name</text>
|
||||
<text x="60" y="128" fill="#ddd" font-family="monospace" font-size="11">AA:BB:CC:DD:EE:01</text>
|
||||
<text x="200" y="128" fill="#ddd" font-family="sans-serif" font-size="11">lounge strip</text>
|
||||
<text x="380" y="128" fill="#888" font-family="sans-serif" font-size="10">Edit</text>
|
||||
<text x="60" y="156" fill="#ddd" font-family="monospace" font-size="11">AA:BB:CC:DD:EE:02</text>
|
||||
<text x="200" y="156" fill="#ddd" font-family="sans-serif" font-size="11">ceiling</text>
|
||||
<text x="60" y="176" fill="#888" font-family="sans-serif" font-size="10">ESP-NOW devices appear when they announce.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
17
src/static/images/help/groups.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 220" width="520" height="220">
|
||||
<title>Device groups modal</title>
|
||||
<rect width="520" height="220" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="188" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Device groups</text>
|
||||
<rect x="60" y="54" width="140" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="70" fill="#bbb" font-family="sans-serif" font-size="11">Group name</text>
|
||||
<rect x="210" y="54" width="56" height="24" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||
<text x="224" y="70" fill="#eee" font-family="sans-serif" font-size="11">Create</text>
|
||||
<text x="60" y="104" fill="#ccc" font-family="sans-serif" font-size="12">lounge lights</text>
|
||||
<text x="300" y="104" fill="#888" font-family="sans-serif" font-size="10">3 devices - Edit</text>
|
||||
<text x="60" y="132" fill="#ccc" font-family="sans-serif" font-size="12">dj booth</text>
|
||||
<text x="300" y="132" fill="#888" font-family="sans-serif" font-size="10">2 devices - Edit</text>
|
||||
<rect x="60" y="148" width="360" height="44" rx="4" fill="#252525" stroke="#444" stroke-width="1"/>
|
||||
<text x="72" y="168" fill="#aaa" font-family="sans-serif" font-size="10">Search devices to add...</text>
|
||||
<text x="72" y="184" fill="#888" font-family="sans-serif" font-size="9">Pick from list - Identify group</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
24
src/static/images/help/header-toolbar.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
|
||||
<title>Header: tab buttons and action bar</title>
|
||||
<rect width="820" height="108" fill="#1a1a1a"/>
|
||||
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
|
||||
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Zones</text>
|
||||
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
|
||||
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
|
||||
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
|
||||
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
|
||||
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
|
||||
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
|
||||
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Zones</text>
|
||||
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
|
||||
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
|
||||
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
|
||||
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
26
src/static/images/help/mobile-menu.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="mobile-menu-title">
|
||||
<title id="mobile-menu-title">Narrow screen: Menu aggregates header actions</title>
|
||||
<rect width="300" height="340" fill="#2e2e2e"/>
|
||||
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
|
||||
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="22" y="30" fill="#eee" font-family="sans-serif" font-size="12">Menu</text>
|
||||
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="86" y="30" fill="#ccc" font-family="sans-serif" font-size="11">tab</text>
|
||||
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
|
||||
<text x="142" y="30" fill="#fff" font-family="sans-serif" font-size="11">tab</text>
|
||||
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
|
||||
<text x="24" y="84" fill="#888" font-family="sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||
<g font-family="sans-serif" font-size="12" fill="#e8e8e8">
|
||||
<text x="24" y="108">Run mode</text>
|
||||
<text x="24" y="132">Profiles</text>
|
||||
<text x="24" y="156">Zones</text>
|
||||
<text x="24" y="180">Presets</text>
|
||||
<text x="24" y="204">Help</text>
|
||||
</g>
|
||||
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
|
||||
<text x="24" y="268" fill="#aaa" font-family="sans-serif" font-size="10">Content area - presets as on desktop</text>
|
||||
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="36" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
|
||||
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||
<text x="124" y="298" fill="#ddd" font-family="sans-serif" font-size="11">preset</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
13
src/static/images/help/patterns.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 180" width="520" height="180">
|
||||
<title>Patterns list</title>
|
||||
<rect width="520" height="180" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="148" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Patterns</text>
|
||||
<text x="60" y="72" fill="#ccc" font-family="sans-serif" font-size="12">pulse</text>
|
||||
<text x="300" y="72" fill="#888" font-family="sans-serif" font-size="10">delay 20-200 ms</text>
|
||||
<text x="60" y="98" fill="#ccc" font-family="sans-serif" font-size="12">rainbow</text>
|
||||
<text x="300" y="98" fill="#888" font-family="sans-serif" font-size="10">delay 10-80 ms</text>
|
||||
<text x="60" y="124" fill="#ccc" font-family="sans-serif" font-size="12">sparkle</text>
|
||||
<text x="300" y="124" fill="#888" font-family="sans-serif" font-size="10">delay 5-50 ms</text>
|
||||
<text x="60" y="152" fill="#888" font-family="sans-serif" font-size="9">Choose a pattern in the preset editor - n1-n8 depend on pattern.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
31
src/static/images/help/preset-editor.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
|
||||
<title>Preset editor modal (simplified)</title>
|
||||
<rect width="520" height="400" fill="#1e1e1e"/>
|
||||
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
|
||||
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
|
||||
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
|
||||
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
|
||||
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
|
||||
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
|
||||
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
|
||||
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
|
||||
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
|
||||
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
|
||||
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
|
||||
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
|
||||
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
|
||||
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
|
||||
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
|
||||
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
|
||||
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
|
||||
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
|
||||
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
|
||||
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
15
src/static/images/help/profiles.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
|
||||
<title>Profiles modal</title>
|
||||
<rect width="520" height="200" fill="#1e1e1e"/>
|
||||
<rect x="40" y="20" width="440" height="160" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="48" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Profiles</text>
|
||||
<rect x="60" y="62" width="48" height="26" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||
<text x="72" y="79" fill="#eee" font-family="sans-serif" font-size="11">Apply</text>
|
||||
<rect x="116" y="62" width="52" height="26" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="126" y="79" fill="#eee" font-family="sans-serif" font-size="11">Create</text>
|
||||
<text x="60" y="108" fill="#ccc" font-family="sans-serif" font-size="12">Garden party</text>
|
||||
<text x="360" y="108" fill="#888" font-family="sans-serif" font-size="10">Clone / Delete</text>
|
||||
<text x="60" y="136" fill="#ccc" font-family="sans-serif" font-size="12">House default</text>
|
||||
<text x="360" y="136" fill="#6a9ee2" font-family="sans-serif" font-size="10">active</text>
|
||||
<text x="60" y="168" fill="#888" font-family="sans-serif" font-size="10">Apply switches zones and presets for this profile.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
18
src/static/images/help/sequences.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
|
||||
<title>Sequence editor</title>
|
||||
<rect width="520" height="200" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Sequence</text>
|
||||
<text x="60" y="68" fill="#aaa" font-family="sans-serif" font-size="10">Lane 1 - lounge lights</text>
|
||||
<rect x="60" y="74" width="56" height="28" rx="3" fill="#4a3a6a" stroke="#7a6aaf" stroke-width="1"/>
|
||||
<text x="72" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 1</text>
|
||||
<rect x="122" y="74" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="134" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 2</text>
|
||||
<rect x="184" y="74" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="196" y="92" fill="#eee" font-family="sans-serif" font-size="9">step 3</text>
|
||||
<text x="60" y="124" fill="#aaa" font-family="sans-serif" font-size="10">Lane 2 - dj booth</text>
|
||||
<rect x="60" y="130" width="56" height="28" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="72" y="148" fill="#eee" font-family="sans-serif" font-size="9">step 1</text>
|
||||
<text x="300" y="44" fill="#888" font-family="sans-serif" font-size="9">Beat / Downbeat</text>
|
||||
<text x="60" y="176" fill="#888" font-family="sans-serif" font-size="9">Add lanes - assign presets per step - attach in zone editor.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
18
src/static/images/help/settings.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 200" width="520" height="200">
|
||||
<title>Settings modal</title>
|
||||
<rect width="520" height="200" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="168" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Settings</text>
|
||||
<rect x="60" y="52" width="56" height="24" rx="3" fill="#1a1a1a" stroke="#6a5acd" stroke-width="2"/>
|
||||
<text x="72" y="68" fill="#fff" font-family="sans-serif" font-size="10">Bridge</text>
|
||||
<rect x="122" y="52" width="64" height="24" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="132" y="68" fill="#ccc" font-family="sans-serif" font-size="10">LED Tool</text>
|
||||
<text x="60" y="100" fill="#aaa" font-family="sans-serif" font-size="10">USB serial</text>
|
||||
<rect x="60" y="106" width="160" height="22" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="121" fill="#bbb" font-family="sans-serif" font-size="9">/dev/ttyUSB0</text>
|
||||
<text x="60" y="144" fill="#aaa" font-family="sans-serif" font-size="10">Wi-Fi</text>
|
||||
<rect x="60" y="150" width="120" height="22" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||
<text x="68" y="165" fill="#bbb" font-family="sans-serif" font-size="9">Bridge-AP</text>
|
||||
<text x="240" y="121" fill="#6a9e6a" font-family="sans-serif" font-size="9">connected</text>
|
||||
<text x="240" y="165" fill="#888" font-family="sans-serif" font-size="9">LED Tool: deploy - flash - serial setup</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
35
src/static/images/help/tab-preset-strip.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
|
||||
<title>Main area: brightness and preset tiles</title>
|
||||
<defs>
|
||||
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
|
||||
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="220" fill="#2e2e2e"/>
|
||||
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
|
||||
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
|
||||
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
|
||||
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
|
||||
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
|
||||
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
|
||||
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
|
||||
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
|
||||
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
|
||||
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
|
||||
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
|
||||
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
|
||||
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
|
||||
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
|
||||
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
|
||||
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
18
src/static/images/help/zones.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 240" width="520" height="240">
|
||||
<title>Zones editor</title>
|
||||
<rect width="520" height="240" fill="#1e1e1e"/>
|
||||
<rect x="40" y="16" width="440" height="208" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||
<text x="60" y="44" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">Edit zone</text>
|
||||
<text x="60" y="72" fill="#aaa" font-family="sans-serif" font-size="10">Device groups on this zone</text>
|
||||
<rect x="60" y="78" width="100" height="22" rx="3" fill="#333" stroke="#666" stroke-width="1"/>
|
||||
<text x="72" y="93" fill="#ccc" font-family="sans-serif" font-size="10">lounge lights</text>
|
||||
<text x="60" y="118" fill="#aaa" font-family="sans-serif" font-size="10">Presets on this zone</text>
|
||||
<rect x="60" y="124" width="72" height="36" rx="4" fill="#3d3d3d" stroke="#666" stroke-width="1"/>
|
||||
<text x="72" y="147" fill="#eee" font-family="sans-serif" font-size="10">warm</text>
|
||||
<rect x="140" y="124" width="72" height="36" rx="4" fill="#3d3d3d" stroke="#666" stroke-width="1"/>
|
||||
<text x="148" y="147" fill="#eee" font-family="sans-serif" font-size="10">pulse</text>
|
||||
<text x="60" y="178" fill="#aaa" font-family="sans-serif" font-size="10">Sequences on this zone</text>
|
||||
<rect x="60" y="184" width="120" height="28" rx="4" fill="#2a4a3a" stroke="#5a8f6a" stroke-width="1"/>
|
||||
<text x="72" y="202" fill="#cfe" font-family="sans-serif" font-size="10">intro build</text>
|
||||
<text x="60" y="228" fill="#888" font-family="sans-serif" font-size="9">Drag presets to reorder - presets and sequences can share a zone.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -268,10 +268,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error((data && data.error) || 'Create failed');
|
||||
}
|
||||
alert(data.message || 'Pattern created.');
|
||||
resetCreateForm();
|
||||
if (patternEditorModal) {
|
||||
patternEditorModal.classList.remove('active');
|
||||
}
|
||||
await loadPatterns();
|
||||
} catch (e) {
|
||||
console.error('Create pattern failed:', e);
|
||||
@@ -408,13 +404,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
row.appendChild(label);
|
||||
|
||||
if (isFirmwareBuiltinPattern(patternName)) {
|
||||
const note = document.createElement('span');
|
||||
note.className = 'muted-text';
|
||||
note.style.fontSize = '0.85em';
|
||||
note.textContent = 'Built-in (no OTA module)';
|
||||
row.appendChild(note);
|
||||
} else {
|
||||
if (!isFirmwareBuiltinPattern(patternName)) {
|
||||
const sendBtn = document.createElement('button');
|
||||
sendBtn.className = 'btn btn-primary btn-small';
|
||||
sendBtn.textContent = 'Send';
|
||||
|
||||
@@ -269,7 +269,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetBackgroundInput = document.getElementById('preset-background-input');
|
||||
const presetBackgroundButton = document.getElementById('preset-background-btn');
|
||||
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
|
||||
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
|
||||
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
|
||||
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
|
||||
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
|
||||
@@ -447,16 +446,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetManualModeLabel) {
|
||||
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
|
||||
}
|
||||
if (presetManualModeHint) {
|
||||
if (!patternName || ok) {
|
||||
presetManualModeHint.style.display = 'none';
|
||||
presetManualModeHint.textContent = '';
|
||||
} else {
|
||||
presetManualModeHint.style.display = '';
|
||||
presetManualModeHint.textContent =
|
||||
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
presetManualModeInput.checked = false;
|
||||
}
|
||||
@@ -521,12 +510,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Get max colors for current pattern
|
||||
const maxColors = getMaxColors();
|
||||
const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : '';
|
||||
|
||||
|
||||
if (currentPresetColors.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
|
||||
empty.textContent = 'No colours yet.';
|
||||
presetColorsContainer.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -536,7 +524,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const info = document.createElement('p');
|
||||
info.className = 'muted-text';
|
||||
info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;';
|
||||
info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`;
|
||||
info.textContent = 'Maximum colours reached.';
|
||||
presetColorsContainer.appendChild(info);
|
||||
}
|
||||
|
||||
@@ -1200,12 +1188,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (preset && preset.name) || presetId;
|
||||
|
||||
const details = document.createElement('span');
|
||||
const pattern = preset && preset.pattern ? preset.pattern : '-';
|
||||
details.textContent = pattern;
|
||||
details.style.color = '#aaa';
|
||||
details.style.fontSize = '0.85em';
|
||||
|
||||
const editButton = document.createElement('button');
|
||||
editButton.className = 'btn btn-secondary btn-small';
|
||||
editButton.textContent = 'Edit';
|
||||
@@ -1235,26 +1217,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||||
});
|
||||
|
||||
const exportButton = document.createElement('button');
|
||||
exportButton.className = 'btn btn-secondary btn-small';
|
||||
exportButton.textContent = 'Export';
|
||||
exportButton.addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch(`/presets/${presetId}/export`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed');
|
||||
}
|
||||
const bundle = await response.json();
|
||||
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
|
||||
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
|
||||
} catch (error) {
|
||||
console.error('Export preset failed:', error);
|
||||
alert('Failed to export preset.');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'btn btn-danger btn-small';
|
||||
deleteButton.textContent = 'Delete';
|
||||
@@ -1282,9 +1244,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(details);
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(exportButton);
|
||||
row.appendChild(sendButton);
|
||||
row.appendChild(deleteButton);
|
||||
presetsList.appendChild(row);
|
||||
@@ -1415,22 +1375,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (zoneCheck.ok) {
|
||||
const zoneDoc = await zoneCheck.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(zoneDoc, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not verify zone content kind:', e);
|
||||
}
|
||||
|
||||
// Load all presets
|
||||
try {
|
||||
const response = await fetch('/presets', {
|
||||
@@ -1470,11 +1414,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
modal.id = 'add-preset-to-zone-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Add Preset to Zone</h2>
|
||||
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
|
||||
<div class="modal-head">
|
||||
<h2>Add Preset to Zone</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1485,7 +1431,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
|
||||
if (availableToAdd.length === 0) {
|
||||
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
|
||||
listContainer.innerHTML = '<p class="muted-text">No presets to add.</p>';
|
||||
} else {
|
||||
availableToAdd.forEach(presetId => {
|
||||
const preset = allPresets[presetId];
|
||||
@@ -1559,13 +1505,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize to flat array to check and update usage
|
||||
let flat = [];
|
||||
@@ -1686,11 +1625,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick Palette Color</h2>
|
||||
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||||
<div class="modal-head">
|
||||
<h2>Pick Palette Color</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
@@ -1755,11 +1696,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
modal.className = 'modal active modal-child-overlay';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<h2>Pick background colour</h2>
|
||||
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
|
||||
<div class="modal-head">
|
||||
<h2>Pick background colour</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
@@ -1866,33 +1809,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
|
||||
// Same device targeting as Try: per-preset zone groups when in a zone tab.
|
||||
const presetIdForSend = currentEditId || payload.name;
|
||||
const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend);
|
||||
|
||||
// Use saved preset from server response for sending
|
||||
const saved = await response.json().catch(() => null);
|
||||
if (saved && typeof saved === 'object') {
|
||||
if (currentEditId) {
|
||||
// PUT returns the preset object directly; use the existing ID
|
||||
await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
|
||||
} else {
|
||||
// POST returns { id: preset }
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
const [newId, presetData] = entries[0];
|
||||
await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
|
||||
}
|
||||
if (!currentEditId && saved && typeof saved === 'object') {
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
currentEditId = entries[0][0];
|
||||
}
|
||||
} else {
|
||||
// Fallback: send what we just built
|
||||
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
|
||||
}
|
||||
|
||||
await loadPresets();
|
||||
clearForm();
|
||||
closeEditor();
|
||||
|
||||
|
||||
// Reload zone presets if we're in a zone view
|
||||
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
|
||||
if (leftPanel) {
|
||||
@@ -2195,13 +2121,29 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre
|
||||
const selectedPresets = {};
|
||||
// Store selected preset payload per zone for beat-trigger reliability.
|
||||
const selectedPresetPayloads = {};
|
||||
// Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
|
||||
let presetUiMode = 'run';
|
||||
const PRESET_UI_MODE_STORAGE_KEY = 'led-controller-ui-mode';
|
||||
|
||||
function readStoredPresetUiMode() {
|
||||
try {
|
||||
const stored = localStorage.getItem(PRESET_UI_MODE_STORAGE_KEY);
|
||||
return stored === 'edit' ? 'edit' : 'run';
|
||||
} catch (_) {
|
||||
return 'run';
|
||||
}
|
||||
}
|
||||
|
||||
// Run vs Edit for zone preset strip (restored from localStorage on load)
|
||||
let presetUiMode = readStoredPresetUiMode();
|
||||
|
||||
const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run');
|
||||
|
||||
const setPresetUiMode = (mode) => {
|
||||
presetUiMode = mode === 'edit' ? 'edit' : 'run';
|
||||
try {
|
||||
localStorage.setItem(PRESET_UI_MODE_STORAGE_KEY, presetUiMode);
|
||||
} catch (_) {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
};
|
||||
|
||||
const updateUiModeToggleButtons = () => {
|
||||
@@ -2216,6 +2158,11 @@ const updateUiModeToggleButtons = () => {
|
||||
document.body.classList.toggle('preset-ui-edit', mode === 'edit');
|
||||
document.body.classList.toggle('preset-ui-run', mode === 'run');
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined' && document.body) {
|
||||
updateUiModeToggleButtons();
|
||||
}
|
||||
|
||||
// Track if we're currently dragging a preset
|
||||
let isDraggingPreset = false;
|
||||
|
||||
@@ -2273,13 +2220,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
throw new Error('This zone is for sequences only.');
|
||||
}
|
||||
|
||||
|
||||
// Store as 2D grid
|
||||
tabData.presets = presetGrid;
|
||||
// Also store as flat array for backward compatibility
|
||||
@@ -2372,12 +2313,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
const ck =
|
||||
typeof window.effectiveZoneContentKind === 'function'
|
||||
? window.effectiveZoneContentKind(tabData)
|
||||
: typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: 'presets';
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
@@ -2389,10 +2324,6 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
// It's a flat array, convert to grid
|
||||
presetGrid = arrayToGrid(presetGrid, 3);
|
||||
}
|
||||
if (ck === 'sequences') {
|
||||
presetGrid = [];
|
||||
}
|
||||
|
||||
if (!presetsResponse.ok) {
|
||||
throw new Error('Failed to load presets');
|
||||
}
|
||||
@@ -2474,18 +2405,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
tabData.sequence_ids.some((x) => x != null && String(x).trim());
|
||||
|
||||
if (flatPresets.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1'; // Span all columns
|
||||
if (ck === 'sequences') {
|
||||
if (!hasSeq) {
|
||||
empty.textContent =
|
||||
"No sequences on this zone yet. Open the zone's Edit menu to add one.";
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
empty.textContent =
|
||||
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
if (!hasSeq) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1';
|
||||
empty.textContent = 'No presets on this zone yet.';
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
@@ -2515,11 +2439,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window.appendZoneSequenceTiles === 'function' &&
|
||||
(typeof window.zoneAllowsSequences !== 'function' ||
|
||||
window.zoneAllowsSequences(tabData, zoneId))
|
||||
) {
|
||||
if (typeof window.appendZoneSequenceTiles === 'function') {
|
||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2760,14 +2680,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
throw new Error('Failed to load zone');
|
||||
}
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Normalize to flat array
|
||||
let flat = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
|
||||
@@ -13,6 +13,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
const isEditModeActive = () => {
|
||||
if (typeof window.getPresetUiMode === 'function') {
|
||||
return window.getPresetUiMode() === 'edit';
|
||||
}
|
||||
const toggle = document.querySelector('.ui-mode-toggle');
|
||||
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
|
||||
// Sequences: lanes (parallel preset chains); advance by audio beats or global simulated BPM.
|
||||
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
|
||||
|
||||
/** @type {'beat'|'downbeat'} */
|
||||
@@ -6,6 +6,8 @@ let sequenceSwitchWaitFor = 'beat';
|
||||
|
||||
let sequenceDebugEnabled = false;
|
||||
let sequenceSwitchSaveInFlight = false;
|
||||
/** When true (simulated BPM / audio off), downbeat is disabled and switch is beat-only. */
|
||||
let sequenceSwitchSimulatedMode = false;
|
||||
|
||||
async function loadSequenceSwitchWaitForFromServer() {
|
||||
try {
|
||||
@@ -49,32 +51,82 @@ function getSequenceSwitchWaitFor() {
|
||||
}
|
||||
|
||||
async function setSequenceSwitchWaitFor(waitFor) {
|
||||
if (sequenceSwitchSimulatedMode) {
|
||||
sequenceSwitchWaitFor = 'beat';
|
||||
updateSequenceSwitchToggleUI();
|
||||
return;
|
||||
}
|
||||
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
|
||||
updateSequenceSwitchToggleUI();
|
||||
await persistSequenceSwitchWaitFor();
|
||||
}
|
||||
|
||||
function updateSequenceSwitchToggleUI() {
|
||||
const mode = getSequenceSwitchWaitFor();
|
||||
const mode = sequenceSwitchSimulatedMode ? 'beat' : getSequenceSwitchWaitFor();
|
||||
const ariaLabels = {
|
||||
beat: 'Switch sequence on beat',
|
||||
downbeat: 'Switch sequence on downbeat',
|
||||
};
|
||||
document.documentElement.classList.toggle(
|
||||
'simulated-bpm-mode',
|
||||
sequenceSwitchSimulatedMode,
|
||||
);
|
||||
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
|
||||
wrap.hidden = sequenceSwitchSimulatedMode;
|
||||
wrap.setAttribute('aria-hidden', sequenceSwitchSimulatedMode ? 'true' : 'false');
|
||||
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
|
||||
});
|
||||
if (sequenceSwitchSimulatedMode) {
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.disabled = false;
|
||||
btn.removeAttribute('aria-disabled');
|
||||
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
|
||||
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
|
||||
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
|
||||
});
|
||||
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
|
||||
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
|
||||
btn.title =
|
||||
mode === 'downbeat'
|
||||
? 'When starting a sequence: wait for downbeat'
|
||||
: 'When starting a sequence: wait for beat';
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {boolean} simulated */
|
||||
function setSequenceSwitchSimulatedMode(simulated) {
|
||||
const next = !!simulated;
|
||||
if (next === sequenceSwitchSimulatedMode) {
|
||||
if (next) updateSequenceSwitchToggleUI();
|
||||
return;
|
||||
}
|
||||
sequenceSwitchSimulatedMode = next;
|
||||
if (next) {
|
||||
sequenceSwitchWaitFor = 'beat';
|
||||
updateSequenceSwitchToggleUI();
|
||||
void persistSequenceSwitchWaitFor();
|
||||
return;
|
||||
}
|
||||
void loadSequenceSwitchWaitForFromServer().then(() => updateSequenceSwitchToggleUI());
|
||||
}
|
||||
|
||||
async function syncSequenceSwitchSimulatedModeFromStatus() {
|
||||
try {
|
||||
const res = await fetch('/api/audio/status', { cache: 'no-store' });
|
||||
const data = await res.json();
|
||||
const simulated = !!(data && data.status && data.status.bpm_simulated);
|
||||
setSequenceSwitchSimulatedMode(simulated);
|
||||
} catch {
|
||||
setSequenceSwitchSimulatedMode(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function initSequenceSwitchToggle() {
|
||||
await syncSequenceSwitchSimulatedModeFromStatus();
|
||||
await loadSequenceSwitchWaitForFromServer();
|
||||
updateSequenceSwitchToggleUI();
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
if (sequenceSwitchSimulatedMode) return;
|
||||
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
|
||||
});
|
||||
});
|
||||
@@ -82,7 +134,7 @@ async function initSequenceSwitchToggle() {
|
||||
|
||||
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
|
||||
function applySequenceSwitchWaitFromServer(raw) {
|
||||
if (sequenceSwitchSaveInFlight) return;
|
||||
if (sequenceSwitchSaveInFlight || sequenceSwitchSimulatedMode) return;
|
||||
let mode = 'beat';
|
||||
if (raw === 'downbeat') mode = 'downbeat';
|
||||
else if (raw !== 'beat' && raw !== 'phrase') return;
|
||||
@@ -95,49 +147,6 @@ function seqDebugEnabled() {
|
||||
return sequenceDebugEnabled;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let sequenceBpmPollTimer = null;
|
||||
|
||||
function stopSequenceEditorBpmPoll() {
|
||||
if (sequenceBpmPollTimer) {
|
||||
clearInterval(sequenceBpmPollTimer);
|
||||
sequenceBpmPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSequenceEditorBpmDisplay() {
|
||||
const live = document.getElementById('sequence-editor-bpm-live');
|
||||
const panel = document.getElementById('sequence-editor-beats-panel');
|
||||
if (!live || !panel) return;
|
||||
try {
|
||||
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
|
||||
const j = res.ok ? await res.json() : {};
|
||||
const st = j && j.status ? j.status : {};
|
||||
const running = !!st.running;
|
||||
const bpmRaw = st.bpm;
|
||||
const bpm =
|
||||
typeof bpmRaw === 'number' && Number.isFinite(bpmRaw)
|
||||
? bpmRaw
|
||||
: typeof bpmRaw === 'string' && bpmRaw.trim()
|
||||
? parseFloat(bpmRaw)
|
||||
: NaN;
|
||||
if (!running) {
|
||||
live.textContent =
|
||||
'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.';
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) {
|
||||
live.textContent = 'Audio detector running; BPM will appear after a few beats.';
|
||||
return;
|
||||
}
|
||||
const msPer = Math.round(60000 / bpm);
|
||||
const rounded = Math.round(bpm * 10) / 10;
|
||||
live.textContent = `Current estimate: ${rounded} BPM (~${msPer} ms per beat).`;
|
||||
} catch (_) {
|
||||
live.textContent = 'Could not read audio status.';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {boolean} [clearSequenceTileSelection] When false, leaves the active highlight on sequence tiles (used when restarting playback so the click handler’s selection is not cleared). */
|
||||
async function stopZoneSequencePlayback(clearSequenceTileSelection = true) {
|
||||
// Clear selection **before** awaiting fetch so overlapping stop() calls cannot finish out of
|
||||
@@ -157,6 +166,9 @@ async function stopZoneSequencePlayback(clearSequenceTileSelection = true) {
|
||||
} catch (e) {
|
||||
console.warn('Sequence stop:', e);
|
||||
}
|
||||
if (typeof window.ledControllerSequencePlaybackChanged === 'function') {
|
||||
window.ledControllerSequencePlaybackChanged(false);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSequenceLanes(doc) {
|
||||
@@ -261,12 +273,6 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sequence-lane-groups-wrap';
|
||||
wrap.style.cssText = 'margin-bottom:0.6rem;';
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'muted-text';
|
||||
hint.style.fontSize = '0.85em';
|
||||
hint.style.marginBottom = '0.35rem';
|
||||
hint.textContent = 'Only checked groups are used on this lane';
|
||||
wrap.appendChild(hint);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sequence-lane-groups';
|
||||
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
|
||||
@@ -343,13 +349,7 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
|
||||
async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
|
||||
// Do not call stop here: server start() already stops any prior run. A fire-and-forget
|
||||
// client stop can reorder after play and clear the new session (same tile re-click bug).
|
||||
let bodyBpm;
|
||||
if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) {
|
||||
const n = parseInt(String(sequenceDoc.simulated_bpm), 10);
|
||||
if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n));
|
||||
}
|
||||
const body = { zone_id: String(zoneId) };
|
||||
if (bodyBpm != null) body.simulated_bpm = bodyBpm;
|
||||
const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
@@ -360,7 +360,9 @@ async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err && err.error) || res.statusText);
|
||||
}
|
||||
console.log(Number(sequenceId));
|
||||
if (typeof window.ledControllerSequencePlaybackChanged === 'function') {
|
||||
window.ledControllerSequencePlaybackChanged(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSequencesMap() {
|
||||
@@ -510,13 +512,6 @@ async function addSequenceToTab(sequenceId, zoneId) {
|
||||
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!tabResponse.ok) throw new Error('Failed to load zone');
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(tabData.sequence_ids) ? tabData.sequence_ids.map(String) : [];
|
||||
if (list.includes(String(sequenceId))) {
|
||||
alert('Sequence is already on this zone.');
|
||||
@@ -579,15 +574,6 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
|
||||
if (!zoneRes.ok) throw new Error('zone');
|
||||
const zone = await zoneRes.json();
|
||||
if (
|
||||
typeof window.zoneAllowsSequences === 'function' &&
|
||||
!window.zoneAllowsSequences(zone, zoneId)
|
||||
) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
return;
|
||||
}
|
||||
const onZone = Array.isArray(zone.sequence_ids) ? zone.sequence_ids.map(String) : [];
|
||||
const seqMap = await fetchSequencesMap();
|
||||
const onSet = new Set(onZone);
|
||||
@@ -600,11 +586,7 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
const sdoc = seqMap[sid] || {};
|
||||
const name = sdoc.name || sid;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.justifyContent = 'space-between';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.className = 'profiles-row edit-zone-item-row';
|
||||
const span = document.createElement('span');
|
||||
span.textContent = `${name} — ${sid}`;
|
||||
const rm = document.createElement('button');
|
||||
@@ -626,7 +608,7 @@ async function refreshEditTabSequencesUi(zoneId) {
|
||||
const available = allIds.filter((id) => !onSet.has(String(id)));
|
||||
if (!available.length) {
|
||||
addEl.innerHTML =
|
||||
'<span class="muted-text">No sequences to add. Create one in Sequences or all are already on this zone.</span>';
|
||||
'<span class="muted-text">No sequences to add.</span>';
|
||||
} else {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'zone-devices-add profiles-actions';
|
||||
@@ -928,20 +910,10 @@ function collectLanesFromEditor() {
|
||||
return { lanes, lanes_group_ids };
|
||||
}
|
||||
|
||||
function syncSequenceBeatsPanel() {
|
||||
const panel = document.getElementById('sequence-editor-beats-panel');
|
||||
stopSequenceEditorBpmPoll();
|
||||
if (panel) {
|
||||
void refreshSequenceEditorBpmDisplay();
|
||||
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSequenceEditor(sequenceId, existing) {
|
||||
sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null;
|
||||
const modal = document.getElementById('sequence-editor-modal');
|
||||
const nameInput = document.getElementById('sequence-editor-name');
|
||||
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
|
||||
const lanesHost = document.getElementById('sequence-editor-lanes');
|
||||
if (!modal || !nameInput || !lanesHost) return;
|
||||
|
||||
@@ -989,12 +961,6 @@ async function openSequenceEditor(sequenceId, existing) {
|
||||
doc = {};
|
||||
}
|
||||
nameInput.value = doc.name || '';
|
||||
if (simBpmInput) {
|
||||
const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10);
|
||||
const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120;
|
||||
simBpmInput.value = String(clamped);
|
||||
}
|
||||
syncSequenceBeatsPanel();
|
||||
|
||||
const lanes = normalizeSequenceLanes(doc);
|
||||
lanesHost.innerHTML = '';
|
||||
@@ -1032,7 +998,6 @@ function resolveZoneIdForPresetStripRefresh() {
|
||||
|
||||
async function saveSequenceEditor() {
|
||||
const nameInput = document.getElementById('sequence-editor-name');
|
||||
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
|
||||
const { lanes, lanes_group_ids } = collectLanesFromEditor();
|
||||
const idxs = [];
|
||||
lanes.forEach((l, i) => {
|
||||
@@ -1044,18 +1009,12 @@ async function saveSequenceEditor() {
|
||||
}
|
||||
const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id));
|
||||
const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : []));
|
||||
let simulated_bpm = 120;
|
||||
if (simBpmInput && simBpmInput.value) {
|
||||
const n = parseInt(String(simBpmInput.value).trim(), 10);
|
||||
if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n));
|
||||
}
|
||||
const payload = {
|
||||
name: nameInput ? nameInput.value.trim() : '',
|
||||
lanes: nonEmpty,
|
||||
lanes_group_ids: nonEmptyLg,
|
||||
group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [],
|
||||
advance_mode: 'beats',
|
||||
simulated_bpm,
|
||||
loop: true,
|
||||
steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
|
||||
};
|
||||
@@ -1081,9 +1040,16 @@ async function saveSequenceEditor() {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err && err.error) || res.statusText);
|
||||
}
|
||||
const created = await res.json().catch(() => null);
|
||||
if (created && typeof created === 'object') {
|
||||
const entries = Object.entries(created);
|
||||
if (entries.length > 0) {
|
||||
sequenceEditorId = String(entries[0][0]);
|
||||
const edDel = document.getElementById('sequence-editor-delete-btn');
|
||||
if (edDel) edDel.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
}
|
||||
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
|
||||
stopSequenceEditorBpmPoll();
|
||||
await loadSequencesModalList();
|
||||
const zid = resolveZoneIdForPresetStripRefresh();
|
||||
if (zid && typeof window.refreshEditTabSequencesUi === 'function') {
|
||||
@@ -1107,7 +1073,6 @@ async function deleteCurrentSequence() {
|
||||
if (!res.ok) throw new Error('Delete failed');
|
||||
const edModal = document.getElementById('sequence-editor-modal');
|
||||
if (edModal) edModal.classList.remove('active');
|
||||
stopSequenceEditorBpmPoll();
|
||||
sequenceEditorId = null;
|
||||
await loadSequencesModalList();
|
||||
const zid = resolveZoneIdForPresetStripRefresh();
|
||||
@@ -1151,7 +1116,7 @@ async function loadSequencesModalList() {
|
||||
});
|
||||
listEl.innerHTML = '';
|
||||
if (!ids.length) {
|
||||
listEl.innerHTML = '<p class="muted-text">No sequences yet. Click Add.</p>';
|
||||
listEl.innerHTML = '<p class="muted-text">No sequences yet.</p>';
|
||||
return;
|
||||
}
|
||||
ids.forEach((id) => {
|
||||
@@ -1164,37 +1129,19 @@ async function loadSequencesModalList() {
|
||||
const nSteps = ln.reduce((a, l) => a + l.length, 0);
|
||||
const nLanes = ln.filter((l) => l.length > 0).length || 1;
|
||||
title.textContent = `${doc.name || id} — ${nLanes} lane(s), ${nSteps} step(s)`;
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.type = 'button';
|
||||
exportBtn.className = 'btn btn-secondary btn-small';
|
||||
exportBtn.textContent = 'Export';
|
||||
exportBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch(`/sequences/${id}/export`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const bundle = await response.json();
|
||||
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
|
||||
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Failed to export sequence.');
|
||||
}
|
||||
});
|
||||
const edit = document.createElement('button');
|
||||
edit.type = 'button';
|
||||
edit.className = 'btn btn-secondary btn-small';
|
||||
edit.textContent = 'Edit';
|
||||
edit.addEventListener('click', () => openSequenceEditor(id, doc));
|
||||
row.appendChild(title);
|
||||
row.appendChild(exportBtn);
|
||||
row.appendChild(edit);
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
|
||||
window.setSequenceSwitchSimulatedMode = setSequenceSwitchSimulatedMode;
|
||||
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
|
||||
/** @param {boolean} on */
|
||||
window.setSequenceDebug = function setSequenceDebug(on) {
|
||||
@@ -1227,33 +1174,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
openSequenceEditor(null, null);
|
||||
});
|
||||
}
|
||||
const importSeqBtn = document.getElementById('import-sequence-btn');
|
||||
if (importSeqBtn) {
|
||||
importSeqBtn.addEventListener('click', async () => {
|
||||
const text = await window.pickJsonFile();
|
||||
if (!text) return;
|
||||
const bundle = window.parseJsonFileText(text);
|
||||
if (!bundle || bundle.kind !== 'sequence') {
|
||||
alert('Invalid sequence bundle file.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/sequences/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ bundle }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Import failed');
|
||||
}
|
||||
await loadSequencesModalList();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(e.message || 'Failed to import sequence.');
|
||||
}
|
||||
});
|
||||
}
|
||||
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
|
||||
if (openPresetsFromSeq) {
|
||||
openPresetsFromSeq.addEventListener('click', () => {
|
||||
@@ -1268,7 +1188,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const edDel = document.getElementById('sequence-editor-delete-btn');
|
||||
if (edClose) {
|
||||
edClose.addEventListener('click', () => {
|
||||
stopSequenceEditorBpmPoll();
|
||||
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,21 +24,6 @@ body {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.hex-address-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input.hex-addr-box {
|
||||
width: 1.35rem;
|
||||
padding: 0.25rem 0.1rem;
|
||||
text-align: center;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.device-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
@@ -46,13 +31,6 @@ input.hex-addr-box {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.device-field-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.device-row-mac {
|
||||
font-size: 0.82em;
|
||||
color: #b0b0b0;
|
||||
@@ -125,6 +103,68 @@ header h1 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.zones-menu-mobile {
|
||||
display: none;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zones-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0;
|
||||
display: none;
|
||||
min-width: 10rem;
|
||||
max-width: min(16rem, calc(100vw - 1rem));
|
||||
max-height: min(50vh, 20rem);
|
||||
overflow-y: auto;
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.zones-menu-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zones-menu-item {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.zones-menu-item:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.zones-menu-item.active {
|
||||
background-color: #6a5acd;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zones-menu-empty {
|
||||
padding: 0.45rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#zones-menu-btn {
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: none;
|
||||
position: relative;
|
||||
@@ -229,6 +269,32 @@ header h1 {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audio-top-indicator.audio-simulated .audio-top-bpm-value,
|
||||
#audio-modal-beat-sync.audio-simulated .audio-top-indicator-value {
|
||||
color: #e6c200;
|
||||
}
|
||||
|
||||
.audio-beat-sync-btn.flash-simulated,
|
||||
.audio-top-beat-sync.flash-simulated {
|
||||
background-color: #5a4a00;
|
||||
border-color: #e6c200;
|
||||
}
|
||||
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-indicator-value,
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-indicator-label,
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout,
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout::before,
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase,
|
||||
.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase::before,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-indicator-value,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-indicator-label,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-beat-readout,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-beat-readout::before,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-bar-phase,
|
||||
.audio-top-beat-sync.flash-simulated .audio-top-bar-phase::before {
|
||||
color: #fff9c4;
|
||||
}
|
||||
|
||||
.audio-beat-sync-btn:disabled,
|
||||
.audio-top-beat-sync:disabled {
|
||||
cursor: default;
|
||||
@@ -1174,6 +1240,10 @@ body.preset-ui-run .edit-mode-only {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html.simulated-bpm-mode .seq-switch-toggle-wrap {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-side-label {
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
@@ -1444,6 +1514,29 @@ body.preset-ui-run .edit-mode-only {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-head h2 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-top-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-content label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
@@ -1504,7 +1597,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
|
||||
/* On mobile, hide header buttons; all actions (including Zones) are in the Menu dropdown */
|
||||
.header-actions {
|
||||
display: none;
|
||||
}
|
||||
@@ -1540,17 +1633,28 @@ body.preset-ui-run .edit-mode-only {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.zones-menu-mobile {
|
||||
display: flex;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.zones-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header-end {
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.header-end .audio-top-indicator {
|
||||
@@ -1569,12 +1673,6 @@ body.preset-ui-run .edit-mode-only {
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.zones-container {
|
||||
padding: 0.35rem 0 0;
|
||||
border-bottom: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -1689,6 +1787,39 @@ body.preset-ui-run .edit-mode-only {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.group-list-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.group-list-row-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.group-list-row-title {
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.group-list-row-meta {
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-list-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zone-modal-create-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -1774,6 +1905,65 @@ body.preset-ui-run .edit-mode-only {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zone-device-add-picker {
|
||||
flex: 1 1 100%;
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.zone-device-add-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.zone-device-add-search:focus {
|
||||
outline: none;
|
||||
border-color: #6a5acd;
|
||||
}
|
||||
|
||||
.zone-device-add-results {
|
||||
max-height: 10rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.zone-device-add-results-empty {
|
||||
padding: 0.5rem 0.6rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.zone-device-add-result {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.zone-device-add-result:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.zone-device-add-result:hover,
|
||||
.zone-device-add-result:focus-visible {
|
||||
background-color: #4a4a4a;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.zone-devices-add {
|
||||
margin-top: 0;
|
||||
flex-wrap: wrap;
|
||||
@@ -1791,6 +1981,11 @@ body.preset-ui-run .edit-mode-only {
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.edit-zone-presets-scroll .edit-zone-item-row {
|
||||
padding: 0.25rem 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
/* Hide any text content in palette rows - only show color swatches */
|
||||
#palette-container .profiles-row {
|
||||
font-size: 0; /* Hide any text nodes */
|
||||
@@ -1871,16 +2066,202 @@ body.preset-ui-run .edit-mode-only {
|
||||
}
|
||||
}
|
||||
/* Help modal readability */
|
||||
#help-modal .modal-content {
|
||||
max-width: 720px;
|
||||
#help-modal .modal-content,
|
||||
#help-modal .help-modal-content {
|
||||
max-width: 840px;
|
||||
width: 95vw;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#help-modal .modal-content h2 {
|
||||
#help-modal .modal-head {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
#help-modal .help-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
#help-modal .help-tab-btn {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
#help-modal .help-tab-btn:hover {
|
||||
color: #fff;
|
||||
border-color: #6a5acd;
|
||||
}
|
||||
#help-modal .help-tab-btn.active {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-color: #6a5acd;
|
||||
border-bottom-color: #1a1a1a;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
#help-modal .help-tab-panel:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
#help-modal .help-tab-panel {
|
||||
max-height: min(70vh, 640px);
|
||||
overflow-y: auto;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
#help-modal .help-ui-preview {
|
||||
margin: 0 0 1rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 8px;
|
||||
background: #1a1a1a;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
#help-modal .help-ui-preview-caption {
|
||||
margin: -0.5rem 0 1rem;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
}
|
||||
#help-modal .help-ui-preview .help-preview-surface {
|
||||
background-color: #2e2e2e;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
#help-modal .help-ui-preview .modal-content {
|
||||
position: static;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
#help-modal .help-ui-preview .modal-head {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
#help-modal .help-ui-preview .profiles-list {
|
||||
max-height: none;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
#help-modal .help-ui-preview .modal-actions {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
#help-modal .help-ui-preview--header .help-preview-header {
|
||||
background-color: #1a1a1a;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 2px solid #4a4a4a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
#help-modal .help-ui-preview--strip .zone-content {
|
||||
padding: 0.5rem 0.75rem;
|
||||
max-height: 11rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
#help-modal .help-ui-preview .help-preview-presets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(5rem, auto);
|
||||
column-gap: 0.3rem;
|
||||
row-gap: 0.3rem;
|
||||
width: 100%;
|
||||
}
|
||||
#help-modal .help-ui-preview--mobile {
|
||||
max-width: 220px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
#help-modal .help-ui-preview--mobile .help-preview-mobile-bar {
|
||||
background-color: #1a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
}
|
||||
#help-modal .help-ui-preview--mobile .main-menu-dropdown {
|
||||
display: block;
|
||||
position: static;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
#help-modal .help-ui-preview .preset-colors-container {
|
||||
min-height: 5rem;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.5rem;
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#help-modal .help-ui-preview .help-preview-color-swatch {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #4a4a4a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#help-modal .help-ui-preview .help-preview-p-badge {
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: -6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: #3f51b5;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
#help-modal .help-ui-preview .settings-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin: 0 0 0.75rem;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
#help-modal .help-ui-preview .settings-tab-btn {
|
||||
background: #3a3a3a;
|
||||
color: #ccc;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px 4px 0 0;
|
||||
padding: 0.45rem 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#help-modal .help-ui-preview .settings-tab-btn.active {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
border-color: #6a5acd;
|
||||
}
|
||||
#help-modal .help-ui-preview .settings-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
#help-modal .help-ui-preview select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background-color: #2e2e2e;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
#help-modal .help-ui-preview .profiles-actions input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
#help-modal .modal-content h3 {
|
||||
margin-top: 1.25rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
@@ -2036,10 +2417,6 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#settings-modal .settings-led-tool-intro {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
#settings-modal .settings-led-tool-iframe {
|
||||
width: 100%;
|
||||
height: min(75vh, 720px);
|
||||
|
||||
@@ -22,6 +22,101 @@ function prepareZoneDevicesPanel(containerEl) {
|
||||
return { listEl, addSlot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search field + scrollable filtered list for picking an item to add.
|
||||
*/
|
||||
function createSearchableAddPicker({
|
||||
entries,
|
||||
excludeIds,
|
||||
labelFor,
|
||||
searchTextFor,
|
||||
onPick,
|
||||
placeholder = 'Search…',
|
||||
emptyMessage = 'No matches.',
|
||||
noItemsMessage = 'Nothing to add.',
|
||||
}) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'zone-device-add-picker';
|
||||
|
||||
const excluded = excludeIds || new Set();
|
||||
const available = (entries || []).filter(([id]) => !excluded.has(id));
|
||||
|
||||
if (!available.length) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'muted-text';
|
||||
empty.textContent = noItemsMessage;
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const search = document.createElement('input');
|
||||
search.type = 'search';
|
||||
search.className = 'zone-device-add-search';
|
||||
search.placeholder = placeholder;
|
||||
search.setAttribute('aria-label', placeholder);
|
||||
|
||||
const results = document.createElement('div');
|
||||
results.className = 'zone-device-add-results';
|
||||
results.setAttribute('role', 'listbox');
|
||||
|
||||
const filterAvailable = (query) => {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
return available.filter(([id, item]) => {
|
||||
if (!q) return true;
|
||||
const text = searchTextFor(id, item);
|
||||
return String(text).toLowerCase().includes(q);
|
||||
});
|
||||
};
|
||||
|
||||
const pickEntry = (id, item) => {
|
||||
onPick(id, item);
|
||||
search.value = '';
|
||||
renderResults('');
|
||||
};
|
||||
|
||||
const renderResults = (query) => {
|
||||
results.innerHTML = '';
|
||||
const filtered = filterAvailable(query);
|
||||
|
||||
if (!filtered.length) {
|
||||
const none = document.createElement('div');
|
||||
none.className = 'zone-device-add-results-empty muted-text';
|
||||
none.textContent = emptyMessage;
|
||||
results.appendChild(none);
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(([id, item]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'zone-device-add-result';
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.textContent = labelFor(id, item);
|
||||
btn.addEventListener('click', () => pickEntry(id, item));
|
||||
results.appendChild(btn);
|
||||
});
|
||||
};
|
||||
|
||||
search.addEventListener('input', () => renderResults(search.value));
|
||||
search.addEventListener('focus', () => renderResults(search.value));
|
||||
|
||||
search.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const filtered = filterAvailable(search.value);
|
||||
if (filtered.length === 1) {
|
||||
event.preventDefault();
|
||||
pickEntry(filtered[0][0], filtered[0][1]);
|
||||
}
|
||||
});
|
||||
|
||||
renderResults('');
|
||||
|
||||
wrap.appendChild(search);
|
||||
wrap.appendChild(results);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
|
||||
window.createSearchableAddPicker = createSearchableAddPicker;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,9 @@ function sendZoneBrightness(zoneId, value) {
|
||||
}
|
||||
|
||||
const isEditModeActive = () => {
|
||||
if (typeof window.getPresetUiMode === 'function') {
|
||||
return window.getPresetUiMode() === 'edit';
|
||||
}
|
||||
const toggle = document.querySelector('.ui-mode-toggle');
|
||||
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||
};
|
||||
@@ -534,27 +537,30 @@ function effectiveZoneContentKind(zoneDoc) {
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||
void zoneDoc;
|
||||
void zoneId;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||
void zoneDoc;
|
||||
void zoneId;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyZoneContentKindEditModal(kind) {
|
||||
function applyZoneContentKindEditModal(_kind) {
|
||||
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||
const seqBlock = document.getElementById('edit-zone-block-sequences');
|
||||
const typeLabel = document.getElementById('edit-zone-type-label');
|
||||
const vis = (el, show) => {
|
||||
if (el) el.style.display = show ? '' : 'none';
|
||||
};
|
||||
const k = kind === 'sequences' ? 'sequences' : 'presets';
|
||||
if (typeLabel) typeLabel.style.display = 'none';
|
||||
vis(groupsBlock, true);
|
||||
vis(presetsBlock, k === 'presets');
|
||||
vis(seqBlock, k === 'sequences');
|
||||
vis(presetsBlock, true);
|
||||
vis(seqBlock, true);
|
||||
}
|
||||
|
||||
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||
@@ -632,6 +638,52 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
renderZonesMenuMobile(tabs, tabOrder, currentZoneId);
|
||||
}
|
||||
|
||||
function renderZonesMenuMobile(tabs, tabOrder, currentZoneId) {
|
||||
const dropdown = document.getElementById('zones-menu-dropdown');
|
||||
const menuBtn = document.getElementById('zones-menu-btn');
|
||||
if (!dropdown) return;
|
||||
|
||||
if (!tabOrder || tabOrder.length === 0) {
|
||||
dropdown.innerHTML = '<p class="muted-text zones-menu-empty">No zones</p>';
|
||||
if (menuBtn) menuBtn.textContent = 'Zones';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (const zoneId of tabOrder) {
|
||||
const zone = tabs[zoneId];
|
||||
if (!zone) continue;
|
||||
const activeClass = String(zoneId) === String(currentZoneId) ? ' active' : '';
|
||||
const disp = zone.name || `Zone ${zoneId}`;
|
||||
html += `
|
||||
<button type="button" class="zones-menu-item${activeClass}"
|
||||
data-zone-id="${zoneId}" role="menuitem">
|
||||
${escapeHtmlAttr(disp)}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
dropdown.innerHTML = html;
|
||||
|
||||
if (menuBtn) {
|
||||
const cur = tabs[currentZoneId];
|
||||
menuBtn.textContent = cur ? (cur.name || `Zone ${currentZoneId}`) : 'Zones';
|
||||
}
|
||||
}
|
||||
|
||||
function syncZonesMenuSelection(zoneId) {
|
||||
document.querySelectorAll('.zones-menu-item').forEach((item) => {
|
||||
item.classList.toggle('active', item.dataset.zoneId === String(zoneId));
|
||||
});
|
||||
const menuBtn = document.getElementById('zones-menu-btn');
|
||||
const activeItem = document.querySelector(
|
||||
`.zones-menu-item[data-zone-id="${zoneId}"]`,
|
||||
);
|
||||
if (menuBtn && activeItem) {
|
||||
menuBtn.textContent = activeItem.textContent.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Render tabs list in modal (like profiles)
|
||||
@@ -673,14 +725,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
||||
label.style.color = "#FFD700";
|
||||
}
|
||||
|
||||
const applyButton = document.createElement("button");
|
||||
applyButton.className = "btn btn-secondary btn-small";
|
||||
applyButton.textContent = "Select";
|
||||
applyButton.addEventListener("click", async () => {
|
||||
await selectZone(zoneId);
|
||||
document.getElementById('zones-modal').classList.remove('active');
|
||||
});
|
||||
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "btn btn-secondary btn-small";
|
||||
editButton.textContent = "Edit";
|
||||
@@ -771,7 +815,6 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
||||
});
|
||||
|
||||
row.appendChild(label);
|
||||
row.appendChild(applyButton);
|
||||
if (editMode) {
|
||||
row.appendChild(editButton);
|
||||
row.appendChild(cloneButton);
|
||||
@@ -819,11 +862,12 @@ async function selectZone(zoneId) {
|
||||
document.querySelectorAll('.zone-button').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
const btn = document.querySelector(`[data-zone-id="${zoneId}"]`);
|
||||
const btn = document.querySelector(`#zones-list .zone-button[data-zone-id="${zoneId}"]`);
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
syncZonesMenuSelection(zoneId);
|
||||
|
||||
// Set as current zone
|
||||
await setCurrentZone(zoneId);
|
||||
// Load zone content
|
||||
@@ -931,12 +975,6 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
if (!zoneAllowsPresets(tabData, zoneId)) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
return;
|
||||
}
|
||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||
|
||||
@@ -960,12 +998,9 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
for (const presetId of inTabIds) {
|
||||
const preset = allPresets[presetId] || {};
|
||||
const name = preset.name || presetId;
|
||||
const block = document.createElement("div");
|
||||
block.style.cssText =
|
||||
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
|
||||
const top = makeRow();
|
||||
const row = makeRow();
|
||||
row.className = "profiles-row edit-zone-item-row";
|
||||
const label = document.createElement("span");
|
||||
label.style.fontWeight = "600";
|
||||
label.textContent = name;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
@@ -977,11 +1012,9 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
await window.removePresetFromTab(zoneId, presetId);
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
});
|
||||
top.appendChild(label);
|
||||
top.appendChild(removeBtn);
|
||||
block.appendChild(top);
|
||||
|
||||
currentEl.appendChild(block);
|
||||
row.appendChild(label);
|
||||
row.appendChild(removeBtn);
|
||||
currentEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,7 +1023,7 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
addEl.innerHTML = "";
|
||||
if (availableToAdd.length === 0) {
|
||||
addEl.innerHTML =
|
||||
'<span class="muted-text">No presets to add. All presets are already on this zone.</span>';
|
||||
'<span class="muted-text">No presets to add.</span>';
|
||||
} else {
|
||||
const addWrap = document.createElement("div");
|
||||
addWrap.className = "zone-devices-add profiles-actions";
|
||||
@@ -1069,17 +1102,8 @@ async function openEditZoneModal(zoneId, zone) {
|
||||
});
|
||||
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||
|
||||
const kind = effectiveZoneContentKind(tabData);
|
||||
const typeLabel = document.getElementById('edit-zone-type-label');
|
||||
if (typeLabel) {
|
||||
typeLabel.textContent =
|
||||
kind === 'sequences'
|
||||
? 'Zone type: Sequences (set when the zone was created)'
|
||||
: 'Zone type: Presets (set when the zone was created)';
|
||||
}
|
||||
|
||||
if (modal) modal.classList.add("active");
|
||||
applyZoneContentKindEditModal(kind);
|
||||
applyZoneContentKindEditModal();
|
||||
await refreshEditTabPresetsUi(zoneId);
|
||||
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||
await window.refreshEditTabSequencesUi(zoneId);
|
||||
@@ -1104,7 +1128,6 @@ async function updateZone(zoneId, name, groupRows) {
|
||||
} catch (_) {
|
||||
/* use empty existing */
|
||||
}
|
||||
const lockedKind = effectiveZoneContentKind(existing);
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -1119,7 +1142,6 @@ async function updateZone(zoneId, name, groupRows) {
|
||||
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||
? existing.preset_group_ids
|
||||
: {},
|
||||
content_kind: lockedKind,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1131,8 +1153,6 @@ async function updateZone(zoneId, name, groupRows) {
|
||||
if (String(currentZoneId) === String(zoneId)) {
|
||||
await loadZoneContent(zoneId);
|
||||
}
|
||||
// Close modal
|
||||
document.getElementById('edit-zone-modal').classList.remove('active');
|
||||
return true;
|
||||
} else {
|
||||
alert(`Error: ${data.error || 'Failed to update zone'}`);
|
||||
@@ -1145,11 +1165,9 @@ async function updateZone(zoneId, name, groupRows) {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
|
||||
async function createZone(name, contentKind) {
|
||||
// Create a new zone (add device groups, presets, and sequences in Edit zone).
|
||||
async function createZone(name) {
|
||||
try {
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
const response = await fetch('/zones', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1159,7 +1177,6 @@ async function createZone(name, contentKind) {
|
||||
name: name,
|
||||
names: [],
|
||||
group_ids: [],
|
||||
content_kind: ck,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1196,6 +1213,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const newTabNameInput = document.getElementById("new-zone-name");
|
||||
const createZoneButton = document.getElementById("create-zone-btn");
|
||||
|
||||
const zonesMenuBtn = document.getElementById('zones-menu-btn');
|
||||
const zonesMenuDropdown = document.getElementById('zones-menu-dropdown');
|
||||
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
|
||||
|
||||
if (zonesMenuBtn && zonesMenuDropdown) {
|
||||
zonesMenuBtn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const open = zonesMenuDropdown.classList.toggle('open');
|
||||
zonesMenuBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open && mainMenuDropdown) {
|
||||
mainMenuDropdown.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
zonesMenuDropdown.addEventListener('click', async (event) => {
|
||||
const item = event.target.closest('.zones-menu-item');
|
||||
if (!item || !item.dataset.zoneId) return;
|
||||
await selectZone(item.dataset.zoneId);
|
||||
zonesMenuDropdown.classList.remove('open');
|
||||
zonesMenuBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
if (tabsButton && zonesModal) {
|
||||
tabsButton.addEventListener("click", async () => {
|
||||
zonesModal.classList.add("active");
|
||||
@@ -1240,12 +1280,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const name = newTabNameInput.value.trim();
|
||||
|
||||
if (name) {
|
||||
const kindRadio = document.querySelector(
|
||||
'input[name="new-zone-content-kind"]:checked',
|
||||
);
|
||||
const contentKind =
|
||||
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
await createZone(name, contentKind);
|
||||
await createZone(name);
|
||||
if (newTabNameInput) newTabNameInput.value = "";
|
||||
}
|
||||
};
|
||||
@@ -1276,7 +1311,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (zoneId && name) {
|
||||
await updateZone(zoneId, name, groupRows);
|
||||
editZoneForm.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
_HOLDOVER_BPM_MIN = 30.0
|
||||
_HOLDOVER_BPM_MAX = 300.0
|
||||
_HOLDOVER_MAX_S = 300.0
|
||||
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
|
||||
# (same window as status() uses to hide stale BPM).
|
||||
@@ -257,6 +255,10 @@ class AudioBeatDetector:
|
||||
st["bpm"] = None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if st.get("bpm") is not None:
|
||||
from util.bpm_limits import clamp_bpm_optional
|
||||
|
||||
st["bpm"] = clamp_bpm_optional(st["bpm"])
|
||||
return st
|
||||
|
||||
def _apply_tracking_reset_status(self) -> None:
|
||||
@@ -275,16 +277,14 @@ class AudioBeatDetector:
|
||||
)
|
||||
|
||||
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
|
||||
try:
|
||||
v = float(bpm)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
|
||||
return None
|
||||
return v
|
||||
from util.bpm_limits import clamp_bpm_optional
|
||||
|
||||
return clamp_bpm_optional(bpm)
|
||||
|
||||
def _holdover_interval_s(self, bpm: float) -> float:
|
||||
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
return 60.0 / clamp_bpm(bpm)
|
||||
|
||||
def _stop_bpm_holdover(self) -> None:
|
||||
with self._lock:
|
||||
@@ -353,6 +353,12 @@ class AudioBeatDetector:
|
||||
return
|
||||
self._emit_holdover_beat(bpm)
|
||||
|
||||
def prime_bpm_holdover(self, bpm: float) -> None:
|
||||
"""Public: tick at *bpm* until the next detected beat (e.g. pending sequence switch)."""
|
||||
if not self._running:
|
||||
return
|
||||
self._start_bpm_holdover(bpm)
|
||||
|
||||
def _start_bpm_holdover(self, bpm: float) -> None:
|
||||
bpm_v = self._clamp_holdover_bpm(bpm)
|
||||
if bpm_v is None:
|
||||
@@ -434,8 +440,12 @@ class AudioBeatDetector:
|
||||
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||
holdover = self._holdover_active
|
||||
last_reset = self._last_gap_tempo_reset_ts
|
||||
if last_real is None or bpm is None:
|
||||
if last_real is None:
|
||||
return
|
||||
if bpm is None:
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
bpm = clamp_bpm(120)
|
||||
try:
|
||||
gap = now - float(last_real)
|
||||
except (TypeError, ValueError):
|
||||
@@ -460,10 +470,15 @@ class AudioBeatDetector:
|
||||
self._last_gap_tempo_reset_ts = now
|
||||
|
||||
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
|
||||
from util.bpm_limits import clamp_bpm_optional
|
||||
|
||||
self._stop_bpm_holdover()
|
||||
now = time.time()
|
||||
self._last_real_beat_ts = now
|
||||
bpm = clamp_bpm_optional(bpm)
|
||||
with self._lock:
|
||||
if bpm is None:
|
||||
bpm = clamp_bpm_optional(self._status.get("bpm"))
|
||||
self._last_gap_tempo_reset_ts = 0.0
|
||||
self._status["last_beat_ts"] = now
|
||||
self._status["bpm"] = bpm
|
||||
@@ -486,6 +501,12 @@ class AudioBeatDetector:
|
||||
seq_pb.push_thread_beat()
|
||||
except Exception as e:
|
||||
print(f"[audio] sequence beat queue: {e}")
|
||||
holdover_bpm = None
|
||||
with self._lock:
|
||||
if self._running:
|
||||
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||
if holdover_bpm is not None:
|
||||
self._start_bpm_holdover(holdover_bpm)
|
||||
|
||||
def _run_loop(self, device):
|
||||
try:
|
||||
@@ -525,6 +546,8 @@ class AudioBeatDetector:
|
||||
dev_info = sd.query_devices(device, "input")
|
||||
sample_rate = int(dev_info["default_samplerate"])
|
||||
|
||||
from util.bpm_limits import max_beat_min_ioi_ms
|
||||
|
||||
args = argparse.Namespace(
|
||||
mode="aubio",
|
||||
device=device,
|
||||
@@ -537,7 +560,7 @@ class AudioBeatDetector:
|
||||
flux_weight=0.3,
|
||||
threshold_multiplier=1.35,
|
||||
ema_alpha=0.08,
|
||||
min_ioi_ms=100.0,
|
||||
min_ioi_ms=max_beat_min_ioi_ms(),
|
||||
bpm_window=8,
|
||||
post_url="",
|
||||
aubio_method="default",
|
||||
@@ -645,6 +668,39 @@ def shared_beat_detector_running():
|
||||
return False
|
||||
|
||||
|
||||
def shared_beat_detector_timing_sequences() -> bool:
|
||||
"""True when live audio is running and has clocked a beat recently enough to drive sequences."""
|
||||
d = _shared_beat_detector
|
||||
if d is None:
|
||||
return False
|
||||
try:
|
||||
st = dict(d.status())
|
||||
except Exception:
|
||||
return False
|
||||
if not st.get("running"):
|
||||
return False
|
||||
with d._lock:
|
||||
last = d._last_real_beat_ts
|
||||
holdover = d._holdover_active
|
||||
if holdover:
|
||||
return True
|
||||
if last is None:
|
||||
return False
|
||||
try:
|
||||
gap = time.time() - float(last)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
bpm_raw = st.get("bpm")
|
||||
try:
|
||||
bpm_v = clamp_bpm(bpm_raw) if bpm_raw is not None else 120.0
|
||||
except (TypeError, ValueError):
|
||||
bpm_v = 120.0
|
||||
max_gap = (60.0 / bpm_v) * 2.0
|
||||
return gap < max_gap
|
||||
|
||||
|
||||
def shared_beat_status_snapshot() -> dict:
|
||||
"""Thread-safe copy of live detector status, or {} if audio is off."""
|
||||
d = _shared_beat_detector
|
||||
@@ -656,6 +712,17 @@ def shared_beat_status_snapshot() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def prime_bpm_holdover(bpm: float) -> None:
|
||||
"""Start BPM holdover on the shared detector when audio is on but not clocking."""
|
||||
d = _shared_beat_detector
|
||||
if d is None:
|
||||
return
|
||||
try:
|
||||
d.prime_bpm_holdover(bpm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def anchor_shared_bar_phase() -> bool:
|
||||
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
|
||||
d = _shared_beat_detector
|
||||
|
||||
156
src/util/beat_status_broadcaster.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Push beat and audio status updates to browser SSE clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
_main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_status_builder: Optional[Callable[[], Dict[str, Any]]] = None
|
||||
_clients_lock = threading.Lock()
|
||||
_client_queues: Set[asyncio.Queue[str]] = set()
|
||||
_heartbeat_task: Optional[asyncio.Task] = None
|
||||
_HEARTBEAT_INTERVAL_S = 0.25
|
||||
|
||||
|
||||
def configure(
|
||||
*,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
status_builder: Callable[[], Dict[str, Any]],
|
||||
) -> None:
|
||||
global _main_loop, _status_builder
|
||||
_main_loop = loop
|
||||
_status_builder = status_builder
|
||||
|
||||
|
||||
def request_beat_status_broadcast() -> None:
|
||||
"""Thread-safe: schedule a beat push after the consumer processes a tick."""
|
||||
loop = _main_loop
|
||||
if loop is None:
|
||||
return
|
||||
loop.call_soon_threadsafe(_schedule_broadcast, "beat")
|
||||
|
||||
|
||||
def request_status_broadcast() -> None:
|
||||
"""Thread-safe: schedule a non-beat status push (e.g. audio start/stop)."""
|
||||
loop = _main_loop
|
||||
if loop is None:
|
||||
return
|
||||
loop.call_soon_threadsafe(_schedule_broadcast, "status")
|
||||
|
||||
|
||||
def _schedule_broadcast(event_type: str) -> None:
|
||||
try:
|
||||
asyncio.create_task(_broadcast(event_type))
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
def _build_sse_line(event_type: str) -> str:
|
||||
if _status_builder is None:
|
||||
return ""
|
||||
st = _status_builder()
|
||||
payload = {"type": event_type, "status": st}
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
def _enqueue(queue: asyncio.Queue[str], line: str) -> None:
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
queue.put_nowait(line)
|
||||
except asyncio.QueueFull:
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(line)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
async def initial_sse_line() -> str:
|
||||
return _build_sse_line("status")
|
||||
|
||||
|
||||
async def register_sse_client(queue: asyncio.Queue[str]) -> None:
|
||||
with _clients_lock:
|
||||
_client_queues.add(queue)
|
||||
await _ensure_heartbeat()
|
||||
|
||||
|
||||
async def unregister_sse_client(queue: asyncio.Queue[str]) -> None:
|
||||
with _clients_lock:
|
||||
_client_queues.discard(queue)
|
||||
await _maybe_stop_heartbeat()
|
||||
|
||||
|
||||
async def _ensure_heartbeat() -> None:
|
||||
global _heartbeat_task
|
||||
if _heartbeat_task is not None and not _heartbeat_task.done():
|
||||
return
|
||||
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
||||
|
||||
|
||||
async def _maybe_stop_heartbeat() -> None:
|
||||
global _heartbeat_task
|
||||
with _clients_lock:
|
||||
if _client_queues:
|
||||
return
|
||||
if _heartbeat_task is not None:
|
||||
_heartbeat_task.cancel()
|
||||
try:
|
||||
await _heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_heartbeat_task = None
|
||||
|
||||
|
||||
def _should_heartbeat() -> bool:
|
||||
if _status_builder is None:
|
||||
return False
|
||||
try:
|
||||
st = _status_builder()
|
||||
return bool(st.get("running"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _heartbeat_loop() -> None:
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
|
||||
with _clients_lock:
|
||||
if not _client_queues:
|
||||
break
|
||||
if _should_heartbeat():
|
||||
await _broadcast("heartbeat")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def _broadcast(event_type: str) -> None:
|
||||
line = _build_sse_line(event_type)
|
||||
if not line:
|
||||
return
|
||||
with _clients_lock:
|
||||
targets: List[asyncio.Queue[str]] = list(_client_queues)
|
||||
dead: List[asyncio.Queue[str]] = []
|
||||
for queue in targets:
|
||||
try:
|
||||
_enqueue(queue, line)
|
||||
except Exception:
|
||||
dead.append(queue)
|
||||
if dead:
|
||||
with _clients_lock:
|
||||
for queue in dead:
|
||||
_client_queues.discard(queue)
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
await _maybe_stop_heartbeat()
|
||||
with _clients_lock:
|
||||
_client_queues.clear()
|
||||
40
src/util/bpm_limits.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Shared BPM bounds for simulated tempo, live detection, and UI."""
|
||||
|
||||
BPM_MIN = 60
|
||||
BPM_MAX = 200
|
||||
|
||||
|
||||
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:
|
||||
"""Clamp when *value* is a positive number; otherwise return ``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:
|
||||
"""Shortest allowed time between counted beats (``BPM_MAX``)."""
|
||||
return 60.0 / float(BPM_MAX)
|
||||
|
||||
|
||||
def max_beat_interval_s() -> float:
|
||||
"""Longest IOI used for BPM estimation (``BPM_MIN``)."""
|
||||
return 60.0 / float(BPM_MIN)
|
||||
|
||||
|
||||
def max_beat_min_ioi_ms() -> float:
|
||||
"""Minimum inter-onset interval (ms) allowed — matches ``BPM_MAX``."""
|
||||
return 60_000.0 / float(BPM_MAX)
|
||||
@@ -1,22 +1,60 @@
|
||||
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
|
||||
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Set
|
||||
|
||||
_ws_clients: set = set()
|
||||
_clients_lock = threading.Lock()
|
||||
_clients: Set[Any] = set()
|
||||
|
||||
|
||||
async def register_device_status_ws(ws):
|
||||
_ws_clients.add(ws)
|
||||
async def _ws_send_text(ws: Any, msg: str) -> None:
|
||||
"""Starlette/FastAPI WebSocket uses send_text; Microdot uses send."""
|
||||
send_text = getattr(ws, "send_text", None)
|
||||
if callable(send_text):
|
||||
await send_text(msg)
|
||||
return
|
||||
await ws.send(msg)
|
||||
|
||||
|
||||
async def unregister_device_status_ws(ws):
|
||||
_ws_clients.discard(ws)
|
||||
async def register_device_status_ws(ws: Any) -> None:
|
||||
with _clients_lock:
|
||||
_clients.add(ws)
|
||||
|
||||
|
||||
async def broadcast_device_tcp_snapshot_to(ws):
|
||||
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
|
||||
async def unregister_device_status_ws(ws: Any) -> None:
|
||||
with _clients_lock:
|
||||
_clients.discard(ws)
|
||||
|
||||
|
||||
async def broadcast_device_tcp_status(mac: str, connected: bool):
|
||||
pass
|
||||
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip
|
||||
|
||||
ip = normalize_tcp_peer_ip(ip)
|
||||
if not ip:
|
||||
return
|
||||
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
|
||||
with _clients_lock:
|
||||
targets = list(_clients)
|
||||
dead = []
|
||||
for ws in targets:
|
||||
try:
|
||||
await _ws_send_text(ws, msg)
|
||||
except Exception as exc:
|
||||
dead.append(ws)
|
||||
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
|
||||
if dead:
|
||||
with _clients_lock:
|
||||
for ws in dead:
|
||||
_clients.discard(ws)
|
||||
|
||||
|
||||
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
|
||||
from models import wifi_ws_clients as tcp
|
||||
|
||||
ips = tcp.list_connected_ips()
|
||||
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
|
||||
try:
|
||||
await _ws_send_text(ws, msg)
|
||||
except Exception as exc:
|
||||
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Deliver v1 JSON to drivers via bridge devices envelope."""
|
||||
"""Deliver v1 JSON via ESP-NOW bridge and/or outbound Wi-Fi driver WebSockets."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from models.device import normalize_mac
|
||||
from models.wifi_ws_clients import send_json_line_to_ip
|
||||
from util.bridge_envelope import (
|
||||
BROADCAST_MAC,
|
||||
build_devices_envelope,
|
||||
@@ -12,6 +14,7 @@ from util.bridge_envelope import (
|
||||
split_v1_body_for_espnow,
|
||||
)
|
||||
from util.espnow_message import build_message
|
||||
|
||||
_MAX_JSON_ESPNOW = 240
|
||||
|
||||
|
||||
@@ -38,6 +41,19 @@ def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Opt
|
||||
return None
|
||||
|
||||
|
||||
def _message_text(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[str]:
|
||||
if isinstance(msg, str):
|
||||
return msg
|
||||
if isinstance(msg, dict):
|
||||
return json.dumps(msg, separators=(",", ":"))
|
||||
if isinstance(msg, (bytes, bytearray)):
|
||||
try:
|
||||
return bytes(msg).decode("utf-8")
|
||||
except UnicodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
|
||||
deliveries = 0
|
||||
try:
|
||||
@@ -53,6 +69,91 @@ async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s:
|
||||
return deliveries
|
||||
|
||||
|
||||
def _wifi_message_for_device(msg: str, device_name: str) -> str:
|
||||
if not device_name:
|
||||
return msg
|
||||
try:
|
||||
body = json.loads(msg)
|
||||
except (ValueError, TypeError):
|
||||
return msg
|
||||
if not isinstance(body, dict):
|
||||
return msg
|
||||
select = body.get("select")
|
||||
if not isinstance(select, dict) or device_name not in select:
|
||||
return msg
|
||||
body["select"] = {device_name: select[device_name]}
|
||||
return json.dumps(body, separators=(",", ":"))
|
||||
|
||||
|
||||
def _combine_preset_chunks_for_wifi(chunk_messages: List[str]) -> str:
|
||||
merged_presets: Dict[str, Any] = {}
|
||||
save_flag = False
|
||||
default_id = None
|
||||
for msg in chunk_messages:
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
presets = body.get("presets")
|
||||
if isinstance(presets, dict):
|
||||
merged_presets.update(presets)
|
||||
if body.get("save"):
|
||||
save_flag = True
|
||||
if body.get("default") is not None:
|
||||
default_id = body.get("default")
|
||||
out: Dict[str, Any] = {"v": "1", "presets": merged_presets}
|
||||
if save_flag:
|
||||
out["save"] = True
|
||||
if default_id is not None:
|
||||
out["default"] = default_id
|
||||
return json.dumps(out, separators=(",", ":"))
|
||||
|
||||
|
||||
def _ordered_target_macs(target_macs: Optional[List[str]]) -> List[str]:
|
||||
if not target_macs:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
ordered: List[str] = []
|
||||
for raw in target_macs:
|
||||
m = normalize_mac(str(raw)) if raw else None
|
||||
if not m or m in seen:
|
||||
continue
|
||||
seen.add(m)
|
||||
ordered.append(m)
|
||||
return ordered
|
||||
|
||||
|
||||
def _wifi_targets(
|
||||
devices_model,
|
||||
target_macs: Optional[List[str]],
|
||||
) -> List[tuple[str, str]]:
|
||||
"""Return (ip, device_name) pairs for Wi-Fi drivers in scope."""
|
||||
ordered = _ordered_target_macs(target_macs)
|
||||
out: List[tuple[str, str]] = []
|
||||
seen_ips: set[str] = set()
|
||||
if ordered:
|
||||
for mac in ordered:
|
||||
doc = devices_model.read(mac)
|
||||
if not doc or doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = str(doc.get("address") or "").strip()
|
||||
if not ip or ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
name = str(doc.get("name") or "").strip() or mac
|
||||
out.append((ip, name))
|
||||
return out
|
||||
for _sid, doc in devices_model.items():
|
||||
if not isinstance(doc, dict) or doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = str(doc.get("address") or "").strip()
|
||||
if not ip or ip in seen_ips:
|
||||
continue
|
||||
seen_ips.add(ip)
|
||||
name = str(doc.get("name") or "").strip() or str(doc.get("id") or _sid)
|
||||
out.append((ip, name))
|
||||
return out
|
||||
|
||||
|
||||
def build_preset_json_chunks(
|
||||
presets_by_name: Dict[str, Any],
|
||||
*,
|
||||
@@ -96,11 +197,10 @@ def build_preset_json_chunks(
|
||||
|
||||
|
||||
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
|
||||
"""One formatted MAC per target; empty list means broadcast."""
|
||||
if not target_macs:
|
||||
return [BROADCAST_MAC]
|
||||
keys: List[str] = []
|
||||
seen: set = set()
|
||||
seen: set[str] = set()
|
||||
for raw in target_macs:
|
||||
h = normalize_mac_key(raw)
|
||||
if h and h not in seen:
|
||||
@@ -109,6 +209,69 @@ def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
|
||||
return keys if keys else [BROADCAST_MAC]
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
bridge,
|
||||
chunk_messages,
|
||||
target_macs,
|
||||
devices_model,
|
||||
default_id,
|
||||
delay_s=0.1,
|
||||
):
|
||||
"""ESP-NOW preset chunks via bridge broadcast; one combined preset per Wi-Fi driver."""
|
||||
if not chunk_messages:
|
||||
return 0
|
||||
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
active = get_current_bridge() or bridge
|
||||
if active is None:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
ordered = _ordered_target_macs(target_macs)
|
||||
wifi_targets = _wifi_targets(devices_model, ordered or None)
|
||||
deliveries = 0
|
||||
|
||||
for msg in chunk_messages:
|
||||
body = _body_from_message(msg)
|
||||
if body:
|
||||
deliveries += await _deliver_v1_body(active, BROADCAST_MAC, body, 0)
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
if wifi_targets:
|
||||
combined = _combine_preset_chunks_for_wifi(chunk_messages)
|
||||
for ip, _name in wifi_targets:
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, combined):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
if default_id:
|
||||
did = str(default_id)
|
||||
macs = ordered or [
|
||||
sid for sid, doc in devices_model.items()
|
||||
if isinstance(doc, dict)
|
||||
]
|
||||
for mac in macs:
|
||||
doc = devices_model.read(mac) or {}
|
||||
name = str(doc.get("name") or "").strip() or mac
|
||||
body = {"v": "1", "default": did, "save": True, "targets": [name]}
|
||||
out = json.dumps(body, separators=(",", ":"))
|
||||
if doc.get("transport") == "wifi" and doc.get("address"):
|
||||
ip = str(doc["address"]).strip()
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, out):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
|
||||
else:
|
||||
deliveries += await _deliver_v1_body(active, mac, body, 0)
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_json_messages(
|
||||
bridge,
|
||||
messages,
|
||||
@@ -119,30 +282,110 @@ async def deliver_json_messages(
|
||||
unicast: bool = False,
|
||||
):
|
||||
"""
|
||||
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers
|
||||
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
|
||||
or single-device identify.
|
||||
Deliver v1 JSON to ESP-NOW (bridge) and Wi-Fi (outbound WebSocket) drivers.
|
||||
|
||||
Uses the current bridge connection only (per-group bridge assignment is disabled).
|
||||
Broadcast (no targets): ESP-NOW via bridge plus all registered Wi-Fi drivers.
|
||||
Targeted: route each MAC by transport. ``unicast=True`` forces per-MAC ESP-NOW
|
||||
delivery instead of broadcast.
|
||||
"""
|
||||
del devices_model
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
active = get_current_bridge() or bridge
|
||||
if active is None:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
if unicast and target_macs:
|
||||
mac_keys = _unicast_mac_keys(target_macs)
|
||||
else:
|
||||
mac_keys = [BROADCAST_MAC]
|
||||
if not messages:
|
||||
return 0, 0
|
||||
|
||||
ordered_macs = _ordered_target_macs(target_macs)
|
||||
deliveries = 0
|
||||
for mac_key in mac_keys:
|
||||
|
||||
if not ordered_macs:
|
||||
wifi_targets = _wifi_targets(devices_model, None)
|
||||
for msg in messages:
|
||||
text = _message_text(msg)
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s)
|
||||
if body:
|
||||
deliveries += await _deliver_v1_body(active, BROADCAST_MAC, body, 0)
|
||||
if text and wifi_targets:
|
||||
for ip, name in wifi_targets:
|
||||
wifi_msg = _wifi_message_for_device(text, name)
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, wifi_msg):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] Wi-Fi delivery failed: {e!r}")
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries, len(messages)
|
||||
|
||||
if unicast:
|
||||
mac_keys = _unicast_mac_keys(target_macs)
|
||||
for mac_key in mac_keys:
|
||||
mac_hex = normalize_mac_key(mac_key) or mac_key.replace(":", "")
|
||||
doc = devices_model.read(mac_hex) if mac_hex else None
|
||||
for msg in messages:
|
||||
text = _message_text(msg)
|
||||
body = _body_from_message(msg)
|
||||
if doc and doc.get("transport") == "wifi" and doc.get("address"):
|
||||
ip = str(doc["address"]).strip()
|
||||
name = str(doc.get("name") or "").strip()
|
||||
wifi_msg = _wifi_message_for_device(text or "", name) if text else ""
|
||||
if wifi_msg:
|
||||
try:
|
||||
if await send_json_line_to_ip(ip, wifi_msg):
|
||||
deliveries += 1
|
||||
except Exception as e:
|
||||
print(f"[driver_delivery] Wi-Fi delivery failed: {e!r}")
|
||||
elif body:
|
||||
deliveries += await _deliver_v1_body(active, mac_key, body, 0)
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries, len(messages)
|
||||
|
||||
for msg in messages:
|
||||
text = _message_text(msg)
|
||||
body = _body_from_message(msg)
|
||||
wifi_tasks = []
|
||||
espnow_macs: List[str] = []
|
||||
for mac in ordered_macs:
|
||||
doc = devices_model.read(mac)
|
||||
if doc and doc.get("transport") == "wifi":
|
||||
ip = str(doc.get("address") or "").strip()
|
||||
if ip and text:
|
||||
name = str(doc.get("name") or "").strip()
|
||||
wifi_tasks.append(send_json_line_to_ip(ip, _wifi_message_for_device(text, name)))
|
||||
else:
|
||||
espnow_macs.append(mac)
|
||||
|
||||
tasks = []
|
||||
espnow_peer_count = 0
|
||||
if body and len(espnow_macs) > 1:
|
||||
for mac in espnow_macs:
|
||||
tasks.append(_deliver_v1_body(active, format_mac_key(normalize_mac_key(mac)), body, 0))
|
||||
espnow_peer_count = len(espnow_macs)
|
||||
elif body and len(espnow_macs) == 1:
|
||||
mac_key = format_mac_key(normalize_mac_key(espnow_macs[0]))
|
||||
tasks.append(_deliver_v1_body(active, mac_key, body, 0))
|
||||
espnow_peer_count = 1
|
||||
|
||||
tasks.extend(wifi_tasks)
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
n_serial = len(tasks) - len(wifi_tasks)
|
||||
for i, r in enumerate(results):
|
||||
if i < n_serial:
|
||||
if isinstance(r, int) and r > 0:
|
||||
deliveries += r
|
||||
elif isinstance(r, Exception):
|
||||
print(f"[driver_delivery] ESP-NOW delivery failed: {r!r}")
|
||||
else:
|
||||
if r is True:
|
||||
deliveries += 1
|
||||
elif isinstance(r, Exception):
|
||||
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
|
||||
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
|
||||
return deliveries, len(messages)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Server-side zone sequence playback (audio beats or simulated BPM).
|
||||
|
||||
Steps advance on each beat from the audio detector when it is running; otherwise the server
|
||||
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
|
||||
A background clock at ``audio_simulated_bpm`` ticks continuously. When the audio detector is
|
||||
running, live (and holdover) beats drive sequences; otherwise the background clock does.
|
||||
The consumer dedupes so both sources cannot exceed the configured BPM limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,6 +11,7 @@ import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from util.espnow_message import resolve_preset_background_hex
|
||||
@@ -17,22 +19,23 @@ from util.espnow_message import resolve_preset_background_hex
|
||||
_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
|
||||
_beat_consumer_started = False
|
||||
_beat_consumer_lock = threading.Lock()
|
||||
_last_beat_processed_ts = 0.0
|
||||
_beat_dedupe_lock = threading.Lock()
|
||||
|
||||
_sim_beat_task: Optional[asyncio.Task] = None
|
||||
_sim_beat_token = 0
|
||||
_background_beat_task: Optional[asyncio.Task] = None
|
||||
_background_beat_token = 0
|
||||
|
||||
_beat_run: Optional[Dict[str, Any]] = None
|
||||
_beat_run_lock = threading.Lock()
|
||||
|
||||
_pending_play: Optional[Dict[str, Any]] = None
|
||||
_pending_play_lock = threading.Lock()
|
||||
_pending_beat_task: Optional[asyncio.Task] = None
|
||||
_pending_beat_token = 0
|
||||
_last_thread_beat_phase: Dict[str, Any] = {
|
||||
"is_downbeat": True,
|
||||
"bar_beat": 1,
|
||||
}
|
||||
_sim_beat_counter = 0
|
||||
_last_completed_beat_readout = ""
|
||||
|
||||
|
||||
def _norm_mac(raw: Any) -> Optional[str]:
|
||||
@@ -933,6 +936,62 @@ def _build_ctx(
|
||||
}
|
||||
|
||||
|
||||
def _beat_readout_for_ctx(ctx: Dict[str, Any]) -> str:
|
||||
"""Pass position readout (e.g. ``3/6`` or ``6/6``) from an active run context."""
|
||||
lanes: List[List[Dict[str, Any]]] = ctx.get("lanes") or []
|
||||
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
|
||||
lane0_steps = len(lanes[0]) if lanes else 0
|
||||
lane0 = lanes[0] if lanes else []
|
||||
sequence_beats_per_pass = 0
|
||||
for step in lane0:
|
||||
sequence_beats_per_pass += max(1, int((step or {}).get("beats") or 1))
|
||||
sequence_beat_at = 0
|
||||
if lane_states and lane0_steps > 0:
|
||||
st0 = lane_states[0]
|
||||
idx = int(st0.get("stepIdx", 0))
|
||||
if st0.get("done"):
|
||||
sequence_beat_at = sequence_beats_per_pass
|
||||
else:
|
||||
beats_per_step = 1
|
||||
if 0 <= idx < len(lanes[0]):
|
||||
step = lanes[0][idx]
|
||||
beats_per_step = max(1, int(step.get("beats") or 1))
|
||||
beat_count_raw = int(st0.get("beatCount", 0))
|
||||
bt = max(1, int(beats_per_step))
|
||||
beat_count = min(bt, max(0, beat_count_raw))
|
||||
for j in range(min(idx, len(lane0))):
|
||||
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
|
||||
sequence_beat_at += beat_count
|
||||
if sequence_beats_per_pass > 0 and lane_states and lane0_steps > 0 and lane_states[0]:
|
||||
tot = max(1, int(sequence_beats_per_pass))
|
||||
st0_done = bool(lane_states[0].get("done"))
|
||||
if st0_done:
|
||||
return f"{tot}/{tot}"
|
||||
at = int(sequence_beat_at)
|
||||
if at > 0:
|
||||
# On the last beat of the pass, show n/n (not n-1/n) for the whole beat interval.
|
||||
sp = tot if at == tot - 1 else min(tot, at)
|
||||
return f"{sp}/{tot}"
|
||||
return ""
|
||||
|
||||
|
||||
def last_completed_beat_readout() -> str:
|
||||
"""Final pass readout kept after playback stops (e.g. ``6/6``)."""
|
||||
return str(_last_completed_beat_readout or "").strip()
|
||||
|
||||
|
||||
def clear_completed_beat_readout() -> None:
|
||||
global _last_completed_beat_readout
|
||||
_last_completed_beat_readout = ""
|
||||
|
||||
|
||||
def remember_completed_beat_readout(readout: str) -> None:
|
||||
global _last_completed_beat_readout
|
||||
text = str(readout or "").strip()
|
||||
if text:
|
||||
_last_completed_beat_readout = text
|
||||
|
||||
|
||||
def playback_status() -> Dict[str, Any]:
|
||||
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
|
||||
with _beat_run_lock:
|
||||
@@ -965,7 +1024,7 @@ def playback_status() -> Dict[str, Any]:
|
||||
beats_per_step = max(1, int(step.get("beats") or 1))
|
||||
beat_count_raw = int(st0.get("beatCount", 0))
|
||||
bt = max(1, int(beats_per_step))
|
||||
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
|
||||
beat_count = min(bt, max(0, beat_count_raw))
|
||||
for j in range(min(idx, len(lane0))):
|
||||
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
|
||||
sequence_beat_at += beat_count
|
||||
@@ -988,19 +1047,7 @@ def playback_status() -> Dict[str, Any]:
|
||||
lane0_preset_name = nm or pid
|
||||
else:
|
||||
lane0_preset_name = pid
|
||||
beat_readout = ""
|
||||
if (
|
||||
sequence_beats_per_pass > 0
|
||||
and lane_states
|
||||
and lane0_steps > 0
|
||||
and lane_states[0]
|
||||
and not lane_states[0].get("done")
|
||||
):
|
||||
tot = max(1, int(sequence_beats_per_pass))
|
||||
at = int(sequence_beat_at)
|
||||
# Pass position within this run: inclusive 1..tot
|
||||
sp = min(tot, max(1, at if at > 0 else 1))
|
||||
beat_readout = f"{sp}/{tot}"
|
||||
beat_readout = _beat_readout_for_ctx(ctx)
|
||||
return {
|
||||
"active": True,
|
||||
"advance_mode": ctx.get("advance_mode"),
|
||||
@@ -1026,6 +1073,11 @@ async def process_active_beat_advance() -> None:
|
||||
ctx = _beat_run
|
||||
if not ctx:
|
||||
return
|
||||
if ctx.get("_pending_switch"):
|
||||
return
|
||||
if _is_sequence_pass_start(ctx):
|
||||
if ctx.pop("_anchor_bar_on_pass_start", True):
|
||||
_anchor_bar_phase_for_sequence_start()
|
||||
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
loop = bool(ctx.get("loop"))
|
||||
@@ -1041,18 +1093,21 @@ async def process_active_beat_advance() -> None:
|
||||
step = lane_steps[int(st.get("stepIdx", 0))]
|
||||
need = max(1, int(step.get("beats") or 1))
|
||||
if int(st["beatCount"]) >= need:
|
||||
st["beatCount"] = 0
|
||||
if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps):
|
||||
at_end_of_lane = int(st.get("stepIdx", 0)) + 1 >= len(lane_steps)
|
||||
if at_end_of_lane:
|
||||
if loop:
|
||||
if i == 0:
|
||||
lane0_looped = True
|
||||
st["beatCount"] = 0
|
||||
# Force step-0 preset re-upload on loop wrap, even if wire id matches.
|
||||
st["_last_wire"] = ""
|
||||
st["stepIdx"] = 0
|
||||
await _send_lane(i, st, ctx)
|
||||
else:
|
||||
st["beatCount"] = need
|
||||
st["done"] = True
|
||||
else:
|
||||
st["beatCount"] = 0
|
||||
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
|
||||
await _send_lane(i, st, ctx)
|
||||
if lane0_looped:
|
||||
@@ -1061,6 +1116,8 @@ async def process_active_beat_advance() -> None:
|
||||
else:
|
||||
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||
if all(s.get("done") for s in lane_states):
|
||||
remember_completed_beat_readout(_beat_readout_for_ctx(ctx))
|
||||
await asyncio.sleep(0)
|
||||
await stop_playback(clear_devices=True)
|
||||
return
|
||||
|
||||
@@ -1097,17 +1154,12 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
||||
"""Drop active run state and cancel simulated beats; return the previous ctx."""
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
"""Drop active run state; return the previous ctx."""
|
||||
global _beat_run
|
||||
ctx: Optional[Dict[str, Any]] = None
|
||||
with _beat_run_lock:
|
||||
ctx = _beat_run
|
||||
_beat_run = None
|
||||
_sim_beat_token += 1
|
||||
st = _sim_beat_task
|
||||
_sim_beat_task = None
|
||||
if st and not st.done():
|
||||
st.cancel()
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -1115,6 +1167,10 @@ async def stop_playback(*, clear_devices: bool = True) -> None:
|
||||
"""Stop sequence playback; optionally clear presets on targeted devices."""
|
||||
clear_pending_play()
|
||||
ctx = _halt_playback_state()
|
||||
if ctx:
|
||||
lane_states = ctx.get("lane_states") or []
|
||||
if not lane_states or not all(s.get("done") for s in lane_states):
|
||||
clear_completed_beat_readout()
|
||||
if clear_devices and ctx:
|
||||
await _clear_devices_after_sequence(ctx)
|
||||
|
||||
@@ -1166,18 +1222,39 @@ def _drain_beat_queue() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_beat_side_effects() -> None:
|
||||
"""Clear manual routes and queued beats so startup cannot select before presets land."""
|
||||
def _clear_beat_route_only() -> None:
|
||||
from util.beat_driver_route import update_beat_route
|
||||
|
||||
update_beat_route({"enabled": False})
|
||||
|
||||
|
||||
def _reset_beat_side_effects() -> None:
|
||||
"""Clear manual routes and queued beats so startup cannot select before presets land."""
|
||||
global _last_beat_processed_ts
|
||||
|
||||
_clear_beat_route_only()
|
||||
_drain_beat_queue()
|
||||
with _beat_dedupe_lock:
|
||||
_last_beat_processed_ts = 0.0
|
||||
|
||||
|
||||
def _sequence_switch_wait_from_settings() -> str:
|
||||
def _rearm_beat_dedupe_clock() -> None:
|
||||
"""After a sequence handoff, enforce a full beat gap before the next accepted tick."""
|
||||
global _last_beat_processed_ts
|
||||
|
||||
with _beat_dedupe_lock:
|
||||
_last_beat_processed_ts = time.time()
|
||||
|
||||
|
||||
def effective_sequence_switch_wait() -> str:
|
||||
"""Beat-only when the simulated clock is driving; otherwise honour saved preference."""
|
||||
try:
|
||||
from util import audio_detector as ad_mod
|
||||
from settings import get_settings
|
||||
|
||||
# Match ``_audio_drives_beat_clock``: mic may be "running" while sim still ticks.
|
||||
if not ad_mod.shared_beat_detector_timing_sequences():
|
||||
return "beat"
|
||||
raw = get_settings().get("sequence_switch_wait", "beat")
|
||||
mode = _normalize_wait_for({"wait_for": raw}) or "beat"
|
||||
if mode == "phrase":
|
||||
@@ -1187,6 +1264,10 @@ def _sequence_switch_wait_from_settings() -> str:
|
||||
return "beat"
|
||||
|
||||
|
||||
def _sequence_switch_wait_from_settings() -> str:
|
||||
return effective_sequence_switch_wait()
|
||||
|
||||
|
||||
def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""``beat`` | ``downbeat`` | None (immediate)."""
|
||||
if not isinstance(play_options, dict):
|
||||
@@ -1213,21 +1294,11 @@ def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Option
|
||||
return out
|
||||
|
||||
|
||||
def _cancel_pending_beat_waiter() -> None:
|
||||
global _pending_beat_task, _pending_beat_token
|
||||
_pending_beat_token += 1
|
||||
t = _pending_beat_task
|
||||
_pending_beat_task = None
|
||||
if t and not t.done():
|
||||
t.cancel()
|
||||
|
||||
|
||||
def clear_pending_play() -> None:
|
||||
"""Drop a queued sequence start (e.g. user stop)."""
|
||||
global _pending_play
|
||||
with _pending_play_lock:
|
||||
_pending_play = None
|
||||
_cancel_pending_beat_waiter()
|
||||
|
||||
|
||||
def pending_play_status() -> Dict[str, Any]:
|
||||
@@ -1246,10 +1317,12 @@ def pending_play_status() -> Dict[str, Any]:
|
||||
def _beat_phase_from_sources() -> Dict[str, Any]:
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
if ad_mod.shared_beat_detector_timing_sequences():
|
||||
st = ad_mod.shared_beat_status_snapshot()
|
||||
if st:
|
||||
return dict(st)
|
||||
if st.get("bar_beat") is not None:
|
||||
bar_beat = int(st["bar_beat"])
|
||||
is_down = bool(st.get("is_downbeat")) or bar_beat == 1
|
||||
return {"bar_beat": bar_beat, "is_downbeat": is_down}
|
||||
return dict(_last_thread_beat_phase)
|
||||
|
||||
|
||||
@@ -1269,6 +1342,42 @@ def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
|
||||
}
|
||||
|
||||
|
||||
def _anchor_simulated_bar_phase(*, beats_per_bar: int = 4) -> None:
|
||||
"""Align simulated bar phase to beat 1 (downbeat) for the current tick."""
|
||||
global _sim_beat_counter, _last_thread_beat_phase
|
||||
bpb = max(1, int(beats_per_bar))
|
||||
c = max(1, int(_sim_beat_counter))
|
||||
_sim_beat_counter = ((c - 1) // bpb) * bpb + 1
|
||||
_last_thread_beat_phase = {
|
||||
"bar_beat": 1,
|
||||
"is_downbeat": True,
|
||||
}
|
||||
|
||||
|
||||
def _is_sequence_pass_start(ctx: Dict[str, Any]) -> bool:
|
||||
"""True on beat 1 of step 1 (including after a loop wrap)."""
|
||||
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
|
||||
if not lane_states:
|
||||
return False
|
||||
st0 = lane_states[0]
|
||||
if st0.get("done"):
|
||||
return False
|
||||
if int(st0.get("stepIdx", 0)) != 0:
|
||||
return False
|
||||
return int(st0.get("beatCount", 0)) == 0
|
||||
|
||||
|
||||
def _anchor_bar_phase_for_sequence_start() -> None:
|
||||
"""Sequence beat 1 and bar beat 1 begin on the same counted beat."""
|
||||
_anchor_simulated_bar_phase()
|
||||
try:
|
||||
from util.audio_detector import anchor_shared_bar_phase
|
||||
|
||||
anchor_shared_bar_phase()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _queue_pending_start(
|
||||
zone_id: str,
|
||||
sequence_id: str,
|
||||
@@ -1279,7 +1388,12 @@ def _queue_pending_start(
|
||||
bpm: float,
|
||||
) -> None:
|
||||
global _pending_play
|
||||
if effective_sequence_switch_wait() == "beat":
|
||||
wait_for = "beat"
|
||||
clear_pending_play()
|
||||
with _beat_run_lock:
|
||||
if _beat_run is not None:
|
||||
_beat_run["_pending_switch"] = True
|
||||
with _pending_play_lock:
|
||||
_pending_play = {
|
||||
"zone_id": str(zone_id),
|
||||
@@ -1288,49 +1402,13 @@ def _queue_pending_start(
|
||||
"play_options": _play_options_without_wait(play_options),
|
||||
"wait_for": wait_for,
|
||||
}
|
||||
_ensure_pending_beat_waiter(bpm)
|
||||
ensure_background_beat_clock_started()
|
||||
_prime_pending_sequence_beat_clock(wait_for)
|
||||
|
||||
|
||||
def _ensure_pending_beat_waiter(bpm: float) -> None:
|
||||
"""When nothing is playing and audio is off, emit synthetic beats until pending starts."""
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
with _beat_run_lock:
|
||||
if _beat_run:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
global _pending_beat_task, _pending_beat_token
|
||||
t = _pending_beat_task
|
||||
if t and not t.done():
|
||||
t.cancel()
|
||||
_pending_beat_token += 1
|
||||
my_tok = _pending_beat_token
|
||||
_pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))
|
||||
|
||||
|
||||
async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
|
||||
while True:
|
||||
with _pending_play_lock:
|
||||
if _pending_beat_token != my_token or _pending_play is None:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
await asyncio.sleep(interval)
|
||||
with _pending_play_lock:
|
||||
if _pending_beat_token != my_token or _pending_play is None:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
_mark_simulated_beat_phase()
|
||||
push_thread_beat()
|
||||
def _prime_pending_sequence_beat_clock(wait_for: str) -> None:
|
||||
"""No-op: the background simulated clock fills beats when live audio is not timing."""
|
||||
_ = wait_for
|
||||
|
||||
|
||||
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
|
||||
@@ -1339,25 +1417,34 @@ async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
|
||||
pending = _pending_play
|
||||
if not pending:
|
||||
return False
|
||||
wait_for = str(pending.get("wait_for") or "beat").strip().lower()
|
||||
# Re-read preference at consume time (e.g. audio stopped → simulated beat-only).
|
||||
wait_for = effective_sequence_switch_wait()
|
||||
if wait_for == "downbeat" and not is_downbeat:
|
||||
return False
|
||||
_pending_play = None
|
||||
_cancel_pending_beat_waiter()
|
||||
await _start_immediate(
|
||||
pending["zone_id"],
|
||||
pending["sequence_id"],
|
||||
pending["profile_id"],
|
||||
pending.get("play_options"),
|
||||
sequence_handoff=True,
|
||||
handoff_is_downbeat=is_downbeat,
|
||||
)
|
||||
_drain_beat_queue()
|
||||
_rearm_beat_dedupe_clock()
|
||||
_restart_background_beat_clock()
|
||||
return True
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
def stop(*, sequence_handoff: bool = False) -> None:
|
||||
"""Stop server playback state without sending device clear (e.g. before starting another run)."""
|
||||
clear_pending_play()
|
||||
clear_completed_beat_readout()
|
||||
_halt_playback_state()
|
||||
_reset_beat_side_effects()
|
||||
if sequence_handoff:
|
||||
_clear_beat_route_only()
|
||||
else:
|
||||
_reset_beat_side_effects()
|
||||
|
||||
|
||||
def push_thread_beat() -> None:
|
||||
@@ -1367,41 +1454,62 @@ def push_thread_beat() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _min_processed_beat_gap_s() -> float:
|
||||
from util.bpm_limits import min_beat_interval_s
|
||||
|
||||
return float(min_beat_interval_s()) * 0.92
|
||||
|
||||
|
||||
def _accept_thread_beat_now() -> bool:
|
||||
"""Drop beats closer than the BPM limit (audio + simulated may both fire)."""
|
||||
global _last_beat_processed_ts
|
||||
now = time.time()
|
||||
gap = _min_processed_beat_gap_s()
|
||||
with _beat_dedupe_lock:
|
||||
if now - _last_beat_processed_ts < gap:
|
||||
return False
|
||||
_last_beat_processed_ts = now
|
||||
return True
|
||||
|
||||
|
||||
async def beat_consumer_loop() -> None:
|
||||
while True:
|
||||
n = 0
|
||||
try:
|
||||
while True:
|
||||
_thread_beat_queue.get_nowait()
|
||||
n += 1
|
||||
_thread_beat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if n:
|
||||
from util.beat_driver_route import notify_beat_detected
|
||||
|
||||
for _ in range(n):
|
||||
phase = _beat_phase_from_sources()
|
||||
is_down = bool(phase.get("is_downbeat"))
|
||||
try:
|
||||
await _try_consume_pending_play(is_downbeat=is_down)
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] pending start: {e}")
|
||||
try:
|
||||
await process_active_beat_advance()
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] beat advance: {e}")
|
||||
try:
|
||||
notify_beat_detected()
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] notify_beat_detected: {e}")
|
||||
else:
|
||||
await asyncio.sleep(0.012)
|
||||
continue
|
||||
if not _accept_thread_beat_now():
|
||||
continue
|
||||
from util.beat_driver_route import notify_beat_detected
|
||||
|
||||
phase = _beat_phase_from_sources()
|
||||
is_down = bool(phase.get("is_downbeat"))
|
||||
try:
|
||||
await _try_consume_pending_play(is_downbeat=is_down)
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] pending start: {e}")
|
||||
try:
|
||||
await process_active_beat_advance()
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] beat advance: {e}")
|
||||
try:
|
||||
notify_beat_detected()
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] notify_beat_detected: {e}")
|
||||
try:
|
||||
from util import beat_status_broadcaster as beat_sse
|
||||
|
||||
beat_sse.request_beat_status_broadcast()
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] beat status broadcast: {e}")
|
||||
|
||||
|
||||
def ensure_beat_consumer_started() -> None:
|
||||
global _beat_consumer_started
|
||||
with _beat_consumer_lock:
|
||||
if _beat_consumer_started:
|
||||
ensure_background_beat_clock_started()
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -1409,46 +1517,80 @@ def ensure_beat_consumer_started() -> None:
|
||||
return
|
||||
_beat_consumer_started = True
|
||||
loop.create_task(beat_consumer_loop())
|
||||
ensure_background_beat_clock_started()
|
||||
|
||||
|
||||
def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
|
||||
raw = None
|
||||
if isinstance(play_options, dict):
|
||||
o = play_options.get("simulated_bpm")
|
||||
if o is not None:
|
||||
raw = o
|
||||
if raw is None and isinstance(sequence_doc, dict):
|
||||
raw = sequence_doc.get("simulated_bpm")
|
||||
try:
|
||||
v = float(raw) if raw is not None else 120.0
|
||||
except (TypeError, ValueError):
|
||||
v = 120.0
|
||||
return max(30.0, min(300.0, v))
|
||||
|
||||
|
||||
async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -> None:
|
||||
def _audio_drives_beat_clock() -> bool:
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
|
||||
return ad_mod.shared_beat_detector_timing_sequences()
|
||||
|
||||
|
||||
def ensure_background_beat_clock_started() -> None:
|
||||
"""Start the always-on simulated BPM tick (no-op if already running)."""
|
||||
global _background_beat_task, _background_beat_token
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
t = _background_beat_task
|
||||
if t is not None and not t.done():
|
||||
return
|
||||
_background_beat_token += 1
|
||||
my_tok = _background_beat_token
|
||||
_background_beat_task = loop.create_task(_background_beat_loop(my_tok))
|
||||
|
||||
|
||||
def _restart_background_beat_clock() -> None:
|
||||
"""Restart the simulated clock so the next tick is a full interval away."""
|
||||
global _background_beat_task, _background_beat_token
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
_background_beat_token += 1
|
||||
my_tok = _background_beat_token
|
||||
_background_beat_task = loop.create_task(_background_beat_loop(my_tok))
|
||||
|
||||
|
||||
async def _background_beat_loop(my_token: int) -> None:
|
||||
"""Tick at simulated BPM; push beats only when audio detection is off."""
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
while True:
|
||||
with _beat_run_lock:
|
||||
cur_tok = _sim_beat_token
|
||||
active = _beat_run
|
||||
if cur_tok != my_token or active is None or active is not ctx:
|
||||
if _background_beat_token != my_token:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
await asyncio.sleep(0.12)
|
||||
continue
|
||||
bpm = _simulated_bpm_from_settings()
|
||||
interval = 60.0 / clamp_bpm(bpm)
|
||||
await asyncio.sleep(interval)
|
||||
with _beat_run_lock:
|
||||
cur_tok = _sim_beat_token
|
||||
active = _beat_run
|
||||
if cur_tok != my_token or active is None or active is not ctx:
|
||||
if _background_beat_token != my_token:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
continue
|
||||
_mark_simulated_beat_phase()
|
||||
push_thread_beat()
|
||||
if not _audio_drives_beat_clock():
|
||||
push_thread_beat()
|
||||
|
||||
|
||||
def simulated_beat_tick() -> int:
|
||||
"""Monotonic counter incremented on each synthetic beat (UI flash when audio is off)."""
|
||||
return int(_sim_beat_counter)
|
||||
|
||||
|
||||
def simulated_beat_phase_snapshot(*, beats_per_bar: int = 4) -> Dict[str, Any]:
|
||||
"""Bar phase from the last synthetic beat (for UI when audio detection is off)."""
|
||||
bpb = max(1, int(beats_per_bar))
|
||||
phase = dict(_last_thread_beat_phase)
|
||||
bar_beat = int(phase.get("bar_beat") or 1)
|
||||
bar_beat = min(bpb, max(1, bar_beat))
|
||||
phase["bar_beat"] = bar_beat
|
||||
phase["bar_phase_readout"] = f"{bar_beat}/{bpb}"
|
||||
return phase
|
||||
|
||||
|
||||
def _simulated_bpm_from_settings() -> float:
|
||||
from settings import get_settings
|
||||
from util.bpm_limits import clamp_bpm
|
||||
|
||||
return clamp_bpm(get_settings().get("audio_simulated_bpm"))
|
||||
|
||||
|
||||
def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||
@@ -1485,8 +1627,10 @@ async def start(
|
||||
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
|
||||
raise ValueError("sequence not found")
|
||||
wait_for = _sequence_switch_wait_from_settings()
|
||||
if wait_for:
|
||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||
with _beat_run_lock:
|
||||
active = _beat_run is not None
|
||||
if wait_for and active:
|
||||
bpm = _simulated_bpm_from_settings()
|
||||
_queue_pending_start(
|
||||
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
|
||||
)
|
||||
@@ -1499,14 +1643,17 @@ async def _start_immediate(
|
||||
sequence_id: str,
|
||||
profile_id: str,
|
||||
play_options: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
sequence_handoff: bool = False,
|
||||
handoff_is_downbeat: bool = False,
|
||||
) -> None:
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
global _beat_run
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.sequence import Sequence
|
||||
from models.zone import Zone
|
||||
|
||||
stop()
|
||||
stop(sequence_handoff=sequence_handoff)
|
||||
seq_m = Sequence()
|
||||
zone_m = Zone()
|
||||
prof_m = Profile()
|
||||
@@ -1534,16 +1681,16 @@ async def _start_immediate(
|
||||
ctx["sequence_id"] = str(sequence_id)
|
||||
ctx["zone_id"] = str(zone_id)
|
||||
ctx["sequence_loop_beat"] = 0
|
||||
if sequence_handoff:
|
||||
ctx["_anchor_bar_on_pass_start"] = handoff_is_downbeat
|
||||
|
||||
_reset_beat_side_effects()
|
||||
if sequence_handoff:
|
||||
_clear_beat_route_only()
|
||||
else:
|
||||
_reset_beat_side_effects()
|
||||
await _prime_all_lanes(ctx)
|
||||
await _deliver_zone_brightness_for_sequence(ctx)
|
||||
with _beat_run_lock:
|
||||
_beat_run = ctx
|
||||
|
||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||
loop = asyncio.get_running_loop()
|
||||
_sim_beat_token += 1
|
||||
my_tok = _sim_beat_token
|
||||
_sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))
|
||||
ensure_background_beat_clock_started()
|
||||
|
||||
|
||||
248
src/util/wifi_driver_runtime.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""UDP discovery and outbound WebSocket maintenance for Wi-Fi LED drivers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import traceback
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from models import wifi_ws_clients as tcp_client_registry
|
||||
from models.device import Device, normalize_mac
|
||||
|
||||
DISCOVERY_UDP_PORT = 8766
|
||||
|
||||
_tcp_device_lock = threading.Lock()
|
||||
_udp_holder: Dict[str, Any] = {"closing": False}
|
||||
_tasks: list[asyncio.Task] = []
|
||||
|
||||
|
||||
def _ipv4_address(addr: str) -> Optional[str]:
|
||||
s = (addr or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
parts = s.split(".")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
try:
|
||||
nums = [int(p) for p in parts]
|
||||
except ValueError:
|
||||
return None
|
||||
if not all(0 <= n <= 255 for n in nums):
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def _register_udp_device_sync(
|
||||
device_name: str, peer_ip: str, mac, device_type=None
|
||||
) -> None:
|
||||
with _tcp_device_lock:
|
||||
try:
|
||||
d = Device()
|
||||
did, persisted = d.upsert_wifi_tcp_client(
|
||||
device_name, peer_ip, mac, device_type=device_type
|
||||
)
|
||||
if did and persisted:
|
||||
print(
|
||||
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"UDP device registry failed: {e}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
|
||||
|
||||
def _process_udp_datagram(data: bytes, peer_ip: str) -> None:
|
||||
line = data.split(b"\n", 1)[0].strip()
|
||||
if not line:
|
||||
return
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return
|
||||
if not isinstance(parsed, dict):
|
||||
return
|
||||
dns = str(parsed.get("device_name") or "").strip()
|
||||
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get("sta_mac")
|
||||
device_type = parsed.get("type") or parsed.get("device_type")
|
||||
if not dns or not normalize_mac(mac):
|
||||
return
|
||||
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
||||
if str(parsed.get("v") or "") == "1":
|
||||
tcp_client_registry.ensure_driver_connection(peer_ip)
|
||||
|
||||
|
||||
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
||||
"""UDP echo + device registration (uvloop-safe; no sock_recvfrom)."""
|
||||
|
||||
def __init__(self, udp_holder: Optional[Dict[str, Any]] = None) -> None:
|
||||
self._udp_holder = udp_holder
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
|
||||
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
||||
self._transport = transport # type: ignore[assignment]
|
||||
if self._udp_holder is not None:
|
||||
self._udp_holder["transport"] = transport
|
||||
|
||||
def connection_lost(self, exc: Optional[BaseException]) -> None:
|
||||
if self._udp_holder is not None:
|
||||
self._udp_holder.pop("transport", None)
|
||||
self._transport = None
|
||||
|
||||
def datagram_received(self, data: bytes, addr) -> None:
|
||||
if self._udp_holder and self._udp_holder.get("closing"):
|
||||
return
|
||||
peer_ip = addr[0] if addr else ""
|
||||
try:
|
||||
_process_udp_datagram(data, peer_ip)
|
||||
except Exception as e:
|
||||
print(f"[UDP] process failed: {e!r}")
|
||||
transport = self._transport
|
||||
if transport is None:
|
||||
return
|
||||
try:
|
||||
transport.sendto(data, addr)
|
||||
except Exception as e:
|
||||
print(f"[UDP] echo send failed: {e!r}")
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
if self._udp_holder and self._udp_holder.get("closing"):
|
||||
return
|
||||
print(f"[UDP] socket error: {exc!r}")
|
||||
|
||||
|
||||
def prime_wifi_outbound_driver_connections() -> None:
|
||||
n = 0
|
||||
try:
|
||||
dev = Device()
|
||||
for _mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
tcp_client_registry.ensure_driver_connection(ip)
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
|
||||
traceback.print_exception(type(e), e, e.__traceback__)
|
||||
return
|
||||
if n:
|
||||
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
|
||||
|
||||
|
||||
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
||||
try:
|
||||
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
|
||||
except (TypeError, ValueError):
|
||||
interval = 10.0
|
||||
if interval <= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
if udp_holder.get("closing"):
|
||||
break
|
||||
transport = udp_holder.get("transport")
|
||||
if transport is None:
|
||||
continue
|
||||
try:
|
||||
dev = Device()
|
||||
except Exception as e:
|
||||
print(f"[hello] device list failed: {e!r}")
|
||||
continue
|
||||
for _mac_key, doc in list(dev.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("transport") != "wifi":
|
||||
continue
|
||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
||||
if not ip:
|
||||
continue
|
||||
if tcp_client_registry.tcp_client_connected(ip):
|
||||
continue
|
||||
name = (doc.get("name") or "").strip()
|
||||
mac = normalize_mac(doc.get("id") or _mac_key)
|
||||
if not name or not mac:
|
||||
continue
|
||||
line = (
|
||||
json.dumps(
|
||||
{"m": "hello", "device_name": name, "mac": mac},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
try:
|
||||
transport.sendto(line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT))
|
||||
except OSError as e:
|
||||
print(f"[hello] UDP to {ip!r} failed: {e!r}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
|
||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except OSError:
|
||||
pass
|
||||
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
|
||||
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
||||
transport, _protocol = await loop.create_datagram_endpoint(
|
||||
lambda: _DiscoveryProtocol(udp_holder),
|
||||
sock=sock,
|
||||
)
|
||||
try:
|
||||
while not (udp_holder and udp_holder.get("closing")):
|
||||
await asyncio.sleep(3600)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
finally:
|
||||
if udp_holder is not None:
|
||||
udp_holder.pop("transport", None)
|
||||
transport.close()
|
||||
|
||||
|
||||
async def start_wifi_driver_runtime(settings) -> None:
|
||||
global _udp_holder, _tasks
|
||||
tcp_client_registry.set_settings(settings)
|
||||
from util.device_status_broadcaster import broadcast_device_tcp_status
|
||||
|
||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
||||
|
||||
_udp_holder = {"closing": False}
|
||||
prime_wifi_outbound_driver_connections()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
_tasks = [
|
||||
loop.create_task(_run_udp_discovery_server(_udp_holder)),
|
||||
loop.create_task(_periodic_wifi_driver_hello_loop(settings, _udp_holder)),
|
||||
]
|
||||
|
||||
|
||||
async def stop_wifi_driver_runtime() -> None:
|
||||
global _udp_holder, _tasks
|
||||
if _udp_holder is not None:
|
||||
_udp_holder["closing"] = True
|
||||
transport = _udp_holder.get("transport")
|
||||
if transport is not None:
|
||||
try:
|
||||
transport.close()
|
||||
except OSError:
|
||||
pass
|
||||
for task in list(_tasks):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
if _tasks:
|
||||
await asyncio.gather(*_tasks, return_exceptions=True)
|
||||
_tasks = []
|
||||
tcp_client_registry.cancel_all_driver_tasks()
|
||||
@@ -7,14 +7,22 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `test_endpoints.py` | HTTP endpoint checks (**`LED_CONTROLLER_RUN_DEVICE_ENDPOINT_TESTS=1`**); **`test_zones`** / **`test_zone_edit_workflow`** hit **`/zones`** |
|
||||
| `test_endpoints_pytest.py` | Pytest-style endpoint coverage |
|
||||
| `api_server.py` | Shared FastAPI `TestClient` fixture (`server`) for in-process API tests |
|
||||
| `test_endpoints_pytest.py` | Pytest API coverage (profiles, zones, devices, bridge, audio, patterns) |
|
||||
| `test_bridge_ws_client.py` | Bridge WebSocket client reconnect / send behaviour |
|
||||
| `test_bridge_envelope.py` | Devices envelope build/split/delivery |
|
||||
| `test_bridge_serial_frame.py` | Pi↔bridge USB serial framing |
|
||||
| `test_bridge_wifi_connect.py` | Saved bridge profile connect (serial path) |
|
||||
| `test_espnow_wire.py`, `test_espnow_ping.py` | Binary wire codec and ping registration |
|
||||
| `test_binary_envelope.py` | v2 binary envelope encode/decode |
|
||||
| `test_browser.py` | Selenium UI flows (set **`LED_CONTROLLER_RUN_BROWSER_TESTS=1`** to run; uses **`test_zones_ui`** and legacy **`tabsManager`** JS aliases) |
|
||||
| `test_pattern_ota_send.py` | Pattern OTA / Wi-Fi send helpers |
|
||||
| `test_pi_wifi_scan.py` | nmcli SSID scan helpers |
|
||||
| `tcp_test_server.py`, `async_tcp_server.py` | TCP test doubles for driver protocol |
|
||||
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
|
||||
| `bridge_broadcast_test.py` | Manual bridge WebSocket broadcast script |
|
||||
| `ws.py` | WebSocket client checks |
|
||||
| `p2p.py` | ESP-NOW–related helpers / experiments |
|
||||
| `web.py` | Local dev static server (not the main app) |
|
||||
| `web.py` | Local dev server on port 5000 (`pipenv run web`) |
|
||||
| `conftest.py` | Pytest fixtures |
|
||||
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
|
||||
|
||||
@@ -42,6 +50,6 @@ Requires **Selenium**, Chrome/Chromium, and a matching **ChromeDriver**.
|
||||
python tests/models/run_all.py
|
||||
```
|
||||
|
||||
### Local static server
|
||||
### Local dev server (port 5000)
|
||||
|
||||
`tests/web.py` serves files for quick UI experiments; it is **not** the Microdot app. For the real server use **`pipenv run run`** from the repo root.
|
||||
`pipenv run web` runs the FastAPI app on **http://localhost:5000** (production-style default is **`pipenv run run`** on port 80).
|
||||
|
||||
166
tests/api_server.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Shared FastAPI test server fixture for API endpoint tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
|
||||
for p in (str(PROJECT_ROOT), str(SRC_PATH)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
|
||||
class DummyBridge:
|
||||
def __init__(self):
|
||||
self.sent: list[tuple[Any, Optional[str]]] = []
|
||||
|
||||
async def send(self, data: Any, addr: Optional[str] = None):
|
||||
if isinstance(data, dict):
|
||||
from util.bridge_envelope import ( # noqa: E402
|
||||
BROADCAST_MAC,
|
||||
build_devices_envelope,
|
||||
format_mac_key,
|
||||
is_broadcast_mac,
|
||||
normalize_mac_key,
|
||||
)
|
||||
from util.v1_wire import compact_envelope # noqa: E402
|
||||
|
||||
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||
data = compact_envelope(data)
|
||||
elif addr is not None:
|
||||
s = str(addr).strip().lower()
|
||||
if is_broadcast_mac(s):
|
||||
mac_key = BROADCAST_MAC
|
||||
else:
|
||||
h = normalize_mac_key(s)
|
||||
mac_key = format_mac_key(h) if h else None
|
||||
if mac_key:
|
||||
body = {k: v for k, v in data.items() if k != "v"}
|
||||
data = build_devices_envelope({mac_key: body})
|
||||
else:
|
||||
data = json.dumps(data, separators=(",", ":"))
|
||||
else:
|
||||
data = json.dumps(data, separators=(",", ":"))
|
||||
elif isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data).decode(errors="ignore")
|
||||
self.sent.append((data, addr))
|
||||
return True
|
||||
|
||||
|
||||
def bridge_sent_envelope(bridge: DummyBridge, index: int) -> Dict[str, Any]:
|
||||
data, _addr = bridge.sent[index]
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
def device_body_from_envelope(envelope: Dict[str, Any], mac: str) -> Dict[str, Any]:
|
||||
from util.bridge_envelope import format_mac_key, normalize_mac_key # noqa: E402
|
||||
|
||||
devs = envelope.get("dv") or envelope.get("devices") or {}
|
||||
key = format_mac_key(normalize_mac_key(mac))
|
||||
return devs[key]
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def server(monkeypatch, tmp_path_factory):
|
||||
"""In-process FastAPI app with isolated db/settings."""
|
||||
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
|
||||
tmp_db_dir = tmp_root / "db"
|
||||
tmp_settings_file = tmp_root / "settings.json"
|
||||
|
||||
for p in (str(SRC_PATH), str(PROJECT_ROOT)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
import settings as settings_mod # noqa: E402
|
||||
|
||||
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
|
||||
|
||||
import models.model as model_mod # noqa: E402
|
||||
|
||||
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
|
||||
|
||||
import models.preset as models_preset # noqa: E402
|
||||
import models.profile as models_profile # noqa: E402
|
||||
import models.group as models_group # noqa: E402
|
||||
import models.zone as models_zone # noqa: E402
|
||||
import models.pallet as models_pallet # noqa: E402
|
||||
import models.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
import models.sequence as models_sequence # noqa: E402
|
||||
import models.device as models_device # noqa: E402
|
||||
|
||||
for cls in (
|
||||
models_preset.Preset,
|
||||
models_profile.Profile,
|
||||
models_group.Group,
|
||||
models_zone.Zone,
|
||||
models_pallet.Palette,
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
models_sequence.Sequence,
|
||||
models_device.Device,
|
||||
):
|
||||
if hasattr(cls, "_instance"):
|
||||
delattr(cls, "_instance")
|
||||
|
||||
orig_open = builtins.open
|
||||
|
||||
def patched_open(file, *args, **kwargs):
|
||||
if isinstance(file, str):
|
||||
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
|
||||
file = str(PROJECT_ROOT / "db" / "pattern.json")
|
||||
return orig_open(file, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", patched_open)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(str(SRC_PATH))
|
||||
|
||||
dummy_bridge = DummyBridge()
|
||||
|
||||
try:
|
||||
for mod_name in (
|
||||
"controllers.preset",
|
||||
"controllers.profile",
|
||||
"controllers.group",
|
||||
"controllers.sequence",
|
||||
"controllers.zone",
|
||||
"controllers.palette",
|
||||
"controllers.scene",
|
||||
"controllers.pattern",
|
||||
"controllers.settings",
|
||||
"controllers.device",
|
||||
"controllers.wifi_bridge",
|
||||
"fastapi_app",
|
||||
"app_factory",
|
||||
):
|
||||
sys.modules.pop(mod_name, None)
|
||||
|
||||
from models.transport import set_bridge # noqa: E402
|
||||
from fastapi_app import create_application # noqa: E402
|
||||
|
||||
set_bridge(dummy_bridge)
|
||||
app = create_application(test_mode=True)
|
||||
|
||||
with TestClient(app, raise_server_exceptions=True) as client:
|
||||
yield {
|
||||
"base_url": "",
|
||||
"client": client,
|
||||
"bridge": dummy_bridge,
|
||||
}
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
@@ -115,14 +115,36 @@ def parse_args() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _clamp_detected_bpm(bpm: float | None) -> float | None:
|
||||
if bpm is None:
|
||||
return None
|
||||
try:
|
||||
from util.bpm_limits import clamp_bpm_optional
|
||||
|
||||
return clamp_bpm_optional(bpm)
|
||||
except ImportError:
|
||||
v = float(bpm)
|
||||
if v <= 0:
|
||||
return None
|
||||
return max(60.0, min(200.0, v))
|
||||
|
||||
|
||||
def _estimate_bpm(beat_times: Deque[float]) -> float | None:
|
||||
if len(beat_times) < 3:
|
||||
return None
|
||||
try:
|
||||
from util.bpm_limits import max_beat_interval_s, min_beat_interval_s
|
||||
|
||||
ioi_min = min_beat_interval_s()
|
||||
ioi_max = max_beat_interval_s()
|
||||
except ImportError:
|
||||
ioi_min = 0.3
|
||||
ioi_max = 1.0
|
||||
intervals = np.diff(np.array(beat_times, dtype=np.float64))
|
||||
valid = intervals[(intervals > 0.2) & (intervals < 2.0)]
|
||||
valid = intervals[(intervals >= ioi_min) & (intervals <= ioi_max)]
|
||||
if valid.size == 0:
|
||||
return None
|
||||
return 60.0 / float(np.median(valid))
|
||||
return _clamp_detected_bpm(60.0 / float(np.median(valid)))
|
||||
|
||||
|
||||
def _is_plausible_ioi(
|
||||
@@ -131,7 +153,7 @@ def _is_plausible_ioi(
|
||||
now_s: float,
|
||||
*,
|
||||
min_ratio: float = 0.42,
|
||||
max_ratio: float = 2.5,
|
||||
max_ratio: float = 3.5,
|
||||
) -> bool:
|
||||
"""Reject double-time / half-time false triggers vs recent median interval."""
|
||||
if last_trigger_s <= 0 or len(beat_times) < 2:
|
||||
@@ -251,7 +273,7 @@ def _resolve_bpm(
|
||||
) -> float | None:
|
||||
estimated = _estimate_bpm(beat_times)
|
||||
if estimated is None:
|
||||
return aubio_bpm
|
||||
return _clamp_detected_bpm(aubio_bpm)
|
||||
if aubio_bpm is None or aubio_bpm <= 0:
|
||||
return estimated
|
||||
ratio = float(aubio_bpm) / estimated
|
||||
@@ -400,7 +422,7 @@ class BeatDetectRuntime:
|
||||
if self.tempo is not None:
|
||||
aubio_hit = bool(self.tempo(f32)[0])
|
||||
val = float(self.tempo.get_bpm())
|
||||
aubio_bpm = val if val > 0 else None
|
||||
aubio_bpm = _clamp_detected_bpm(val if val > 0 else None)
|
||||
|
||||
if now_s is None:
|
||||
now_s = time.time()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
pytest_plugins = ["api_server"]
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
LIB_PATH = PROJECT_ROOT / "lib"
|
||||
|
||||
# Last insert(0) wins: order must be (root, lib, src) so src/models wins over
|
||||
# Last insert(0) wins: order must be (root, src) so src/models wins over
|
||||
# tests/models (same package name "models" on sys.path when pytest imports tests).
|
||||
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)):
|
||||
for p in (str(PROJECT_ROOT), str(SRC_PATH)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
@@ -27,7 +27,6 @@ def test_sequence():
|
||||
assert sequence["lanes"] == [[]]
|
||||
assert sequence.get("lanes_group_ids") == [[]]
|
||||
assert sequence.get("advance_mode") == "beats"
|
||||
assert sequence.get("simulated_bpm") == 120
|
||||
assert sequence["step_duration_ms"] == 3000
|
||||
assert sequence["loop"] is True
|
||||
assert sequence.get("sequence_transition") == 500
|
||||
@@ -43,7 +42,6 @@ def test_sequence():
|
||||
"step_duration_ms": 5000,
|
||||
"loop": True,
|
||||
"advance_mode": "beats",
|
||||
"simulated_bpm": 128,
|
||||
}
|
||||
result = sequences.update(sequence_id, update_data)
|
||||
assert result is True
|
||||
@@ -58,7 +56,6 @@ def test_sequence():
|
||||
assert len(updated["lanes"][0]) == 2
|
||||
assert updated["lanes"][0][0]["beats"] == 2
|
||||
assert updated.get("advance_mode") == "beats"
|
||||
assert updated.get("simulated_bpm") == 128
|
||||
assert updated["step_duration_ms"] == 5000
|
||||
assert updated["loop"] is True
|
||||
|
||||
|
||||
105
tests/p2p.py
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# MicroPython script to test LED bar patterns over ESP-NOW (no WebSocket)
|
||||
|
||||
import json
|
||||
import uasyncio as asyncio
|
||||
|
||||
# Import P2P from src/p2p.py
|
||||
# Note: When running on device, ensure src/p2p.py is in the path
|
||||
try:
|
||||
from p2p import P2P
|
||||
except ImportError:
|
||||
# Fallback: import from src directory
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
from p2p import P2P
|
||||
|
||||
async def main():
|
||||
p2p = P2P()
|
||||
|
||||
# Test cases following msg.json format:
|
||||
# {"g": {"df": {...}, "group_name": {...}}, "sv": true, "st": 0}
|
||||
# Note: led-bar device must have matching group in settings["groups"]
|
||||
tests = [
|
||||
# Example 1: Default format with df defaults and dj group (matches msg.json)
|
||||
{
|
||||
"g": {
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"cl": ["#ff0000"],
|
||||
"br": 200,
|
||||
"n1": 10,
|
||||
"n2": 10,
|
||||
"n3": 10,
|
||||
"n4": 10,
|
||||
"n5": 10,
|
||||
"n6": 10,
|
||||
"dl": 100
|
||||
},
|
||||
"dj": {
|
||||
"pt": "blink",
|
||||
"cl": ["#00ff00"],
|
||||
"dl": 500
|
||||
}
|
||||
},
|
||||
"sv": True,
|
||||
"st": 0
|
||||
},
|
||||
# Example 2: Different group with df defaults
|
||||
{
|
||||
"g": {
|
||||
"df": {
|
||||
"pt": "on",
|
||||
"br": 150,
|
||||
"dl": 100
|
||||
},
|
||||
"group1": {
|
||||
"pt": "rainbow",
|
||||
"dl": 50
|
||||
}
|
||||
},
|
||||
"sv": False
|
||||
},
|
||||
# Example 3: Multiple groups
|
||||
{
|
||||
"g": {
|
||||
"df": {
|
||||
"br": 200,
|
||||
"dl": 100
|
||||
},
|
||||
"group1": {
|
||||
"pt": "on",
|
||||
"cl": ["#0000ff"]
|
||||
},
|
||||
"group2": {
|
||||
"pt": "blink",
|
||||
"cl": ["#ff00ff"],
|
||||
"dl": 300
|
||||
}
|
||||
},
|
||||
"sv": True,
|
||||
"st": 1
|
||||
},
|
||||
# Example 4: Single group without df
|
||||
{
|
||||
"g": {
|
||||
"dj": {
|
||||
"pt": "off"
|
||||
}
|
||||
},
|
||||
"sv": False
|
||||
}
|
||||
]
|
||||
|
||||
for i, test in enumerate(tests, 1):
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Test {i}/{len(tests)}")
|
||||
print(f"Sending: {json.dumps(test, indent=2)}")
|
||||
await p2p.send(json.dumps(test))
|
||||
await asyncio.sleep_ms(2000)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print("All tests completed")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,23 +1,18 @@
|
||||
"""Audio input device_select persistence (Pulse name must survive start)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
if str(SRC_PATH) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_PATH))
|
||||
|
||||
from microdot import Microdot # noqa: E402
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
|
||||
|
||||
SNOWBALL = (
|
||||
@@ -25,28 +20,6 @@ SNOWBALL = (
|
||||
)
|
||||
|
||||
|
||||
def _start_app(app: Microdot, port: int = 0):
|
||||
def runner():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
thread = threading.Thread(target=runner, daemon=True)
|
||||
thread.start()
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
server = getattr(app, "server", None)
|
||||
if server and getattr(server, "sockets", None):
|
||||
sockets = server.sockets or []
|
||||
if sockets:
|
||||
return thread, sockets[0].getsockname()[1]
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError("server failed to start")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio_run_path(tmp_path, monkeypatch):
|
||||
path = tmp_path / "audio_run.json"
|
||||
@@ -69,12 +42,12 @@ def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_pat
|
||||
|
||||
|
||||
def test_put_device_saves_pulse_name(audio_run_path):
|
||||
app = Microdot()
|
||||
api = FastAPI()
|
||||
|
||||
@app.route("/api/audio/device", methods=["PUT"])
|
||||
async def audio_set_device(request):
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
@api.put("/api/audio/device")
|
||||
async def audio_set_device(payload: dict | None = None):
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||
|
||||
prev = read_audio_run_state()
|
||||
@@ -86,13 +59,11 @@ def test_put_device_saves_pulse_name(audio_run_path):
|
||||
)
|
||||
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||
|
||||
_, port = _start_app(app)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
resp = requests.put(
|
||||
f"{base}/api/audio/device",
|
||||
json={"device_select": SNOWBALL, "device_override": ""},
|
||||
timeout=5,
|
||||
)
|
||||
with TestClient(api) as client:
|
||||
resp = client.put(
|
||||
"/api/audio/device",
|
||||
json={"device_select": SNOWBALL, "device_override": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["audio_run"]["device_select"] == SNOWBALL
|
||||
@@ -112,15 +83,15 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
|
||||
fake_resolve,
|
||||
)
|
||||
|
||||
app = Microdot()
|
||||
api = FastAPI()
|
||||
|
||||
@app.route("/api/audio/start", methods=["POST"])
|
||||
async def audio_start(request):
|
||||
payload = request.json if isinstance(request.json, dict) else {}
|
||||
device = payload.get("device", None)
|
||||
@api.post("/api/audio/start")
|
||||
async def audio_start(payload: dict | None = None):
|
||||
body = payload if isinstance(payload, dict) else {}
|
||||
device = body.get("device", None)
|
||||
if device in ("", None):
|
||||
device = None
|
||||
device_select = str(payload.get("device_select") or "").strip()
|
||||
device_select = str(body.get("device_select") or "").strip()
|
||||
if not device_select and device not in ("", None):
|
||||
device_select = str(device).strip()
|
||||
from util.pulse_audio_devices import resolve_capture_device
|
||||
@@ -138,29 +109,26 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return {"ok": True, "status": st}
|
||||
|
||||
@app.route("/api/audio/status")
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
@api.get("/api/audio/status")
|
||||
async def audio_status():
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st = detector.status()
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return {"status": st}
|
||||
|
||||
_, port = _start_app(app)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
start = requests.post(
|
||||
f"{base}/api/audio/start",
|
||||
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
|
||||
timeout=5,
|
||||
)
|
||||
assert start.status_code == 200, start.text
|
||||
run = start.json()["status"]["audio_run"]
|
||||
assert run["device_select"] == SNOWBALL
|
||||
assert run["device"] == 2
|
||||
with TestClient(api) as client:
|
||||
start = client.post(
|
||||
"/api/audio/start",
|
||||
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
|
||||
)
|
||||
assert start.status_code == 200, start.text
|
||||
run = start.json()["status"]["audio_run"]
|
||||
assert run["device_select"] == SNOWBALL
|
||||
assert run["device"] == 2
|
||||
|
||||
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"]
|
||||
assert status["audio_run"]["device_select"] == SNOWBALL
|
||||
status = client.get("/api/audio/status").json()["status"]
|
||||
assert status["audio_run"]["device_select"] == SNOWBALL
|
||||
|
||||
|
||||
def test_pulse_device_list_uses_stable_pulse_ids():
|
||||
|
||||
@@ -9,7 +9,11 @@ SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||
if SRC_PATH not in sys.path:
|
||||
sys.path.insert(0, SRC_PATH)
|
||||
|
||||
from util.audio_detector import AudioBeatDetector # noqa: E402
|
||||
from util.audio_detector import ( # noqa: E402
|
||||
AudioBeatDetector,
|
||||
set_shared_beat_detector,
|
||||
shared_beat_detector_timing_sequences,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRuntime:
|
||||
@@ -83,7 +87,47 @@ def test_silence_gap_starts_holdover_and_resets_tempo_once():
|
||||
det._maybe_recover_after_silence_gap(rt)
|
||||
assert rt.reset_tempo_calls == 1
|
||||
det._record_beat(120.0)
|
||||
assert det._holdover_active is False
|
||||
assert det._holdover_active is True
|
||||
|
||||
|
||||
def test_timing_sequences_true_while_holdover_active():
|
||||
det = AudioBeatDetector()
|
||||
set_shared_beat_detector(det)
|
||||
try:
|
||||
with det._lock:
|
||||
det._running = True
|
||||
det._status["running"] = True
|
||||
det._status["bpm"] = 120.0
|
||||
det._record_beat(120.0)
|
||||
assert det._holdover_active is True
|
||||
assert shared_beat_detector_timing_sequences() is True
|
||||
finally:
|
||||
set_shared_beat_detector(None)
|
||||
|
||||
|
||||
def test_timing_sequences_false_when_running_without_beats():
|
||||
det = AudioBeatDetector()
|
||||
set_shared_beat_detector(det)
|
||||
try:
|
||||
with det._lock:
|
||||
det._running = True
|
||||
det._status["running"] = True
|
||||
assert shared_beat_detector_timing_sequences() is False
|
||||
det._record_beat(120.0)
|
||||
assert shared_beat_detector_timing_sequences() is True
|
||||
det._stop_bpm_holdover()
|
||||
with det._lock:
|
||||
det._last_real_beat_ts = time.time() - 5.0
|
||||
assert shared_beat_detector_timing_sequences() is False
|
||||
finally:
|
||||
set_shared_beat_detector(None)
|
||||
|
||||
|
||||
def test_record_beat_keeps_previous_bpm_when_new_readout_invalid():
|
||||
det = AudioBeatDetector()
|
||||
det._record_beat(128.0)
|
||||
det._record_beat(None)
|
||||
assert det.status()["bpm"] == 128.0
|
||||
|
||||
|
||||
def test_holdover_last_beat_does_not_block_tempo_retry():
|
||||
|
||||
34
tests/test_audio_sse.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Server-sent events for audio/beat status."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initial_sse_line_includes_status(monkeypatch):
|
||||
from util import beat_status_broadcaster as bsb
|
||||
|
||||
bsb.configure(
|
||||
loop=asyncio.get_running_loop(),
|
||||
status_builder=lambda: {"bpm_simulated": True, "beat_seq": 3},
|
||||
)
|
||||
line = await bsb.initial_sse_line()
|
||||
assert line.startswith("data: ")
|
||||
payload = json.loads(line[6:])
|
||||
assert payload["type"] == "status"
|
||||
assert payload["status"]["beat_seq"] == 3
|
||||
|
||||
|
||||
def test_audio_events_sse_first_chunk(server):
|
||||
c = server["client"]
|
||||
with c.stream("GET", "/api/audio/events") as resp:
|
||||
assert resp.status_code == 200
|
||||
assert "text/event-stream" in resp.headers.get("content-type", "")
|
||||
chunk = next(resp.iter_bytes())
|
||||
text = chunk.decode("utf-8")
|
||||
assert text.startswith("data: ")
|
||||
payload = json.loads(text.strip().removeprefix("data: "))
|
||||
assert payload.get("type") == "status"
|
||||
assert "bpm_simulated" in payload.get("status", {})
|
||||
@@ -26,3 +26,14 @@ def test_resolve_bpm_prefers_intervals_over_wrong_aubio():
|
||||
bpm = _resolve_bpm(times, 70.0)
|
||||
assert bpm is not None
|
||||
assert abs(bpm - 120.0) < 5.0
|
||||
|
||||
|
||||
def test_resolve_bpm_clamps_runaway_aubio():
|
||||
times = deque([0.0])
|
||||
assert _resolve_bpm(times, 400.0) == 200.0
|
||||
assert _resolve_bpm(times, 999.0) == 200.0
|
||||
|
||||
|
||||
def test_resolve_bpm_clamps_slow_aubio():
|
||||
times = deque([0.0])
|
||||
assert _resolve_bpm(times, 30.0) == 60.0
|
||||
|
||||
45
tests/test_bpm_limits.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""BPM clamp helpers."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
|
||||
if SRC_PATH not in sys.path:
|
||||
sys.path.insert(0, SRC_PATH)
|
||||
|
||||
from util.audio_detector import AudioBeatDetector # noqa: E402
|
||||
from util.bpm_limits import (
|
||||
BPM_MAX,
|
||||
BPM_MIN,
|
||||
clamp_bpm,
|
||||
clamp_bpm_optional,
|
||||
max_beat_interval_s,
|
||||
max_beat_min_ioi_ms,
|
||||
min_beat_interval_s,
|
||||
)
|
||||
|
||||
|
||||
def test_clamp_bpm_bounds():
|
||||
assert clamp_bpm(120) == 120.0
|
||||
assert clamp_bpm(400) == float(BPM_MAX)
|
||||
assert clamp_bpm(20) == float(BPM_MIN)
|
||||
|
||||
|
||||
def test_clamp_bpm_optional():
|
||||
assert clamp_bpm_optional(None) is None
|
||||
assert clamp_bpm_optional(0) is None
|
||||
assert clamp_bpm_optional(350) == float(BPM_MAX)
|
||||
|
||||
|
||||
def test_beat_interval_bounds():
|
||||
assert abs(min_beat_interval_s() - 60.0 / BPM_MAX) < 1e-9
|
||||
assert abs(max_beat_interval_s() - 60.0 / BPM_MIN) < 1e-9
|
||||
assert abs(max_beat_min_ioi_ms() - 60_000.0 / BPM_MAX) < 1e-6
|
||||
|
||||
|
||||
def test_status_clamps_high_bpm():
|
||||
det = AudioBeatDetector()
|
||||
with det._lock:
|
||||
det._status["bpm"] = 350.0
|
||||
assert det.status()["bpm"] == float(BPM_MAX)
|
||||
@@ -43,18 +43,32 @@ def test_deliver_json_messages_defaults_broadcast():
|
||||
def __init__(self):
|
||||
self.keys = []
|
||||
|
||||
async def send(self, envelope):
|
||||
async def send(self, envelope, addr=None):
|
||||
del addr
|
||||
devs = envelope.get("dv") or envelope.get("devices") or {}
|
||||
self.keys.extend(devs.keys())
|
||||
return True
|
||||
|
||||
class _Devices:
|
||||
def read(self, mac):
|
||||
return {
|
||||
"id": mac,
|
||||
"name": mac,
|
||||
"transport": "espnow",
|
||||
"address": mac,
|
||||
}
|
||||
|
||||
def items(self):
|
||||
return []
|
||||
|
||||
async def _run():
|
||||
bridge = _Bridge()
|
||||
await deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": ["2"]})],
|
||||
["188b0e1560a8", "e8f60a16ea10"],
|
||||
None,
|
||||
_Devices(),
|
||||
delay_s=0,
|
||||
)
|
||||
return bridge.keys
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_send_returns_false_when_not_connected():
|
||||
async def _run():
|
||||
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
|
||||
|
||||
async def _no_wait(_timeout=30.0):
|
||||
async def _no_wait(timeout=30.0):
|
||||
return False
|
||||
|
||||
client.wait_connected = _no_wait # type: ignore[method-assign]
|
||||
|
||||
@@ -607,39 +607,45 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
|
||||
return passed == total
|
||||
|
||||
|
||||
def test_mobile_tab_presets_two_columns():
|
||||
def test_mobile_tab_presets_three_columns():
|
||||
"""
|
||||
Verify that the zone preset selecting area shows roughly two preset tiles per row
|
||||
on a phone-sized viewport.
|
||||
On a phone-sized viewport the zone strip is hidden; zones are chosen from the
|
||||
header Zones menu. Preset tiles use a 3-column grid (see style.css).
|
||||
"""
|
||||
bt = BrowserTest(base_url=BASE_URL, headless=True)
|
||||
if not bt.setup():
|
||||
assert False, "Failed to start browser"
|
||||
|
||||
try:
|
||||
# Simulate a mobile viewport
|
||||
bt.driver.set_window_size(400, 800)
|
||||
assert bt.navigate('/'), "Failed to load main page"
|
||||
|
||||
# Click the first zone button to load presets for that zone
|
||||
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10)
|
||||
assert first_tab is not None, "No zone buttons found"
|
||||
first_tab.click()
|
||||
# Desktop zone buttons live in .zones-container which is display:none on mobile.
|
||||
WebDriverWait(bt.driver, 10).until(
|
||||
EC.presence_of_element_located(
|
||||
(By.CSS_SELECTOR, '#zones-menu-dropdown .zones-menu-item')
|
||||
)
|
||||
)
|
||||
assert bt.click_element(By.ID, 'zones-menu-btn'), "Failed to open Zones menu"
|
||||
assert bt.click_element(
|
||||
By.CSS_SELECTOR, '#zones-menu-dropdown .zones-menu-item'
|
||||
), "Failed to select zone from mobile menu"
|
||||
_browser_sleep(1)
|
||||
|
||||
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
|
||||
assert container is not None, "presets-list-zone not found"
|
||||
|
||||
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row')
|
||||
# Need at least 2 presets to make this meaningful
|
||||
tiles = bt.driver.find_elements(
|
||||
By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row'
|
||||
)
|
||||
assert len(tiles) >= 2, "Fewer than 2 presets found for zone"
|
||||
|
||||
container_width = container.size['width']
|
||||
first_width = tiles[0].size['width']
|
||||
|
||||
# Each tile should be about half the container width (tolerate some margin)
|
||||
assert 0.4 * container_width <= first_width <= 0.6 * container_width, (
|
||||
f"Preset tile width {first_width} not ~half of container {container_width}"
|
||||
# Three columns on max-width 600px (~one third of the row, minus gaps).
|
||||
assert 0.22 * container_width <= first_width <= 0.42 * container_width, (
|
||||
f"Preset tile width {first_width} not ~third of container {container_width}"
|
||||
)
|
||||
finally:
|
||||
bt.teardown()
|
||||
|
||||
461
tests/test_driver_delivery_wifi.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""Tests for dual-transport delivery (ESP-NOW bridge + Wi-Fi WebSocket) and Wi-Fi runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
|
||||
for p in (str(PROJECT_ROOT), str(SRC_PATH)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
_models = sys.modules.get("models")
|
||||
if _models is not None:
|
||||
_mf = (getattr(_models, "__file__", "") or "").replace("\\", "/")
|
||||
if "/tests/models" in _mf:
|
||||
for key in list(sys.modules):
|
||||
if key == "models" or key.startswith("models."):
|
||||
del sys.modules[key]
|
||||
|
||||
from util.bridge_envelope import BROADCAST_MAC # noqa: E402
|
||||
|
||||
|
||||
class FakeDevices:
|
||||
def __init__(self, docs: Dict[str, Dict[str, Any]]):
|
||||
self._docs = docs
|
||||
|
||||
def read(self, mac: str) -> Optional[Dict[str, Any]]:
|
||||
return self._docs.get(mac)
|
||||
|
||||
def items(self):
|
||||
return self._docs.items()
|
||||
|
||||
|
||||
class RecordingBridge:
|
||||
def __init__(self) -> None:
|
||||
self.envelopes: List[Dict[str, Any]] = []
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
del addr
|
||||
if isinstance(data, dict):
|
||||
self.envelopes.append(data)
|
||||
elif isinstance(data, str):
|
||||
self.envelopes.append(json.loads(data))
|
||||
return True
|
||||
|
||||
def mac_keys(self) -> List[str]:
|
||||
keys: List[str] = []
|
||||
for env in self.envelopes:
|
||||
devs = env.get("dv") or env.get("devices") or {}
|
||||
keys.extend(devs.keys())
|
||||
return keys
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge():
|
||||
return RecordingBridge()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def espnow_devices():
|
||||
return FakeDevices(
|
||||
{
|
||||
"188b0e1560a8": {
|
||||
"id": "188b0e1560a8",
|
||||
"name": "esp-a",
|
||||
"transport": "espnow",
|
||||
"address": "188b0e1560a8",
|
||||
},
|
||||
"e8f60a16ea10": {
|
||||
"id": "e8f60a16ea10",
|
||||
"name": "esp-b",
|
||||
"transport": "espnow",
|
||||
"address": "e8f60a16ea10",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mixed_devices():
|
||||
return FakeDevices(
|
||||
{
|
||||
"188b0e1560a8": {
|
||||
"id": "188b0e1560a8",
|
||||
"name": "esp-a",
|
||||
"transport": "espnow",
|
||||
"address": "188b0e1560a8",
|
||||
},
|
||||
"102030405060": {
|
||||
"id": "102030405060",
|
||||
"name": "wifi-a",
|
||||
"transport": "wifi",
|
||||
"address": "192.168.50.10",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_wifi_message_for_device_narrows_select():
|
||||
from util.driver_delivery import _wifi_message_for_device
|
||||
|
||||
msg = json.dumps(
|
||||
{"v": "1", "select": {"wifi-a": 0, "esp-a": 1}},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
narrowed = _wifi_message_for_device(msg, "wifi-a")
|
||||
body = json.loads(narrowed)
|
||||
assert body["select"] == {"wifi-a": 0}
|
||||
|
||||
|
||||
def test_combine_preset_chunks_for_wifi():
|
||||
from util.driver_delivery import _combine_preset_chunks_for_wifi
|
||||
|
||||
chunks = [
|
||||
json.dumps({"v": "1", "presets": {"a": {"p": "on"}}}, separators=(",", ":")),
|
||||
json.dumps(
|
||||
{"v": "1", "presets": {"b": {"p": "blink"}}, "save": True, "default": "b"},
|
||||
separators=(",", ":"),
|
||||
),
|
||||
]
|
||||
combined = json.loads(_combine_preset_chunks_for_wifi(chunks))
|
||||
assert combined["presets"]["a"]["p"] == "on"
|
||||
assert combined["presets"]["b"]["p"] == "blink"
|
||||
assert combined["save"] is True
|
||||
assert combined["default"] == "b"
|
||||
|
||||
|
||||
def test_deliver_json_broadcast_espnow_only(bridge, espnow_devices, monkeypatch):
|
||||
from util import driver_delivery
|
||||
|
||||
wifi_sends: list[tuple[str, str]] = []
|
||||
|
||||
async def fake_wifi(ip, msg):
|
||||
wifi_sends.append((ip, msg))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
|
||||
|
||||
async def _run():
|
||||
return await driver_delivery.deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": ["off"]})],
|
||||
None,
|
||||
espnow_devices,
|
||||
delay_s=0,
|
||||
)
|
||||
|
||||
deliveries, n = asyncio.run(_run())
|
||||
assert n == 1
|
||||
assert deliveries >= 1
|
||||
assert bridge.mac_keys() == [BROADCAST_MAC]
|
||||
assert wifi_sends == []
|
||||
|
||||
|
||||
def test_deliver_json_broadcast_includes_wifi(bridge, mixed_devices, monkeypatch):
|
||||
from util import driver_delivery
|
||||
|
||||
wifi_sends: list[tuple[str, str]] = []
|
||||
|
||||
async def fake_wifi(ip, msg):
|
||||
wifi_sends.append((ip, msg))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
|
||||
|
||||
async def _run():
|
||||
return await driver_delivery.deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": ["off"]})],
|
||||
None,
|
||||
mixed_devices,
|
||||
delay_s=0,
|
||||
)
|
||||
|
||||
deliveries, _n = asyncio.run(_run())
|
||||
assert deliveries >= 2
|
||||
assert bridge.mac_keys() == [BROADCAST_MAC]
|
||||
assert len(wifi_sends) == 1
|
||||
assert wifi_sends[0][0] == "192.168.50.10"
|
||||
|
||||
|
||||
def test_deliver_json_targeted_espnow_unicasts(bridge, espnow_devices, monkeypatch):
|
||||
from util import driver_delivery
|
||||
|
||||
monkeypatch.setattr(
|
||||
driver_delivery,
|
||||
"send_json_line_to_ip",
|
||||
AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
async def _run():
|
||||
return await driver_delivery.deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": ["2"]})],
|
||||
["188b0e1560a8", "e8f60a16ea10"],
|
||||
espnow_devices,
|
||||
delay_s=0,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
keys = bridge.mac_keys()
|
||||
assert "18:8b:0e:15:60:a8" in keys
|
||||
assert "e8:f6:0a:16:ea:10" in keys
|
||||
assert BROADCAST_MAC not in keys
|
||||
|
||||
|
||||
def test_deliver_json_targeted_wifi_uses_websocket(bridge, mixed_devices, monkeypatch):
|
||||
from util import driver_delivery
|
||||
|
||||
wifi_sends: list[tuple[str, str]] = []
|
||||
|
||||
async def fake_wifi(ip, msg):
|
||||
wifi_sends.append((ip, msg))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
|
||||
|
||||
async def _run():
|
||||
await driver_delivery.deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": {"wifi-a": 0}})],
|
||||
["102030405060"],
|
||||
mixed_devices,
|
||||
delay_s=0,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
assert bridge.mac_keys() == []
|
||||
assert len(wifi_sends) == 1
|
||||
assert wifi_sends[0][0] == "192.168.50.10"
|
||||
body = json.loads(wifi_sends[0][1])
|
||||
assert body["select"] == {"wifi-a": 0}
|
||||
|
||||
|
||||
def test_deliver_json_unicast_flag_wifi(bridge, mixed_devices, monkeypatch):
|
||||
from util import driver_delivery
|
||||
|
||||
wifi_sends: list[str] = []
|
||||
|
||||
async def fake_wifi(ip, msg):
|
||||
wifi_sends.append(msg)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
|
||||
|
||||
async def _run():
|
||||
await driver_delivery.deliver_json_messages(
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "b": 128})],
|
||||
["102030405060"],
|
||||
mixed_devices,
|
||||
delay_s=0,
|
||||
unicast=True,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(wifi_sends) == 1
|
||||
assert bridge.mac_keys() == []
|
||||
|
||||
|
||||
def test_deliver_preset_broadcast_then_per_device_wifi(
|
||||
bridge, mixed_devices, monkeypatch
|
||||
):
|
||||
from util import driver_delivery
|
||||
|
||||
wifi_sends: list[str] = []
|
||||
|
||||
async def fake_wifi(ip, msg):
|
||||
wifi_sends.append(msg)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
|
||||
|
||||
chunks = [
|
||||
json.dumps(
|
||||
{"v": "1", "presets": {"p1": {"p": "on"}}, "save": True},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
]
|
||||
|
||||
async def _run():
|
||||
return await driver_delivery.deliver_preset_broadcast_then_per_device(
|
||||
bridge,
|
||||
chunks,
|
||||
None,
|
||||
mixed_devices,
|
||||
default_id=None,
|
||||
delay_s=0,
|
||||
)
|
||||
|
||||
count = asyncio.run(_run())
|
||||
assert count >= 2
|
||||
assert bridge.mac_keys() == [BROADCAST_MAC]
|
||||
assert len(wifi_sends) == 1
|
||||
combined = json.loads(wifi_sends[0])
|
||||
assert "p1" in combined["presets"]
|
||||
|
||||
|
||||
def test_deliver_json_requires_bridge(monkeypatch):
|
||||
from util import driver_delivery
|
||||
import models.transport as transport_mod
|
||||
|
||||
monkeypatch.setattr(transport_mod, "get_current_bridge", lambda: None)
|
||||
|
||||
async def _run():
|
||||
with pytest.raises(RuntimeError, match="Transport not configured"):
|
||||
await driver_delivery.deliver_json_messages(
|
||||
None, ["{}"], None, FakeDevices({}), delay_s=0
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_device_status_broadcaster_send_text():
|
||||
from util.device_status_broadcaster import (
|
||||
_ws_send_text,
|
||||
broadcast_device_tcp_snapshot_to,
|
||||
broadcast_device_tcp_status,
|
||||
register_device_status_ws,
|
||||
unregister_device_status_ws,
|
||||
)
|
||||
|
||||
class StarletteLikeWS:
|
||||
def __init__(self):
|
||||
self.out: list[str] = []
|
||||
|
||||
async def send_text(self, msg: str):
|
||||
self.out.append(msg)
|
||||
|
||||
class SendTextOnlyWS:
|
||||
def __init__(self):
|
||||
self.out: list[str] = []
|
||||
|
||||
async def send(self, msg: str):
|
||||
self.out.append(msg)
|
||||
|
||||
async def _run():
|
||||
starlette = StarletteLikeWS()
|
||||
legacy_ws = SendTextOnlyWS()
|
||||
await _ws_send_text(starlette, '{"ok":true}')
|
||||
await _ws_send_text(legacy_ws, '{"ok":true}')
|
||||
assert starlette.out == ['{"ok":true}']
|
||||
assert legacy_ws.out == ['{"ok":true}']
|
||||
|
||||
await register_device_status_ws(starlette)
|
||||
await broadcast_device_tcp_status("192.168.1.5", True)
|
||||
assert len(starlette.out) == 2
|
||||
status = json.loads(starlette.out[1])
|
||||
assert status["type"] == "device_tcp"
|
||||
assert status["ip"] == "192.168.1.5"
|
||||
assert status["connected"] is True
|
||||
|
||||
await broadcast_device_tcp_snapshot_to(starlette)
|
||||
snapshot = json.loads(starlette.out[2])
|
||||
assert snapshot["type"] == "device_tcp_snapshot"
|
||||
assert "connected_ips" in snapshot
|
||||
|
||||
await unregister_device_status_ws(starlette)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_process_udp_datagram_registers_and_connects(monkeypatch):
|
||||
from util import wifi_driver_runtime
|
||||
|
||||
registered: list[tuple[str, str, str]] = []
|
||||
connected: list[str] = []
|
||||
|
||||
def fake_register(device_name, peer_ip, mac, device_type=None):
|
||||
del device_type
|
||||
registered.append((device_name, peer_ip, str(mac)))
|
||||
|
||||
monkeypatch.setattr(
|
||||
wifi_driver_runtime,
|
||||
"_register_udp_device_sync",
|
||||
fake_register,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
wifi_driver_runtime.tcp_client_registry,
|
||||
"ensure_driver_connection",
|
||||
lambda ip: connected.append(ip),
|
||||
)
|
||||
|
||||
line = json.dumps(
|
||||
{"v": "1", "device_name": "strip-a", "mac": "aabbccddeeff", "type": "led"}
|
||||
).encode()
|
||||
wifi_driver_runtime._process_udp_datagram(line, "192.168.1.42")
|
||||
assert registered == [("strip-a", "192.168.1.42", "aabbccddeeff")]
|
||||
assert connected == ["192.168.1.42"]
|
||||
|
||||
|
||||
def test_process_udp_datagram_ignores_invalid():
|
||||
from util.wifi_driver_runtime import _process_udp_datagram
|
||||
|
||||
_process_udp_datagram(b"not-json\n", "10.0.0.1")
|
||||
_process_udp_datagram(b'{"v":"1"}\n', "10.0.0.1")
|
||||
|
||||
|
||||
def test_discovery_protocol_uses_datagram_endpoint(monkeypatch):
|
||||
pytest.importorskip("uvloop")
|
||||
import uvloop
|
||||
|
||||
from util.wifi_driver_runtime import _DiscoveryProtocol
|
||||
|
||||
async def _run():
|
||||
echoed: list[bytes] = []
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
holder: dict = {"closing": False}
|
||||
loop = asyncio.get_running_loop()
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
transport, _protocol = await loop.create_datagram_endpoint(
|
||||
lambda: _DiscoveryProtocol(holder),
|
||||
sock=sock,
|
||||
)
|
||||
|
||||
class _EchoClient(asyncio.DatagramProtocol):
|
||||
def connection_made(self, t):
|
||||
self._transport = t
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
del addr
|
||||
echoed.append(data)
|
||||
|
||||
client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
client_sock.bind(("127.0.0.1", 0))
|
||||
client_transport, _ = await loop.create_datagram_endpoint(
|
||||
_EchoClient,
|
||||
sock=client_sock,
|
||||
)
|
||||
payload = b'{"v":"1","device_name":"x","mac":"112233445566"}\n'
|
||||
client_transport.sendto(payload, ("127.0.0.1", port))
|
||||
await asyncio.sleep(0.05)
|
||||
holder["closing"] = True
|
||||
client_transport.close()
|
||||
transport.close()
|
||||
return echoed, payload
|
||||
|
||||
monkeypatch.setattr(
|
||||
"util.wifi_driver_runtime._register_udp_device_sync",
|
||||
lambda *a, **k: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"util.wifi_driver_runtime.tcp_client_registry.ensure_driver_connection",
|
||||
lambda _ip: None,
|
||||
)
|
||||
echoed, payload = asyncio.run(_run())
|
||||
assert echoed == [payload]
|
||||
@@ -1,44 +1,18 @@
|
||||
import asyncio
|
||||
import builtins
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
# Ensure imports resolve to the repo's `src/` + `lib/` code.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
LIB_PATH = PROJECT_ROOT / "lib"
|
||||
|
||||
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
from microdot import Microdot, send_file # noqa: E402
|
||||
from microdot.session import Session # noqa: E402
|
||||
from microdot.websocket import with_websocket # noqa: E402
|
||||
from api_server import ( # noqa: E402
|
||||
DummyBridge,
|
||||
bridge_sent_envelope,
|
||||
device_body_from_envelope,
|
||||
)
|
||||
|
||||
|
||||
class DummyBridge:
|
||||
def __init__(self):
|
||||
self.sent: list[tuple[str, Optional[str]]] = []
|
||||
|
||||
async def send(self, data: Any, addr: Optional[str] = None):
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data).decode(errors="ignore")
|
||||
self.sent.append((data, addr))
|
||||
return True
|
||||
|
||||
|
||||
def _json(resp: requests.Response) -> Dict[str, Any]:
|
||||
def _json(resp) -> Dict[str, Any]:
|
||||
# Many endpoints already set Content-Type; but be tolerant for now.
|
||||
return resp.json() # pragma: no cover
|
||||
|
||||
@@ -50,7 +24,7 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
|
||||
raise AssertionError(f"Could not find id for {field}={value!r}")
|
||||
|
||||
|
||||
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
||||
def _create_and_apply_profile(c, base_url: str) -> str:
|
||||
"""Sequences/scenes/presets need an active profile in session."""
|
||||
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
|
||||
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
|
||||
@@ -61,243 +35,8 @@ def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
|
||||
return str(profile_id)
|
||||
|
||||
|
||||
def _start_microdot_server(app: Microdot, host: str, port: int):
|
||||
"""
|
||||
Start Microdot server on a background thread.
|
||||
Returns (thread, chosen_port).
|
||||
"""
|
||||
|
||||
def runner():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(app.start_server(host=host, port=port))
|
||||
finally:
|
||||
try:
|
||||
loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=runner, daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Poll until the socket is bound and app.server is available.
|
||||
chosen_port = None
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
server = getattr(app, "server", None)
|
||||
if server and getattr(server, "sockets", None):
|
||||
sockets = server.sockets or []
|
||||
if sockets:
|
||||
chosen_port = sockets[0].getsockname()[1]
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
if chosen_port is None:
|
||||
raise RuntimeError("Microdot server failed to start in time")
|
||||
|
||||
return thread, chosen_port
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def server(monkeypatch, tmp_path_factory):
|
||||
"""
|
||||
Start the Microdot app in-process and return a test client.
|
||||
"""
|
||||
|
||||
tmp_root = tmp_path_factory.mktemp("endpoint-tests")
|
||||
tmp_db_dir = tmp_root / "db"
|
||||
tmp_settings_file = tmp_root / "settings.json"
|
||||
|
||||
# Be defensive: pytest runners can sometimes alter sys.path ordering.
|
||||
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)):
|
||||
if p in sys.path:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
# Patch Settings so endpoint tests never touch real `settings.json`.
|
||||
import settings as settings_mod # noqa: E402
|
||||
|
||||
settings_mod.Settings.SETTINGS_FILE = str(tmp_settings_file)
|
||||
|
||||
# Patch the Model db directory so endpoint CRUD is isolated.
|
||||
import models.model as model_mod # noqa: E402
|
||||
|
||||
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(tmp_db_dir))
|
||||
|
||||
# Reset model singletons (controllers instantiate model classes at import time).
|
||||
# Import the classes first so we can delete their `_instance` attribute if present.
|
||||
import models.preset as models_preset # noqa: E402
|
||||
import models.profile as models_profile # noqa: E402
|
||||
import models.group as models_group # noqa: E402
|
||||
import models.zone as models_zone # noqa: E402
|
||||
import models.pallet as models_pallet # noqa: E402
|
||||
import models.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
import models.sequence as models_sequence # noqa: E402
|
||||
import models.device as models_device # noqa: E402
|
||||
|
||||
for cls in (
|
||||
models_preset.Preset,
|
||||
models_profile.Profile,
|
||||
models_group.Group,
|
||||
models_zone.Zone,
|
||||
models_pallet.Palette,
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
models_sequence.Sequence,
|
||||
models_device.Device,
|
||||
):
|
||||
if hasattr(cls, "_instance"):
|
||||
delattr(cls, "_instance")
|
||||
|
||||
# Patch open() so pattern definitions work after we `chdir` into src/.
|
||||
orig_open = builtins.open
|
||||
|
||||
def patched_open(file, *args, **kwargs):
|
||||
if isinstance(file, str):
|
||||
# Pattern controller loads definitions from a relative db/ path.
|
||||
if file in {"db/pattern.json", "pattern.json", "/db/pattern.json"}:
|
||||
file = str(PROJECT_ROOT / "db" / "pattern.json")
|
||||
return orig_open(file, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "open", patched_open)
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(str(SRC_PATH))
|
||||
|
||||
dummy_bridge = DummyBridge()
|
||||
|
||||
try:
|
||||
# Ensure controllers are imported fresh after our patching.
|
||||
for mod_name in (
|
||||
"controllers.preset",
|
||||
"controllers.profile",
|
||||
"controllers.group",
|
||||
"controllers.sequence",
|
||||
"controllers.zone",
|
||||
"controllers.palette",
|
||||
"controllers.scene",
|
||||
"controllers.pattern",
|
||||
"controllers.settings",
|
||||
"controllers.device",
|
||||
):
|
||||
sys.modules.pop(mod_name, None)
|
||||
|
||||
# Import controllers after patching db/settings/model singletons.
|
||||
import controllers.preset as preset_ctl # noqa: E402
|
||||
import controllers.profile as profile_ctl # noqa: E402
|
||||
import controllers.group as group_ctl # noqa: E402
|
||||
import controllers.sequence as sequence_ctl # noqa: E402
|
||||
import controllers.zone as zone_ctl # noqa: E402
|
||||
import controllers.palette as palette_ctl # noqa: E402
|
||||
import controllers.scene as scene_ctl # noqa: E402
|
||||
import controllers.pattern as pattern_ctl # noqa: E402
|
||||
import controllers.settings as settings_ctl # noqa: E402
|
||||
import controllers.device as device_ctl # noqa: E402
|
||||
|
||||
# Configure transport bridge used by /presets/send.
|
||||
from models.transport import set_bridge # noqa: E402
|
||||
|
||||
set_bridge(dummy_bridge)
|
||||
|
||||
app = Microdot()
|
||||
|
||||
# Session secret key comes from settings (patched to tmp).
|
||||
settings = settings_mod.Settings()
|
||||
secret_key = settings.get(
|
||||
"session_secret_key",
|
||||
"led-controller-secret-key-change-in-production",
|
||||
)
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
# Mount model controllers under their public prefixes.
|
||||
app.mount(preset_ctl.controller, "/presets")
|
||||
app.mount(profile_ctl.controller, "/profiles")
|
||||
app.mount(group_ctl.controller, "/groups")
|
||||
app.mount(sequence_ctl.controller, "/sequences")
|
||||
app.mount(zone_ctl.controller, "/zones")
|
||||
app.mount(palette_ctl.controller, "/palettes")
|
||||
app.mount(scene_ctl.controller, "/scenes")
|
||||
app.mount(pattern_ctl.controller, "/patterns")
|
||||
app.mount(settings_ctl.controller, "/settings")
|
||||
app.mount(device_ctl.controller, "/devices")
|
||||
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
return send_file("templates/index.html")
|
||||
|
||||
@app.route("/settings")
|
||||
def settings_page(request):
|
||||
return send_file("templates/settings.html")
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon(request):
|
||||
return "", 204
|
||||
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
if ".." in path:
|
||||
return "Not found", 404
|
||||
return send_file("static/" + path)
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws(request, ws):
|
||||
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if not data:
|
||||
break
|
||||
try:
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
payload = json.dumps(parsed) if parsed else data
|
||||
await dummy_bridge.send(payload, addr=addr)
|
||||
except Exception:
|
||||
await dummy_bridge.send(data)
|
||||
|
||||
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
|
||||
base_url = f"http://127.0.0.1:{chosen_port}"
|
||||
|
||||
client = requests.Session()
|
||||
client.headers.update(
|
||||
{
|
||||
"User-Agent": "pytest/requests",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
yield {
|
||||
"base_url": base_url,
|
||||
"client": client,
|
||||
"bridge": dummy_bridge,
|
||||
"thread": thread,
|
||||
"app": app,
|
||||
}
|
||||
finally:
|
||||
# Stop server cleanly.
|
||||
try:
|
||||
app = locals().get("app")
|
||||
if app is not None:
|
||||
app.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Give it a moment to close sockets.
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
thread = locals().get("thread")
|
||||
if thread is not None:
|
||||
thread.join(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
os.chdir(old_cwd)
|
||||
|
||||
|
||||
def test_main_routes(server):
|
||||
c: requests.Session = server["client"]
|
||||
c = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
|
||||
resp = c.get(f"{base_url}/")
|
||||
@@ -314,14 +53,15 @@ def test_main_routes(server):
|
||||
assert resp.status_code == 200
|
||||
assert "LED Controller" in resp.text
|
||||
|
||||
resp = c.get(f"{base_url}/ws")
|
||||
# WebSocket endpoints should reject non-upgraded HTTP requests.
|
||||
assert resp.status_code != 200
|
||||
assert resp.status_code in {400, 401, 403, 404, 405, 426}
|
||||
with c.websocket_connect("/ws") as ws:
|
||||
ws.send_text('{"v":"1","select":["off"]}')
|
||||
snapshot = ws.receive_json()
|
||||
assert snapshot.get("type") == "device_tcp_snapshot"
|
||||
assert isinstance(snapshot.get("connected_ips"), list)
|
||||
|
||||
|
||||
def test_settings_controller(server):
|
||||
c: requests.Session = server["client"]
|
||||
c = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
|
||||
resp = c.get(f"{base_url}/settings")
|
||||
@@ -329,6 +69,9 @@ def test_settings_controller(server):
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "wifi_channel" in data
|
||||
assert "wifi_driver_ws_port" in data
|
||||
assert "wifi_driver_ws_path" in data
|
||||
assert data.get("wifi_driver_ws_path") == "/ws"
|
||||
|
||||
resp = c.get(f"{base_url}/settings/wifi/ap")
|
||||
assert resp.status_code == 200
|
||||
@@ -377,7 +120,7 @@ def test_settings_controller(server):
|
||||
|
||||
|
||||
def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||
c: requests.Session = server["client"]
|
||||
c = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
bridge: DummyBridge = server["bridge"]
|
||||
|
||||
@@ -446,6 +189,37 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||
assert sent_result["presets_sent"] >= 1
|
||||
assert len(bridge.sent) >= 1
|
||||
|
||||
wifi_sends = []
|
||||
|
||||
async def _fake_wifi_send(ip, msg):
|
||||
wifi_sends.append((ip, msg))
|
||||
return True
|
||||
|
||||
import util.driver_delivery as driver_delivery_mod
|
||||
|
||||
monkeypatch.setattr(driver_delivery_mod, "send_json_line_to_ip", _fake_wifi_send)
|
||||
resp = c.post(
|
||||
f"{base_url}/devices",
|
||||
json={
|
||||
"name": "pytest-wifi-preset",
|
||||
"transport": "wifi",
|
||||
"address": "192.168.50.20",
|
||||
"mac": "203040506070",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
bridge.sent.clear()
|
||||
resp = c.post(
|
||||
f"{base_url}/presets/send",
|
||||
json={"preset_ids": [new_preset_id], "save": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(bridge.sent) >= 1
|
||||
assert len(wifi_sends) >= 1
|
||||
assert wifi_sends[0][0] == "192.168.50.20"
|
||||
resp = c.delete(f"{base_url}/devices/203040506070")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
|
||||
assert resp.status_code == 200
|
||||
resp = c.get(f"{base_url}/presets/{new_preset_id}")
|
||||
@@ -553,7 +327,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
|
||||
|
||||
|
||||
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
c: requests.Session = server["client"]
|
||||
c = server["client"]
|
||||
base_url: str = server["base_url"]
|
||||
bridge: DummyBridge = server["bridge"]
|
||||
|
||||
@@ -672,17 +446,19 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("message")
|
||||
assert len(bridge.sent) >= 1
|
||||
first = json.loads(bridge.sent[0][0])
|
||||
assert "presets" in first and "select" in first
|
||||
assert first["presets"]["__identify"]["p"] == "blink"
|
||||
assert first["presets"]["__identify"]["d"] == 50
|
||||
assert first["select"] == ["__identify"]
|
||||
first = bridge_sent_envelope(bridge, 0)
|
||||
assert first["v"] == "1"
|
||||
first_body = device_body_from_envelope(first, dev_id)
|
||||
assert first_body["p"]["__identify"]["p"] == "blink"
|
||||
assert first_body["p"]["__identify"]["d"] == 50
|
||||
assert first_body["s"] == ["__identify"]
|
||||
deadline = time.monotonic() + 2.0
|
||||
while len(bridge.sent) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
assert len(bridge.sent) >= 2
|
||||
second = json.loads(bridge.sent[1][0])
|
||||
assert second.get("select") == ["off"]
|
||||
second = bridge_sent_envelope(bridge, 1)
|
||||
second_body = device_body_from_envelope(second, dev_id)
|
||||
assert second_body["s"] == ["off"]
|
||||
|
||||
resp = c.post(
|
||||
f"{base_url}/devices",
|
||||
@@ -702,7 +478,7 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
|
||||
resp = c.get(f"{base_url}/devices/{wid}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("connected") is False
|
||||
assert resp.json().get("connected") is None
|
||||
|
||||
resp = c.post(
|
||||
f"{base_url}/devices",
|
||||
@@ -825,3 +601,118 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_audio_api(server):
|
||||
c = server["client"]
|
||||
base_url = server["base_url"]
|
||||
|
||||
resp = c.get(f"{base_url}/api/audio/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "status" in body
|
||||
assert "audio_run" in body["status"]
|
||||
|
||||
resp = c.get(f"{base_url}/api/audio/devices")
|
||||
assert resp.status_code == 200
|
||||
assert "devices" in resp.json()
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/api/audio/device",
|
||||
json={"device_select": "default", "device_override": ""},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("ok") is True
|
||||
|
||||
resp = c.post(f"{base_url}/api/audio/reset")
|
||||
assert resp.status_code == 409
|
||||
|
||||
resp = c.post(f"{base_url}/api/audio/stop")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("ok") is True
|
||||
|
||||
|
||||
def test_bridge_settings_api(server, monkeypatch):
|
||||
c = server["client"]
|
||||
base_url = server["base_url"]
|
||||
|
||||
import controllers.wifi_bridge as wifi_bridge_ctl # noqa: E402
|
||||
|
||||
monkeypatch.setattr(wifi_bridge_ctl, "nmcli_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
wifi_bridge_ctl,
|
||||
"list_wifi_interfaces",
|
||||
lambda: [{"device": "wlan0", "type": "wifi", "state": "connected"}],
|
||||
)
|
||||
|
||||
async def _fake_scan(device):
|
||||
_ = device
|
||||
return [{"ssid": "bridge-test", "signal": 80}]
|
||||
|
||||
monkeypatch.setattr(wifi_bridge_ctl, "scan_wifi", _fake_scan)
|
||||
|
||||
resp = c.get(f"{base_url}/settings/wifi/interfaces")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("ok") is True
|
||||
assert resp.json()["interfaces"][0]["device"] == "wlan0"
|
||||
|
||||
resp = c.get(f"{base_url}/settings/wifi/scan", params={"device": "wlan0"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["networks"][0]["ssid"] == "bridge-test"
|
||||
|
||||
resp = c.get(f"{base_url}/settings/wifi/bridges")
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()
|
||||
assert payload.get("ok") is True
|
||||
assert "bridge_transport" in payload
|
||||
assert "bridges" in payload
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/settings/wifi/bridges",
|
||||
json={
|
||||
"bridge_transport": "serial",
|
||||
"bridge_serial_port": "/dev/ttyUSB0",
|
||||
"bridges": [],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("ok") is True
|
||||
|
||||
resp = c.get(f"{base_url}/settings/wifi/bridges")
|
||||
assert resp.json().get("bridge_transport") == "serial"
|
||||
|
||||
|
||||
def test_group_identify(server, monkeypatch):
|
||||
c = server["client"]
|
||||
base_url = server["base_url"]
|
||||
bridge: DummyBridge = server["bridge"]
|
||||
|
||||
import controllers.device as device_ctl # noqa: E402
|
||||
|
||||
monkeypatch.setattr(device_ctl, "IDENTIFY_OFF_DELAY_S", 0.05)
|
||||
|
||||
_create_and_apply_profile(c, base_url)
|
||||
|
||||
resp = c.post(f"{base_url}/groups", json={"name": "pytest-identify-group"})
|
||||
assert resp.status_code == 201
|
||||
groups_list = c.get(f"{base_url}/groups").json()
|
||||
group_id = _find_id_by_field(groups_list, "name", "pytest-identify-group")
|
||||
|
||||
resp = c.post(
|
||||
f"{base_url}/devices",
|
||||
json={"name": "identify-dev", "address": "aabbccddeeff"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
dev_id = "aabbccddeeff"
|
||||
|
||||
resp = c.put(
|
||||
f"{base_url}/groups/{group_id}",
|
||||
json={"devices": [dev_id]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
bridge.sent.clear()
|
||||
resp = c.post(f"{base_url}/groups/{group_id}/identify")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("sent", 0) >= 1
|
||||
assert len(bridge.sent) >= 1
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import pytest
|
||||
|
||||
pytest.skip("Legacy manual server script (not a pytest suite).", allow_module_level=True)
|
||||
|
||||
from microdot import Microdot
|
||||
from src.profile import profile_app
|
||||
|
||||
app = Microdot()
|
||||
|
||||
@app.route('/')
|
||||
async def index(request):
|
||||
return 'Hello, world!'
|
||||
|
||||
app.mount(profile_app, url_prefix="/profile")
|
||||
|
||||
app.run(port=8080, debug=True)
|
||||