8 Commits

Author SHA1 Message Date
cb9758b97b fix(api): align zone content kind validation with model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:42 +12:00
aab62efd4f feat(ui): refresh layout, help assets, and panel styling
Update the main template and client scripts for the revised navigation
and zone/device panels, and add bundled help SVG assets under static.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:41 +12:00
2382ef16a1 refactor(api): migrate server to fastapi and uvicorn
Replace the Microdot-only entrypoint with a CombinedASGI app that
handles FastAPI routes (audio API, websocket, dev live-reload) while
delegating the rest to Microdot. Suppress noisy /__dev/ access logs
during live-reload polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 10:33:38 +12:00
cfdd6de291 docs(espnow): update docs and tests for p2p merge
Align API, architecture, and help with devices envelope transport,
bridge wifi/serial settings, and MAC-keyed device registry. Fix
endpoint tests for envelope identify payloads; remove obsolete p2p.py.
Bump led-tool for --serial-usb bridge provisioning.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 21:10:06 +12:00
d682753e42 chore(submodules): bump led-driver
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
53976cdd70 chore(scripts): add mpremote ESP-NOW ch5 send helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
94635a8cc7 chore(db): add devices to test group
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
de0547615c feat(ui): add device from devices modal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:59 +12:00
55 changed files with 3240 additions and 1439 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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 WiFi 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

File diff suppressed because one or more lines are too long

View File

@@ -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 drivers 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 WiFi 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`**. WiFi 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 drivers 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 WiFi 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` (111). 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 WiFi 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 WiFi 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 WiFi 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 0255 (driver applies this in addition to per-preset brightness).
### Preset object (wire / driver keys)

View File

@@ -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)

View File

@@ -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 WiFi:
```json
{
"bridge_transport": "serial",
"bridge_serial_port": "/dev/ttyACM0",
"bridge_serial_baudrate": 921600,
"wifi_channel": 5
}
```
**WiFi 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) |

View File

@@ -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 devices 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 **WiFi** 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.

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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
View 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})
"

View 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()

View File

@@ -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
View 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
View 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()

View File

@@ -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
View 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})

View File

@@ -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

View File

@@ -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 {

View File

@@ -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();
})();

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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) => {

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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);

View File

@@ -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 = [];

View File

@@ -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');
};

View File

@@ -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', () => {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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();
}
});
}

View File

@@ -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 + WiFi 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 WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes 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;">WiFi 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 &amp; 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, WiFi 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 WiFi 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">&#10003; 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 WiFi 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 WiFi defaults once per group, then attach groups to a zone.</li>
<li>Standalone presets use the zones 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>n1n8</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 20200 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 1080 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>n1n8</strong>.</li>
<li>WiFi 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 profiles 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 WiFi.</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>

View File

@@ -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-NOWrelated 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
View 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)

View File

@@ -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"

View File

@@ -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())

View File

@@ -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]

View File

@@ -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

View File

@@ -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