Compare commits
8 Commits
78dc8ffc77
...
beta-1.08
| Author | SHA1 | Date | |
|---|---|---|---|
| cb9758b97b | |||
| aab62efd4f | |||
| 2382ef16a1 | |||
| cfdd6de291 | |||
| d682753e42 | |||
| 53976cdd70 | |||
| 94635a8cc7 | |||
| de0547615c |
7
Pipfile
@@ -14,9 +14,12 @@ requests = "*"
|
||||
selenium = "*"
|
||||
adafruit-ampy = "*"
|
||||
microdot = "*"
|
||||
fastapi = "*"
|
||||
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'"
|
||||
|
||||
336
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80"
|
||||
"sha256": "b6783f8de9b1f98387fccab1d9fed190f2acbf66f0f188e43fe91f28a6950ad1"
|
||||
},
|
||||
"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": [
|
||||
@@ -607,11 +704,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 +718,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",
|
||||
@@ -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
|
||||
|
||||
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
@@ -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.*'
|
||||
|
||||
24
scripts/mpremote_send_ch5.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Upload and run a device-side ESP-NOW sender script.
|
||||
# Default channel is 5 and default destination is broadcast.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/mpremote_send_ch5.sh [port] [dest_mac_hex] [payload_hex]
|
||||
#
|
||||
# Examples:
|
||||
# scripts/mpremote_send_ch5.sh /dev/ttyACM0
|
||||
# scripts/mpremote_send_ch5.sh /dev/ttyACM0 ffffffffffff 4c0501000000
|
||||
|
||||
PORT="${1:-/dev/ttyACM0}"
|
||||
DEST_HEX="${2:-ffffffffffff}"
|
||||
PAYLOAD_HEX="${3:-4c0501000000}"
|
||||
CHANNEL=5
|
||||
DEVICE_SCRIPT="send_ch5.py"
|
||||
|
||||
mpremote connect "${PORT}" fs cp "scripts/mpremote_send_ch5_device.py" ":${DEVICE_SCRIPT}"
|
||||
mpremote connect "${PORT}" exec "
|
||||
import ${DEVICE_SCRIPT%.*}
|
||||
${DEVICE_SCRIPT%.*}.send_once('${DEST_HEX}', '${PAYLOAD_HEX}', ${CHANNEL})
|
||||
"
|
||||
42
scripts/mpremote_send_ch5_device.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Device-side ESP-NOW sender (MicroPython, channel 5)."""
|
||||
|
||||
import espnow
|
||||
import network
|
||||
import ubinascii
|
||||
|
||||
|
||||
CHANNEL = 5
|
||||
DEST_HEX = "ffffffffffff"
|
||||
PAYLOAD_HEX = "4c0501000000"
|
||||
|
||||
|
||||
def _set_channel(channel):
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
sta.config(channel=channel)
|
||||
|
||||
|
||||
def _add_peer(esp, dest, channel):
|
||||
try:
|
||||
esp.add_peer(dest, channel=channel)
|
||||
except TypeError:
|
||||
esp.add_peer(dest)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
|
||||
dest = ubinascii.unhexlify(dest_hex)
|
||||
pkt = ubinascii.unhexlify(payload_hex)
|
||||
_set_channel(channel)
|
||||
e = espnow.ESPNow()
|
||||
e.active(True)
|
||||
_add_peer(e, dest, channel)
|
||||
ok = e.send(dest, pkt, True)
|
||||
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
|
||||
return ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
send_once()
|
||||
@@ -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"
|
||||
|
||||
298
src/app_factory.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Application factory: Microdot routes and shared runtime startup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from typing import Any, Optional
|
||||
|
||||
from microdot import Microdot, send_file
|
||||
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
|
||||
import controllers.wifi_bridge as wifi_bridge_controller
|
||||
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
|
||||
|
||||
|
||||
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 = os.path.dirname(os.path.abspath(__file__))
|
||||
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 create_microdot_app(*, inject_live_reload: bool = False) -> Microdot:
|
||||
"""Build the Microdot app with mounted controllers and static routes."""
|
||||
settings = get_settings()
|
||||
app = Microdot()
|
||||
|
||||
secret_key = settings.get(
|
||||
"session_secret_key", "led-controller-secret-key-change-in-production"
|
||||
)
|
||||
Session(app, secret_key=secret_key)
|
||||
|
||||
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")
|
||||
|
||||
build_id = dev_build_id() if inject_live_reload else None
|
||||
if build_id:
|
||||
|
||||
@app.route("/__dev/build-id")
|
||||
def dev_build_id_route(request):
|
||||
_ = request
|
||||
return (
|
||||
build_id,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
_ = request
|
||||
if 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")
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon(request):
|
||||
_ = 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)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
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 sequence_playback as seq_pb
|
||||
|
||||
seq_pb.ensure_beat_consumer_started()
|
||||
Device()
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
try:
|
||||
self.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
|
||||
|
||||
|
||||
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")
|
||||
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
|
||||
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 st
|
||||
251
src/fastapi_app.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from app_factory import (
|
||||
AppRuntime,
|
||||
audio_status_payload,
|
||||
create_microdot_app,
|
||||
live_reload_enabled,
|
||||
)
|
||||
from microdot_asgi import MicrodotASGI
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
|
||||
_runtime: Optional[AppRuntime] = None
|
||||
_microdot_app = 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()
|
||||
|
||||
|
||||
@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_fastapi() -> FastAPI:
|
||||
api = FastAPI(title="LED Controller", lifespan=_lifespan)
|
||||
|
||||
@api.get("/__dev/build-id", response_class=PlainTextResponse)
|
||||
async def dev_build_id_route():
|
||||
from app_factory import dev_build_id as current_build_id
|
||||
|
||||
bid = current_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():
|
||||
from app_factory import dev_client_revision
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
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
|
||||
|
||||
return api
|
||||
|
||||
|
||||
class CombinedASGI:
|
||||
"""Route FastAPI-only paths first; delegate the rest to Microdot."""
|
||||
|
||||
_FASTAPI_PREFIXES = ("/api/", "/__dev/")
|
||||
|
||||
def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI):
|
||||
self.fastapi_app = fastapi_app
|
||||
self.microdot_asgi = microdot_asgi
|
||||
|
||||
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||
stype = scope.get("type")
|
||||
if stype == "lifespan":
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
if stype == "websocket":
|
||||
if scope.get("path") == "/ws":
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
await send({"type": "websocket.close", "code": 1000})
|
||||
return
|
||||
if stype == "http":
|
||||
path = scope.get("path") or ""
|
||||
if path.startswith(self._FASTAPI_PREFIXES):
|
||||
await self.fastapi_app(scope, receive, send)
|
||||
return
|
||||
await self.microdot_asgi(scope, receive, send)
|
||||
|
||||
|
||||
def create_application(*, test_mode: bool = False) -> CombinedASGI:
|
||||
global _microdot_app, _test_mode
|
||||
_test_mode = test_mode
|
||||
_microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled())
|
||||
fastapi_app = _create_fastapi()
|
||||
return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app))
|
||||
|
||||
|
||||
app = create_application()
|
||||
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")
|
||||
84
src/microdot_asgi.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""ASGI bridge for existing Microdot route handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from microdot.microdot import Microdot, NoCaseDict, Request, Response
|
||||
|
||||
|
||||
class MicrodotASGI:
|
||||
"""Dispatch HTTP requests to a :class:`Microdot` application."""
|
||||
|
||||
def __init__(self, microdot_app: Microdot):
|
||||
self.app = microdot_app
|
||||
|
||||
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") != "http":
|
||||
return
|
||||
|
||||
body = b""
|
||||
while True:
|
||||
message = await receive()
|
||||
if message["type"] != "http.request":
|
||||
continue
|
||||
body += message.get("body", b"")
|
||||
if not message.get("more_body"):
|
||||
break
|
||||
|
||||
headers = NoCaseDict()
|
||||
for key, value in scope.get("headers", ()):
|
||||
headers[key.decode("latin-1")] = value.decode("latin-1")
|
||||
|
||||
path = scope.get("path", "/") or "/"
|
||||
query = scope.get("query_string", b"").decode("latin-1")
|
||||
url = path + (f"?{query}" if query else "")
|
||||
|
||||
client = scope.get("client") or ("127.0.0.1", 0)
|
||||
req = Request(
|
||||
self.app,
|
||||
client,
|
||||
scope.get("method", "GET"),
|
||||
url,
|
||||
"1.1",
|
||||
headers,
|
||||
body=body,
|
||||
)
|
||||
|
||||
res = await self.app.dispatch_request(req)
|
||||
if res is Response.already_handled:
|
||||
return
|
||||
await _send_microdot_response(res, send)
|
||||
|
||||
|
||||
async def _send_microdot_response(res: Response, send: Any) -> None:
|
||||
res.complete()
|
||||
headers: list[tuple[bytes, bytes]] = []
|
||||
for header, value in res.headers.items():
|
||||
values = value if isinstance(value, list) else [value]
|
||||
for item in values:
|
||||
headers.append(
|
||||
(header.lower().encode("latin-1"), str(item).encode("latin-1"))
|
||||
)
|
||||
|
||||
body = res.body
|
||||
if isinstance(body, str):
|
||||
payload = body.encode()
|
||||
elif isinstance(body, bytes):
|
||||
payload = body
|
||||
else:
|
||||
parts: list[bytes] = []
|
||||
async for chunk in res.body_iter():
|
||||
if isinstance(chunk, str):
|
||||
chunk = chunk.encode()
|
||||
parts.append(chunk)
|
||||
payload = b"".join(parts)
|
||||
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": res.status_code,
|
||||
"headers": headers,
|
||||
}
|
||||
)
|
||||
await send({"type": "http.response.body", "body": payload})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -76,6 +76,13 @@ function normalizeDeviceMacKey(mac) {
|
||||
.replace(/[:-]/g, '');
|
||||
}
|
||||
|
||||
function normalizeMacInput(raw) {
|
||||
return String(raw || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[:-]/g, '');
|
||||
}
|
||||
|
||||
function findPingResponse(responses, deviceId) {
|
||||
if (!responses || typeof responses !== 'object') return null;
|
||||
const want = normalizeDeviceMacKey(deviceId);
|
||||
@@ -430,6 +437,69 @@ async function loadDevicesModal() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceFromModal() {
|
||||
const nameEl = document.getElementById('devices-add-name');
|
||||
const trEl = document.getElementById('devices-add-transport');
|
||||
const macEl = document.getElementById('devices-add-mac');
|
||||
const addrEl = document.getElementById('devices-add-address');
|
||||
const statusEl = document.getElementById('devices-add-status');
|
||||
const btn = document.getElementById('devices-add-btn');
|
||||
const name = (nameEl && nameEl.value.trim()) || '';
|
||||
const transport = (trEl && trEl.value) || 'espnow';
|
||||
const mac = normalizeMacInput(macEl && macEl.value);
|
||||
const address = (addrEl && addrEl.value.trim()) || '';
|
||||
|
||||
if (!name) {
|
||||
if (statusEl) statusEl.textContent = 'Name is required';
|
||||
return;
|
||||
}
|
||||
if (mac.length !== 12) {
|
||||
if (statusEl) statusEl.textContent = 'MAC must be 12 hex characters';
|
||||
return;
|
||||
}
|
||||
if (transport === 'wifi' && !address) {
|
||||
if (statusEl) statusEl.textContent = 'Address is required for Wi-Fi devices';
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Adding…';
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Creating device…';
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
transport,
|
||||
type: 'led',
|
||||
mac,
|
||||
address: transport === 'wifi' ? address : mac,
|
||||
};
|
||||
const res = await fetch('/devices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (statusEl) statusEl.textContent = data.error || 'Create failed';
|
||||
return;
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Device added';
|
||||
if (nameEl) nameEl.value = '';
|
||||
if (macEl) macEl.value = '';
|
||||
if (addrEl) addrEl.value = '';
|
||||
await loadDevicesModal();
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = e.message || 'Create failed';
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Add device';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDevicesList(devices) {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
@@ -750,6 +820,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const editForm = document.getElementById('edit-device-form');
|
||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||
const addTransport = document.getElementById('devices-add-transport');
|
||||
const addAddress = document.getElementById('devices-add-address');
|
||||
const addBtn = document.getElementById('devices-add-btn');
|
||||
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
@@ -768,6 +841,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (addTransport && addAddress) {
|
||||
const syncAddAddress = () => {
|
||||
addAddress.hidden = addTransport.value !== 'wifi';
|
||||
};
|
||||
addTransport.addEventListener('change', syncAddAddress);
|
||||
syncAddAddress();
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => createDeviceFromModal());
|
||||
}
|
||||
|
||||
const devicesPingBtn = document.getElementById('devices-ping-btn');
|
||||
if (devicesPingBtn) {
|
||||
devicesPingBtn.addEventListener('click', () => {
|
||||
@@ -854,7 +938,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
if (!pushRes.ok) return;
|
||||
}
|
||||
editDeviceModal.classList.remove('active');
|
||||
await loadDevicesModal();
|
||||
refreshEditDeviceDebug();
|
||||
});
|
||||
}
|
||||
if (editCloseBtn) {
|
||||
|
||||
@@ -85,29 +85,33 @@ 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 {
|
||||
@@ -130,15 +134,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);
|
||||
@@ -292,22 +298,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 +403,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);
|
||||
});
|
||||
}
|
||||
@@ -540,8 +547,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
} catch (_) {
|
||||
/* ignore push errors after save */
|
||||
}
|
||||
if (editModal) editModal.classList.remove('active');
|
||||
await loadGroupsModal();
|
||||
refreshEditGroupDebug();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Save failed');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1200,12 +1200,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 +1229,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 +1256,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 +1387,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 +1426,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>
|
||||
`;
|
||||
|
||||
@@ -1559,13 +1517,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 +1637,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 +1708,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,32 +1821,15 @@ 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]');
|
||||
@@ -2195,13 +2133,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 +2170,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,12 +2232,6 @@ 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;
|
||||
@@ -2372,12 +2325,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 +2336,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 +2417,12 @@ 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 {
|
||||
if (!hasSeq) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'muted-text';
|
||||
empty.style.gridColumn = '1 / -1';
|
||||
empty.textContent =
|
||||
'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.';
|
||||
"No presets or sequences on this zone yet. Open Edit to add presets or sequences.";
|
||||
presetsList.appendChild(empty);
|
||||
}
|
||||
} else {
|
||||
@@ -2515,11 +2452,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,13 +2693,6 @@ 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 = [];
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -510,13 +510,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 +572,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 +584,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');
|
||||
@@ -1081,9 +1061,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') {
|
||||
@@ -1164,31 +1151,12 @@ 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);
|
||||
});
|
||||
@@ -1227,33 +1195,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', () => {
|
||||
|
||||
@@ -125,6 +125,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;
|
||||
@@ -1444,6 +1506,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 +1589,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 +1625,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 +1665,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 +1779,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 +1897,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 +1973,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 +2058,205 @@ 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-modal-intro {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
#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;
|
||||
|
||||
@@ -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,10 +862,11 @@ 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="header-end">
|
||||
<div class="zones-menu-mobile">
|
||||
<button type="button" class="btn btn-secondary" id="zones-menu-btn" aria-haspopup="true" aria-expanded="false" aria-controls="zones-menu-dropdown">Zones</button>
|
||||
<div id="zones-menu-dropdown" class="zones-menu-dropdown" role="menu" aria-label="Zones"></div>
|
||||
</div>
|
||||
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
|
||||
@@ -61,7 +65,7 @@
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
||||
<button type="button" class="edit-mode-only" data-target="groups-btn">Groups</button>
|
||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="zones-btn">Zones</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="sequences-btn">Sequences</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
@@ -88,34 +92,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Modal -->
|
||||
<!-- Zones Modal -->
|
||||
<div id="zones-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Tabs</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Zones</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||
</div>
|
||||
<div class="zone-content-kind-row muted-text">
|
||||
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
||||
</div>
|
||||
<div id="zones-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Zone Modal -->
|
||||
<div id="edit-zone-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit Zone</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Edit Zone</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="submit" form="edit-zone-form" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="edit-zone-form">
|
||||
<input type="hidden" id="edit-zone-id">
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
|
||||
<div id="edit-zone-block-groups">
|
||||
<label class="zone-devices-label">Device groups on this zone</label>
|
||||
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||
@@ -132,10 +139,6 @@
|
||||
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +146,12 @@
|
||||
<!-- Profiles Modal -->
|
||||
<div id="profiles-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Profiles</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Profiles</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||
@@ -156,16 +164,31 @@
|
||||
</label>
|
||||
</div>
|
||||
<div id="profiles-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
|
||||
<div id="devices-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Devices</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Devices</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;">
|
||||
<input type="text" id="devices-add-name" placeholder="Device name" autocomplete="off" style="min-width:10rem;">
|
||||
<select id="devices-add-transport">
|
||||
<option value="espnow">ESP-NOW</option>
|
||||
<option value="wifi">Wi-Fi</option>
|
||||
</select>
|
||||
<input type="text" id="devices-add-mac" placeholder="MAC (12 hex)" autocomplete="off" style="min-width:10rem;">
|
||||
<input type="text" id="devices-add-address" placeholder="Address (IP/host for Wi-Fi)" autocomplete="off" style="min-width:12rem;" hidden>
|
||||
<button type="button" class="btn btn-primary btn-small" id="devices-add-btn">Add device</button>
|
||||
</div>
|
||||
<small id="devices-add-status" class="muted-text" aria-live="polite"></small>
|
||||
</div>
|
||||
<div id="devices-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||
<button type="button" class="btn btn-secondary" id="devices-ping-btn" title="ESP-NOW broadcast ping (3 s)">Ping drivers</button>
|
||||
@@ -173,7 +196,6 @@
|
||||
<span id="devices-ping-status" class="muted-text" aria-live="polite"></span>
|
||||
<button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button>
|
||||
<span id="devices-groups-status" class="muted-text" aria-live="polite"></span>
|
||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +203,12 @@
|
||||
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups for presets) -->
|
||||
<div id="groups-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Device groups</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Device groups</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" id="new-group-name" placeholder="Group name">
|
||||
@@ -191,15 +218,18 @@
|
||||
<button class="btn btn-primary" id="create-group-btn">Create</button>
|
||||
</div>
|
||||
<div id="groups-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-group-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit device group</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Edit device group</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="submit" form="edit-group-form" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="edit-group-form">
|
||||
<input type="hidden" id="edit-group-id">
|
||||
<label for="edit-group-name">Group name</label>
|
||||
@@ -219,40 +249,19 @@
|
||||
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
|
||||
<span id="edit-group-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
|
||||
</div>
|
||||
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">Wi‑Fi driver defaults (apply to all members via <strong>Apply defaults to drivers</strong> on the list)</p>
|
||||
<label for="edit-group-wifi-driver-name">Display name</label>
|
||||
<input type="text" id="edit-group-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
|
||||
<label for="edit-group-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
|
||||
<input type="number" id="edit-group-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
|
||||
<label for="edit-group-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
|
||||
<select id="edit-group-wifi-color-order">
|
||||
<option value="rgb">RGB</option>
|
||||
<option value="rbg">RBG</option>
|
||||
<option value="grb">GRB</option>
|
||||
<option value="gbr">GBR</option>
|
||||
<option value="brg">BRG</option>
|
||||
<option value="bgr">BGR</option>
|
||||
</select>
|
||||
<label for="edit-group-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
|
||||
<select id="edit-group-wifi-startup-mode">
|
||||
<option value="default">Default preset</option>
|
||||
<option value="last">Last preset</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
||||
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
||||
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="edit-device-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Edit device</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Edit device</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="submit" form="edit-device-form" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="edit-device-form">
|
||||
<input type="hidden" id="edit-device-id">
|
||||
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
|
||||
@@ -306,10 +315,6 @@
|
||||
<label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label>
|
||||
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
||||
<textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,39 +322,48 @@
|
||||
<!-- Presets Modal -->
|
||||
<div id="presets-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Presets</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Presets</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
|
||||
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
||||
</div>
|
||||
<div id="presets-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="presets-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sequences Modal -->
|
||||
<div id="sequences-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Sequences</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Sequences</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
|
||||
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||
</div>
|
||||
<div id="sequences-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sequence Editor Modal -->
|
||||
<div id="sequence-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Sequence</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Sequence</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label for="sequence-editor-name">Name</label>
|
||||
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||
@@ -372,8 +386,6 @@
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +393,13 @@
|
||||
<!-- Preset Editor Modal -->
|
||||
<div id="preset-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Preset</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Preset</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save</button>
|
||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" id="preset-name-input" placeholder="Preset name">
|
||||
<select id="preset-pattern-input">
|
||||
@@ -472,8 +490,6 @@
|
||||
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||
<button type="button" class="btn btn-danger" id="preset-remove-from-zone-btn" hidden>Remove from zone</button>
|
||||
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,22 +497,30 @@
|
||||
<!-- Patterns Modal -->
|
||||
<div id="patterns-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
|
||||
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
|
||||
</div>
|
||||
<div id="patterns-list" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="patterns-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pattern Editor Modal -->
|
||||
<div id="pattern-editor-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Pattern</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Pattern</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
|
||||
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
|
||||
@@ -559,8 +583,6 @@
|
||||
<input type="checkbox" id="pattern-create-overwrite" checked>
|
||||
<span>Overwrite existing file</span>
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" id="pattern-create-btn">Save</button>
|
||||
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -568,63 +590,522 @@
|
||||
<!-- Colour Palette Modal -->
|
||||
<div id="color-palette-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Colour Palette</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Colour Palette</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
||||
<div id="palette-container" class="profiles-list"></div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" id="palette-new-color" value="#ffffff">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<div id="help-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Help</h2>
|
||||
<p class="muted-text">How to use the LED controller UI.</p>
|
||||
|
||||
<h3>Run mode</h3>
|
||||
<ul>
|
||||
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li>
|
||||
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the zone.</li>
|
||||
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
|
||||
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit addresses or remove rows.</li>
|
||||
<li><strong>Groups</strong>: define device groups, Wi‑Fi driver defaults, then assign groups to zones.</li>
|
||||
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
|
||||
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Edit mode</h3>
|
||||
<ul>
|
||||
<li><strong>Tabs</strong>: create, edit, and manage zones and which <strong>device groups</strong> each zone drives.</li>
|
||||
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
|
||||
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
|
||||
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
|
||||
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
|
||||
<li><strong>Devices</strong>: registry rows are keyed by <strong>MAC</strong>; edit a device for transport/IP and per-driver Wi‑Fi settings, or use <strong>Groups</strong> for shared defaults.</li>
|
||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||
</ul>
|
||||
|
||||
<h3>LED Tool (Settings tab)</h3>
|
||||
<ul>
|
||||
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
|
||||
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
|
||||
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open <strong>Settings → LED Tool</strong> in Edit mode.</li>
|
||||
</ul>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||
<div class="modal-content help-modal-content">
|
||||
<div class="modal-head">
|
||||
<h2>Help</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted-text help-modal-intro">How to use the LED controller UI. Previews use the same styles as the live interface.</p>
|
||||
|
||||
<div class="help-tabs" role="tablist" aria-label="Help sections">
|
||||
<button type="button" class="help-tab-btn active" role="tab" id="help-tab-overview" data-help-tab="overview" aria-selected="true" aria-controls="help-panel-overview">Overview</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-profiles" data-help-tab="profiles" aria-selected="false" aria-controls="help-panel-profiles">Profiles</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-devices" data-help-tab="devices" aria-selected="false" aria-controls="help-panel-devices">Devices</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-groups" data-help-tab="groups" aria-selected="false" aria-controls="help-panel-groups">Groups</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-zones" data-help-tab="zones" aria-selected="false" aria-controls="help-panel-zones">Zones</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-presets" data-help-tab="presets" aria-selected="false" aria-controls="help-panel-presets">Presets</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-sequences" data-help-tab="sequences" aria-selected="false" aria-controls="help-panel-sequences">Sequences</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-patterns" data-help-tab="patterns" aria-selected="false" aria-controls="help-panel-patterns">Patterns</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-colour-palette" data-help-tab="colour-palette" aria-selected="false" aria-controls="help-panel-colour-palette">Colour Palette</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-audio" data-help-tab="audio" aria-selected="false" aria-controls="help-panel-audio">Audio</button>
|
||||
<button type="button" class="help-tab-btn" role="tab" id="help-tab-settings" data-help-tab="settings" aria-selected="false" aria-controls="help-panel-settings">Settings</button>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-overview" class="help-tab-panel active" data-help-panel="overview" role="tabpanel" aria-labelledby="help-tab-overview">
|
||||
<div class="help-ui-preview help-ui-preview--header" aria-hidden="true">
|
||||
<div class="help-preview-header">
|
||||
<div class="header-end">
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
<label>Brightness</label>
|
||||
<input type="range" min="0" max="255" value="200" tabindex="-1">
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Profiles</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Devices</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Zones</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Presets</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Audio</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" tabindex="-1">Run mode</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="zones-container">
|
||||
<div class="zones-list">
|
||||
<button type="button" class="zone-button" tabindex="-1">default</button>
|
||||
<button type="button" class="zone-button active" tabindex="-1">lounge</button>
|
||||
<button type="button" class="zone-button" tabindex="-1">dj</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-ui-preview-caption">Zone buttons below the header; management buttons on the right (Edit mode).</p>
|
||||
<h3>Run mode and Edit mode</h3>
|
||||
<ul>
|
||||
<li><strong>Run mode</strong>: day-to-day control — choose a zone, tap presets, apply profiles. Management buttons are hidden.</li>
|
||||
<li><strong>Edit mode</strong>: full setup — zones, presets, sequences, patterns, colour palette, and per-tile <strong>Edit</strong> on the strip.</li>
|
||||
<li><strong>Switch modes</strong>: use the mode button in the header or mobile menu. The label shows the mode you will switch <em>to</em>.</li>
|
||||
</ul>
|
||||
<div class="help-ui-preview help-ui-preview--strip" aria-hidden="true">
|
||||
<div class="zone-content">
|
||||
<div class="presets-section">
|
||||
<div class="help-preview-presets-grid">
|
||||
<div class="preset-tile-row preset-tile-row--edit">
|
||||
<div class="preset-tile-row-top">
|
||||
<button type="button" class="pattern-button preset-tile-main active" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#ffd54f 0%,#fff8e1 100%)" tabindex="-1"><span class="pattern-button-label">warm white</span></button>
|
||||
<div class="preset-tile-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-tile-row preset-tile-row--edit">
|
||||
<div class="preset-tile-row-top">
|
||||
<button type="button" class="pattern-button preset-tile-main" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#e53935 0%,#1e88e5 100%)" tabindex="-1"><span class="pattern-button-label">rainbow</span></button>
|
||||
<div class="preset-tile-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-tile-row preset-tile-row--edit">
|
||||
<div class="preset-tile-row-top">
|
||||
<button type="button" class="pattern-button preset-tile-main" style="background-image:linear-gradient(rgba(0,0,0,0.4),rgba(0,0,0,0.4)),linear-gradient(to right,#00897b 0%,#4db6ac 100%)" tabindex="-1"><span class="pattern-button-label">pulse</span></button>
|
||||
<div class="preset-tile-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-ui-preview-caption">Click a preset tile to select it on all devices in the zone.</p>
|
||||
<ul>
|
||||
<li><strong>Select zone</strong>: click a zone button in the top bar.</li>
|
||||
<li><strong>Brightness</strong>: the header slider adjusts global brightness for the current zone.</li>
|
||||
<li><strong>Edit mode</strong>: drag preset tiles to reorder; use <strong>Edit</strong> and <strong>Remove</strong> on each tile.</li>
|
||||
</ul>
|
||||
<div class="help-ui-preview help-ui-preview--mobile" aria-hidden="true">
|
||||
<div class="help-preview-mobile-bar">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Menu</button>
|
||||
</div>
|
||||
<div class="main-menu-dropdown">
|
||||
<button type="button" tabindex="-1">Run mode</button>
|
||||
<button type="button" tabindex="-1">Profiles</button>
|
||||
<button type="button" tabindex="-1">Zones</button>
|
||||
<button type="button" tabindex="-1">Presets</button>
|
||||
<button type="button" tabindex="-1">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-ui-preview-caption">On narrow screens, <strong>Menu</strong> reaches the same actions as the desktop header.</p>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-profiles" class="help-tab-panel" data-help-panel="profiles" role="tabpanel" aria-labelledby="help-tab-profiles" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Profiles</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" value="New profile" readonly tabindex="-1">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Create</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Import</button>
|
||||
</div>
|
||||
<div class="profiles-list">
|
||||
<div class="profiles-row">
|
||||
<span style="font-weight:bold;color:#FFD700">✓ House default</span>
|
||||
<span>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Export</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Clone</button>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="profiles-row">
|
||||
<span>Garden party</span>
|
||||
<span>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Export</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Clone</button>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Apply</strong>: sets the current profile. Zones and presets you see are scoped to that profile.</li>
|
||||
<li><strong>Create</strong> (Edit mode): new profiles get a populated <strong>default</strong> zone. Optionally tick <strong>DJ zone</strong> for a starter <code>dj</code> zone.</li>
|
||||
<li><strong>Clone</strong> / <strong>Delete</strong>: available in Edit mode from the profile list.</li>
|
||||
<li>In Run mode you can only apply profiles; create, clone, and delete are hidden.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-devices" class="help-tab-panel" data-help-panel="devices" role="tabpanel" aria-labelledby="help-tab-devices" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Devices</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-list">
|
||||
<div class="profiles-row">
|
||||
<span class="device-status-dot device-status-dot--online" role="img"></span>
|
||||
<span style="flex:1">lounge strip</span>
|
||||
<code class="device-row-mac">AA:BB:CC:DD:EE:01</code>
|
||||
<span class="muted-text" style="font-size:0.85em">led · espnow · —</span>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Identify</button>
|
||||
</div>
|
||||
<div class="profiles-row">
|
||||
<span class="device-status-dot device-status-dot--unknown" role="img"></span>
|
||||
<span style="flex:1">ceiling</span>
|
||||
<code class="device-row-mac">AA:BB:CC:DD:EE:02</code>
|
||||
<span class="muted-text" style="font-size:0.85em">led · espnow · —</span>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Identify</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions" style="justify-content:flex-start;margin-top:0.75rem;">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Ping drivers</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Update groups</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Devices</strong> (Edit mode): registry of LED drivers keyed by <strong>MAC</strong>.</li>
|
||||
<li>ESP-NOW devices appear automatically after <strong>ANNOUNCE</strong>; you can also add rows manually.</li>
|
||||
<li><strong>Identify</strong>: short red blink (~2 s) so you can spot hardware.</li>
|
||||
<li><strong>Update groups</strong>: pushes group membership from device groups to ESP-NOW drivers.</li>
|
||||
<li>Edit a device for transport, IP, and per-driver settings; use <strong>Groups</strong> for shared Wi‑Fi defaults.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-groups" class="help-tab-panel" data-help-panel="groups" role="tabpanel" aria-labelledby="help-tab-groups" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Device groups</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions zone-modal-create-row">
|
||||
<input type="text" value="Group name" readonly tabindex="-1">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Create</button>
|
||||
</div>
|
||||
<div class="profiles-list">
|
||||
<div class="group-list-row">
|
||||
<div class="group-list-row-info">
|
||||
<div class="group-list-row-title">lounge lights (3 devices)</div>
|
||||
<div class="group-list-row-meta muted-text">Shared across profiles</div>
|
||||
</div>
|
||||
<div class="group-list-row-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply brightness</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-list-row">
|
||||
<div class="group-list-row-info">
|
||||
<div class="group-list-row-title">dj booth (2 devices)</div>
|
||||
<div class="group-list-row-meta muted-text">This profile only</div>
|
||||
</div>
|
||||
<div class="group-list-row-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Apply brightness</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Assign drivers to a <strong>group</strong>, set Wi‑Fi defaults once per group, then attach groups to a zone.</li>
|
||||
<li>Standalone presets use the zone’s device groups. Sequence lanes each target their own group.</li>
|
||||
<li>New groups are <strong>shared</strong> across profiles by default; tick <strong>this profile only</strong> to hide a group elsewhere.</li>
|
||||
<li>In the group editor, search and pick devices from the list to add members; <strong>Identify devices in group</strong> blinks them together.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-zones" class="help-tab-panel" data-help-panel="zones" role="tabpanel" aria-labelledby="help-tab-zones" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Edit Zone</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<label>Zone Name:</label>
|
||||
<input type="text" value="lounge" readonly tabindex="-1">
|
||||
<label class="zone-devices-label">Device groups on this zone</label>
|
||||
<div class="profiles-list">
|
||||
<div class="profiles-row edit-zone-item-row"><span>lounge lights</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
|
||||
</div>
|
||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||
<div class="profiles-list edit-zone-presets-scroll">
|
||||
<div class="profiles-row edit-zone-item-row"><span>warm white</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
|
||||
<div class="profiles-row edit-zone-item-row"><span>rainbow</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
|
||||
</div>
|
||||
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||
<div class="profiles-list edit-zone-presets-scroll">
|
||||
<div class="profiles-row edit-zone-item-row"><span>intro build</span><button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Zones</strong> (Edit mode): create and manage zones from the header <strong>Zones</strong> button.</li>
|
||||
<li>Each zone lists <strong>device groups</strong>, <strong>presets</strong>, and <strong>sequences</strong> — presets and sequences can share the same zone.</li>
|
||||
<li>Drag presets on the main strip or in the zone editor to reorder.</li>
|
||||
<li>Right-click a zone button for quick access to zone settings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-presets" class="help-tab-panel" data-help-panel="presets" role="tabpanel" aria-labelledby="help-tab-presets" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Preset</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="text" value="evening glow" readonly tabindex="-1">
|
||||
<select tabindex="-1"><option>pulse</option></select>
|
||||
</div>
|
||||
<label>Colours</label>
|
||||
<div class="preset-colors-container">
|
||||
<div class="help-preview-color-swatch" style="background-color:#7e57c2"><span class="help-preview-p-badge">P</span></div>
|
||||
<div class="help-preview-color-swatch" style="background-color:#26a69a"></div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" value="#ffffff" tabindex="-1">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">From Palette</button>
|
||||
</div>
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Try</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Default</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Presets</strong> (Edit mode): profile-wide list — <strong>Add</strong>, <strong>Edit</strong>, <strong>Send</strong>, and <strong>Delete</strong>.</li>
|
||||
<li><strong>Pattern</strong> and optional <strong>n1–n8</strong> fields depend on the pattern.</li>
|
||||
<li><strong>From Palette</strong>: inserts a colour linked to the profile palette (badge <strong>P</strong>).</li>
|
||||
<li><strong>Try</strong>: previews on the current zone without saving on the device.</li>
|
||||
<li><strong>Save</strong>: writes the preset to the server (does not close the editor).</li>
|
||||
<li><strong>Send</strong>: pushes the definition to devices with save.</li>
|
||||
<li><strong>Remove from zone</strong> (when opened from a zone): removes from this zone only.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-sequences" class="help-tab-panel" data-help-panel="sequences" role="tabpanel" aria-labelledby="help-tab-sequences" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Sequence</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Save</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-editor-field">
|
||||
<label>Name</label>
|
||||
<input type="text" value="intro build" readonly tabindex="-1">
|
||||
</div>
|
||||
<p class="muted-text" style="font-size:0.85em;margin:0.5rem 0;">Lane 1 — lounge lights</p>
|
||||
<div class="profiles-list">
|
||||
<div class="sequence-step-row profiles-row" style="display:flex;flex-direction:column;gap:0.35rem;padding:0.5rem;border:1px solid rgba(255,255,255,0.12);border-radius:6px;">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||
<label>Preset</label>
|
||||
<select tabindex="-1"><option>warm white — 1</option></select>
|
||||
<label>Beats</label>
|
||||
<input type="number" value="4" readonly style="width:4rem" tabindex="-1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Add lane</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Sequences</strong> (Edit mode): build multi-step shows with one or more <strong>lanes</strong> (each lane targets a device group).</li>
|
||||
<li>Add presets as steps per lane; open from the zone editor to attach a sequence to a zone.</li>
|
||||
<li><strong>Beat</strong> / <strong>Downbeat</strong> toggle (header): when starting a sequence, wait for beat or downbeat before step 1.</li>
|
||||
<li>Tap <kbd>S</kbd> or the BPM button during playback to sync step timing to music (with Audio running).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-patterns" class="help-tab-panel" data-help-panel="patterns" role="tabpanel" aria-labelledby="help-tab-patterns" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Patterns</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:0;justify-content:flex-start;">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Add</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Send All Patterns</button>
|
||||
</div>
|
||||
<div class="profiles-list">
|
||||
<div class="profiles-row"><span>pulse</span><span class="muted-text">delay 20–200 ms</span><button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button></div>
|
||||
<div class="profiles-row"><span>rainbow</span><span class="muted-text">delay 10–80 ms</span><button type="button" class="btn btn-secondary btn-small" tabindex="-1">Edit</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Patterns</strong> (Edit mode): reference list of pattern names and typical delay ranges.</li>
|
||||
<li>Choose the pattern inside the preset editor; parameters map to <strong>n1–n8</strong>.</li>
|
||||
<li>Wi‑Fi drivers can install pattern modules over HTTP (OTA upload); ESP-NOW devices use the bridge you configure in <strong>Settings</strong>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-colour-palette" class="help-tab-panel" data-help-panel="colour-palette" role="tabpanel" aria-labelledby="help-tab-colour-palette" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Colour Palette</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted-text">Profile: <span>House default</span></p>
|
||||
<div id="palette-container" class="profiles-list">
|
||||
<div class="profiles-row" style="display:flex;align-items:center;gap:1rem;">
|
||||
<div style="width:64px;height:64px;border-radius:8px;background:#7e57c2;border:2px solid #4a4a4a;flex-shrink:0;"></div>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
|
||||
</div>
|
||||
<div class="profiles-row" style="display:flex;align-items:center;gap:1rem;">
|
||||
<div style="width:64px;height:64px;border-radius:8px;background:#26a69a;border:2px solid #4a4a4a;flex-shrink:0;"></div>
|
||||
<button type="button" class="btn btn-danger btn-small" tabindex="-1">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<input type="color" value="#ffffff" tabindex="-1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help-ui-preview-caption">Add or change swatches; linked preset colours update automatically.</p>
|
||||
<ul>
|
||||
<li><strong>Colour Palette</strong> (Edit mode): edits the current profile’s palette swatches.</li>
|
||||
<li>Use <strong>From Palette</strong> in the preset editor for colours that stay in sync (badge <strong>P</strong>).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-audio" class="help-tab-panel" data-help-panel="audio" role="tabpanel" aria-labelledby="help-tab-audio" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content audio-modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Audio Beat Detection</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group audio-device-block">
|
||||
<label>Input device</label>
|
||||
<div class="profiles-actions audio-device-select-row">
|
||||
<select tabindex="-1"><option>Monitor of Built-in Audio</option></select>
|
||||
<button type="button" class="btn btn-secondary btn-small" tabindex="-1">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beat indicators</label>
|
||||
<button type="button" class="audio-beat-sync-btn audio-modal-beat-sync" tabindex="-1">
|
||||
<span class="audio-top-indicator-label">BPM</span>
|
||||
<span class="audio-top-indicator-value">128</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-group audio-volume-block">
|
||||
<div class="audio-volume-header">
|
||||
<label>Volume</label>
|
||||
<span class="audio-volume-readout">100% (0.00 dB)</span>
|
||||
</div>
|
||||
<input type="range" class="audio-volume-slider" min="0" max="200" value="100" tabindex="-1">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" tabindex="-1">Start</button>
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Audio</strong>: beat detection from a chosen input device (monitor sources follow playback).</li>
|
||||
<li>BPM and beat indicators appear in the header and Audio modal while detection is running.</li>
|
||||
<li>Adjust <strong>Volume</strong> (gain before detection); the level meter shows live input.</li>
|
||||
<li><strong>Start</strong> / <strong>Stop</strong> detection; <strong>Reset detector</strong> clears stuck BPM tracking.</li>
|
||||
<li>Sync sequences to music with <kbd>S</kbd> on a downbeat while a sequence plays.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="help-panel-settings" class="help-tab-panel" data-help-panel="settings" role="tabpanel" aria-labelledby="help-tab-settings" hidden>
|
||||
<div class="help-ui-preview" aria-hidden="true">
|
||||
<div class="modal-content settings-modal-content help-preview-surface">
|
||||
<div class="modal-head">
|
||||
<h2>Settings</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" tabindex="-1">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist">
|
||||
<button type="button" class="settings-tab-btn active" tabindex="-1">Bridge</button>
|
||||
<button type="button" class="settings-tab-btn" tabindex="-1">LED Tool</button>
|
||||
</div>
|
||||
<div class="settings-section">
|
||||
<span class="muted-text">USB serial: /dev/ttyUSB0 (connected)</span>
|
||||
<h3 class="settings-subheading" style="margin-top:0.75rem;">Wi-Fi</h3>
|
||||
<p class="muted-text" style="margin:0;">Bridge-AP — ws://192.168.4.1/ws</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>Settings</strong> (Edit mode): <strong>Bridge</strong> connects the Pi to ESP-NOW hardware over USB serial or Wi‑Fi.</li>
|
||||
<li>Save bridge profiles, scan for the bridge AP, and check connection status.</li>
|
||||
<li><strong>LED Tool</strong>: USB serial setup for drivers — <code>settings.json</code>, deploy, flash, and maintenance.</li>
|
||||
<li>LED Tool configures devices directly; this UI controls profiles, zones, presets, and runtime messages.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Modal -->
|
||||
<div id="audio-modal" class="modal">
|
||||
<div class="modal-content audio-modal-content">
|
||||
<h2>Audio Beat Detection</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Audio Beat Detection</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group audio-device-block">
|
||||
<label for="audio-device-select">Input device</label>
|
||||
<div class="profiles-actions audio-device-select-row">
|
||||
@@ -670,7 +1151,6 @@
|
||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,7 +1158,12 @@
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content settings-modal-content">
|
||||
<h2>Settings</h2>
|
||||
<div class="modal-head">
|
||||
<h2>Settings</h2>
|
||||
<div class="modal-top-actions">
|
||||
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
|
||||
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
|
||||
@@ -765,9 +1250,6 @@
|
||||
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,13 +7,21 @@ 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) |
|
||||
| `conftest.py` | Pytest fixtures |
|
||||
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
|
||||
|
||||
167
tests/api_server.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""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"
|
||||
LIB_PATH = PROJECT_ROOT / "lib"
|
||||
|
||||
for p in (str(PROJECT_ROOT), str(LIB_PATH), 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(LIB_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)
|
||||
@@ -1,6 +1,8 @@
|
||||
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"
|
||||
|
||||
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())
|
||||
@@ -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]
|
||||
|
||||
@@ -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,12 @@ 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"]}')
|
||||
|
||||
|
||||
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")
|
||||
@@ -377,7 +114,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"]
|
||||
|
||||
@@ -553,7 +290,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 +409,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 +441,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 +564,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,4 +1,4 @@
|
||||
"""Zone content_kind is fixed after create."""
|
||||
"""Zones may hold both presets and sequences."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -12,7 +12,7 @@ sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
from models.zone import Zone # noqa: E402
|
||||
|
||||
|
||||
def test_update_cannot_change_content_kind():
|
||||
def test_zone_presets_and_sequences_can_coexist():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "zone.json")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
@@ -20,8 +20,16 @@ def test_update_cannot_change_content_kind():
|
||||
z = Zone()
|
||||
z.file = path
|
||||
z.clear()
|
||||
zid = z.create("preset zone", group_ids=[], content_kind="presets")
|
||||
z.update(zid, {"content_kind": "sequences", "name": "preset zone"})
|
||||
zid = z.create("mixed zone", group_ids=[], content_kind="presets")
|
||||
z.update(
|
||||
zid,
|
||||
{
|
||||
"presets": [["p1", "p2"]],
|
||||
"sequence_ids": ["seq1"],
|
||||
},
|
||||
)
|
||||
doc = z.read(zid)
|
||||
assert doc["content_kind"] == "presets"
|
||||
assert doc.get("sequence_ids") == []
|
||||
assert doc.get("sequence_ids") == ["seq1"]
|
||||
preset_ids = Zone._preset_ids_in_doc(doc)
|
||||
assert "p1" in preset_ids
|
||||
assert "p2" in preset_ids
|
||||
|
||||