7 Commits

Author SHA1 Message Date
0ae39ab94b chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:55:07 +12:00
822d9d8e01 feat(audio): move beat routing server-side and extend presets
Route beat-triggered manual selects from the controller server, add preset background and beat-counter UI support, and bump led-driver to include the matching pattern/runtime fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:08:05 +12:00
1db905eaae feat(patterns): add supports_manual metadata in db/pattern.json
Allow staging db/pattern.json by replacing blanket db/ ignore with a whitelist for tracked db files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 17:06:56 +12:00
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
pi
827eb97203 feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI).

- Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects.

- Zones UI loads brightness from GET /settings only (no localStorage).

- Bump led-driver submodule for settings.save on brightness with save flag.

- Extend API doc and endpoint tests for global_brightness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:30 +12:00
45 changed files with 4600 additions and 241 deletions

View File

@@ -7,6 +7,8 @@ alwaysApply: true
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`. 1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. 2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there. Optionally set **`supports_manual`** to `false` when the pattern is a poor fit for manual mode or audio beat triggers (smooth/blended animations); omit or `true` otherwise.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern. 3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.
4. For any pattern that supports both auto and manual modes, keep behaviour parity unless explicitly requested otherwise: background colour handling, colour-cycling order, and parameter timing semantics (e.g. `n2`/`n3` meaning) must match between auto and manual paths.

View File

@@ -0,0 +1,18 @@
---
description: Keep led-driver and led-tool git submodules in sync when updating led-controller
alwaysApply: true
---
# Submodule pointers (`led-driver`, `led-tool`)
This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`).
When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**:
1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision).
2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits.
3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees).
**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first.
If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule).

11
.gitignore vendored
View File

@@ -28,6 +28,17 @@ Thumbs.db
scripts/.led-controller-venv scripts/.led-controller-venv
docs/.help-print.html docs/.help-print.html
settings.json settings.json
# Track shared JSON + preset binaries; ignore other db/*.json (e.g. device, zone) locally
db/*
!db/group.json
!db/palette.json
!db/pattern.json
!db/preset.json
!db/profile.json
!db/scene.json
!db/sequence.json
!db/presets/
!db/presets/*.bin
*.log *.log
*.db *.db
*.sqlite *.sqlite

13
Pipfile
View File

@@ -14,6 +14,8 @@ selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*" microdot = "*"
websockets = "*" websockets = "*"
numpy = "*"
sounddevice = "*"
[dev-packages] [dev-packages]
pytest = "*" pytest = "*"
@@ -22,9 +24,10 @@ pytest = "*"
python_version = "3.11" python_version = "3.11"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles \"python tests/web.py\" src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh" 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'"

224
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "98da2012e549e7b62ed49a5e1717acaf535b71e8df61bf4108d25b9023be612e" "sha256": "7975a888034cb3e0f68c7b866f7e69e5a1db7a5228fe1f02d6cb5f9c180de557"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -40,6 +40,13 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==26.1.0" "version": "==26.1.0"
}, },
"aubio": {
"hashes": [
"sha256:df1244f6c4cf5bea382c8c2d35aa43bc31f4cf631fe325ae3992c219546a4202"
],
"index": "pypi",
"version": "==0.4.9"
},
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
"sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80", "sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
@@ -252,7 +259,7 @@
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf" "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
], ],
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'", "markers": "python_version >= '3.9'",
"version": "==2.0.0" "version": "==2.0.0"
}, },
"charset-normalizer": { "charset-normalizer": {
@@ -400,64 +407,65 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13",
"sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6",
"sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8",
"sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25",
"sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c",
"sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832",
"sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12",
"sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c",
"sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7",
"sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c",
"sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec",
"sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5",
"sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355",
"sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c",
"sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741",
"sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86",
"sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321",
"sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a",
"sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7",
"sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920",
"sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e",
"sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff",
"sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd",
"sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3",
"sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f",
"sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602",
"sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855",
"sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18",
"sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a",
"sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336",
"sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239",
"sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74",
"sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a",
"sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c",
"sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4",
"sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c",
"sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f",
"sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4",
"sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db",
"sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166",
"sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5",
"sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f",
"sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae",
"sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20",
"sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a",
"sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057",
"sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb",
"sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c",
"sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736" "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.9' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==47.0.0" "version": "==48.0.0"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0" "version": "==5.2.0"
}, },
"h11": { "h11": {
@@ -485,11 +493,11 @@
}, },
"markdown-it-py": { "markdown-it-py": {
"hashes": [ "hashes": [
"sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49",
"sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==4.0.0" "version": "==4.2.0"
}, },
"mdurl": { "mdurl": {
"hashes": [ "hashes": [
@@ -505,6 +513,7 @@
"sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721" "sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.1" "version": "==2.6.1"
}, },
"mpremote": { "mpremote": {
@@ -513,8 +522,88 @@
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31" "sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.28.0" "version": "==1.28.0"
}, },
"numpy": {
"hashes": [
"sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed",
"sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50",
"sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959",
"sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827",
"sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd",
"sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233",
"sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc",
"sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b",
"sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7",
"sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e",
"sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a",
"sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d",
"sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3",
"sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e",
"sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb",
"sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a",
"sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0",
"sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e",
"sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113",
"sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103",
"sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93",
"sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af",
"sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5",
"sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7",
"sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392",
"sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c",
"sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4",
"sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40",
"sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf",
"sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44",
"sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b",
"sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5",
"sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e",
"sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74",
"sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0",
"sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e",
"sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec",
"sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015",
"sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d",
"sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d",
"sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842",
"sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150",
"sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8",
"sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a",
"sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed",
"sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f",
"sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008",
"sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e",
"sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0",
"sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e",
"sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f",
"sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a",
"sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40",
"sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7",
"sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83",
"sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d",
"sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c",
"sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871",
"sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502",
"sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252",
"sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8",
"sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115",
"sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f",
"sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e",
"sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d",
"sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0",
"sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119",
"sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e",
"sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db",
"sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121",
"sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d",
"sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"
],
"index": "pypi",
"markers": "python_version >= '3.11'",
"version": "==2.4.4"
},
"outcome": { "outcome": {
"hashes": [ "hashes": [
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8", "sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
@@ -553,6 +642,7 @@
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1" "version": "==2.12.1"
}, },
"pyserial": { "pyserial": {
@@ -671,6 +761,7 @@
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.1" "version": "==2.33.1"
}, },
"rich": { "rich": {
@@ -695,6 +786,7 @@
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e" "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0" "version": "==4.43.0"
}, },
"sniffio": { "sniffio": {
@@ -712,6 +804,19 @@
], ],
"version": "==2.4.0" "version": "==2.4.0"
}, },
"sounddevice": {
"hashes": [
"sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722",
"sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103",
"sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3",
"sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f",
"sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6",
"sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.5.5"
},
"tibs": { "tibs": {
"hashes": [ "hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a", "sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
@@ -774,7 +879,9 @@
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": { "urllib3": {
"extras": [], "extras": [
"socks"
],
"hashes": [ "hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -895,6 +1002,7 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"websocket-client": { "websocket-client": {
@@ -970,6 +1078,7 @@
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==16.0" "version": "==16.0"
}, },
"wsproto": { "wsproto": {
@@ -1020,6 +1129,7 @@
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==9.0.3" "version": "==9.0.3"
} }
} }

View File

@@ -1 +0,0 @@
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}

View File

@@ -1 +1 @@
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}} {"1": {"name": "Main Group", "devices": ["188b0e1560a8"], "wifi_driver_display_name": "desk", "wifi_driver_num_leds": 59, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "output_brightness": 255}}

View File

@@ -1 +1,291 @@
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}} {
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1,
"supports_manual": true
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"chase": {
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": false
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": false
},
"flicker": {
"n1": "Min brightness",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"flame": {
"n1": "Min brightness",
"n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"radiate": {
"n1": "Node spacing (LEDs)",
"n2": "Out time (ms)",
"n3": "In time (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2,
"has_background": true,
"supports_manual": true
},
"meteor_rain": {
"n1": "Tail length",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (1-255)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"scanner": {
"n1": "Eye width",
"n2": "End pause (frames)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"gradient_scroll": {
"n1": "Scroll step rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"comet_dual": {
"n1": "Tail length",
"n2": "Speed",
"n3": "Gap",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"sparkle_trail": {
"n1": "Spark density",
"n2": "Decay",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": true
},
"wave": {
"n1": "Wavelength",
"n2": "Amplitude",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"plasma": {
"n1": "Scale",
"n2": "Speed",
"n3": "Contrast",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"segment_chase": {
"n1": "Segment size",
"n2": "Phase step",
"n3": "Segment phase offset",
"n4": "Gap per segment",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"bar_graph": {
"n1": "Level percent",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": false
},
"breathing_dual": {
"n1": "Phase offset",
"n2": "Ease",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"strobe_burst": {
"n1": "Burst count",
"n2": "Burst gap",
"n3": "Cooldown",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"rain_drops": {
"n1": "Drop rate",
"n2": "Ripple width",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"fireflies": {
"n1": "Count",
"n2": "Twinkle speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": {
"n1": "Hand width",
"n2": "Marker interval",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"marquee": {
"n1": "On length",
"n2": "Off length",
"n3": "Step",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": {
"n1": "Band count",
"n2": "Shimmer",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"snowfall": {
"n1": "Flake density",
"n2": "Fall speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"heartbeat": {
"n1": "Pulse 1 ms",
"n2": "Pulse 2 ms",
"n3": "Pause ms",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"orbit": {
"n1": "Orbit count",
"n2": "Base speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"palette_morph": {
"n1": "Morph ms",
"n2": "Warp rate",
"n3": "Turbulence",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

View File

@@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) | | GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) | | GET | `/settings/page` | Standalone settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) | | GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` | | GET | `/static/<path>` | Static files under `src/static/` |
@@ -72,7 +72,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. | | PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). | | GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). | | GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices` ### Devices — `/devices`

253
scripts/pi-eth-lan-router.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Configure Raspberry Pi OS: Wi-Fi client on IF_WAN (default wlan0), Ethernet IF_LAN
# (default eth0) toward an external AP. Static LAN IP, DHCP via dnsmasq, NAT masquerade.
#
# Usage:
# sudo ./pi-eth-lan-router.sh install
# sudo ./pi-eth-lan-router.sh remove
#
# Environment overrides (optional):
# IF_WAN=wlan0 IF_LAN=eth0 LAN_IP=192.168.4.1 LAN_PREFIX=24 \
# DHCP_START=192.168.4.100 DHCP_END=192.168.4.200 \
# DNSMASQ_DNS=1.1.1.1,8.8.8.8 \
# sudo ./pi-eth-lan-router.sh install
set -euo pipefail
IF_WAN="${IF_WAN:-wlan0}"
IF_LAN="${IF_LAN:-eth0}"
LAN_IP="${LAN_IP:-192.168.4.1}"
LAN_PREFIX="${LAN_PREFIX:-24}"
DHCP_START="${DHCP_START:-192.168.4.100}"
DHCP_END="${DHCP_END:-192.168.4.200}"
# Comma-separated DNS for DHCP clients (Pi does not need to run a resolver).
DNSMASQ_DNS="${DNSMASQ_DNS:-1.1.1.1,8.8.8.8}"
NM_CON_NAME="pi-eth-lan-router"
MARK_BEGIN="# BEGIN pi-eth-lan-router (scripts/pi-eth-lan-router.sh)"
MARK_END="# END pi-eth-lan-router"
SYSCTL_FILE="/etc/sysctl.d/99-pi-eth-lan-router.conf"
DNSMASQ_SNIPPET="/etc/dnsmasq.d/pi-eth-lan-router.conf"
NFT_SNIPPET="/etc/nftables.d/50-pi-eth-lan-router.nft"
NFT_INCLUDE='include "/etc/nftables.d/50-pi-eth-lan-router.nft"'
NFTABLES_CONF="/etc/nftables.conf"
DHCPCD_CONF="/etc/dhcpcd.conf"
die() { echo "error: $*" >&2; exit 1; }
log() { echo "$*"; }
need_root() {
[[ "${EUID:-0}" -eq 0 ]] || die "run as root (sudo)"
}
have_cmd() { command -v "$1" >/dev/null 2>&1; }
apt_install() {
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq dnsmasq nftables
}
write_sysctl() {
cat >"$SYSCTL_FILE" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
net.ipv4.ip_forward=1
EOF
sysctl --system -q 2>/dev/null || sysctl -p "$SYSCTL_FILE" || true
}
remove_sysctl() {
rm -f "$SYSCTL_FILE"
sysctl --system -q 2>/dev/null || true
}
write_dnsmasq() {
local mask="255.255.255.0"
if [[ "$LAN_PREFIX" != "24" ]]; then
die "only LAN_PREFIX=24 is supported by this script (extend dnsmasq netmask manually)"
fi
cat >"$DNSMASQ_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
interface=$IF_LAN
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,$mask,24h
dhcp-option=option:router,$LAN_IP
dhcp-option=option:dns-server,$DNSMASQ_DNS
EOF
}
remove_dnsmasq() {
rm -f "$DNSMASQ_SNIPPET"
}
write_nft() {
mkdir -p /etc/nftables.d
cat >"$NFT_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
table ip pi_eth_wlan_nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "$IF_WAN" masquerade
}
}
EOF
if [[ -f "$NFTABLES_CONF" ]] && ! grep -qF '50-pi-eth-lan-router.nft' "$NFTABLES_CONF" 2>/dev/null; then
printf '\n# pi-eth-lan-router\n%s\n' "$NFT_INCLUDE" >>"$NFTABLES_CONF"
elif [[ ! -f "$NFTABLES_CONF" ]]; then
log "warning: $NFTABLES_CONF missing; NAT was not added for boot persistence. Install/configure nftables, or add: $NFT_INCLUDE"
fi
}
remove_nft() {
rm -f "$NFT_SNIPPET"
if [[ -f "$NFTABLES_CONF" ]]; then
sed -i '/# pi-eth-lan-router/d;/50-pi-eth-lan-router\.nft/d' "$NFTABLES_CONF" || true
fi
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
}
apply_nft() {
if have_cmd nft; then
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
nft -f "$NFT_SNIPPET"
fi
}
configure_nm_eth() {
have_cmd nmcli || return 1
systemctl is-active --quiet NetworkManager 2>/dev/null || return 1
if nmcli -t -f NAME con show --active 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con down "$NM_CON_NAME" || true
fi
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con mod "$NM_CON_NAME" \
connection.interface-name "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
else
nmcli con add type ethernet con-name "$NM_CON_NAME" ifname "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
fi
if ! nmcli con up "$NM_CON_NAME"; then
log "warning: could not activate '$NM_CON_NAME' (is $IF_LAN connected?); profile saved for next boot."
fi
return 0
}
remove_nm_eth() {
have_cmd nmcli || return 0
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con delete "$NM_CON_NAME" || true
fi
}
configure_dhcpcd_eth() {
[[ -f "$DHCPCD_CONF" ]] || return 1
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
fi
{
echo "$MARK_BEGIN"
echo "interface $IF_LAN"
echo "static ip_address=${LAN_IP}/${LAN_PREFIX}"
echo "nohook wpa_supplicant"
echo "$MARK_END"
} >>"$DHCPCD_CONF"
systemctl restart dhcpcd 2>/dev/null || true
return 0
}
remove_dhcpcd_block() {
[[ -f "$DHCPCD_CONF" ]] || return 0
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
systemctl restart dhcpcd 2>/dev/null || true
fi
}
configure_eth_static() {
if configure_nm_eth; then
log "configured $IF_LAN via NetworkManager profile '$NM_CON_NAME'"
return 0
fi
if configure_dhcpcd_eth; then
log "configured $IF_LAN via dhcpcd ($DHCPCD_CONF)"
return 0
fi
die "neither NetworkManager (active) nor $DHCPCD_CONF found; set $IF_LAN to ${LAN_IP}/${LAN_PREFIX} manually"
}
remove_eth_static() {
remove_nm_eth
remove_dhcpcd_block
}
do_install() {
need_root
log "installing packages (dnsmasq, nftables)…"
apt_install
log "writing sysctl, dnsmasq, nftables snippets…"
write_sysctl
write_dnsmasq
write_nft
log "setting static IP on $IF_LAN"
configure_eth_static
log "restarting dnsmasq…"
systemctl enable dnsmasq
systemctl restart dnsmasq
log "loading NAT rules and enabling nftables…"
apply_nft
systemctl enable nftables 2>/dev/null || true
systemctl restart nftables 2>/dev/null || true
log "done. Connect $IF_LAN to the external AP (DHCP off on the AP)."
log "Join Wi-Fi on $IF_WAN to the uplink network and complete any captive portal on the Pi."
}
do_remove() {
need_root
remove_eth_static
remove_dnsmasq
systemctl restart dnsmasq 2>/dev/null || true
remove_nft
systemctl restart nftables 2>/dev/null || true
remove_sysctl
sysctl -w net.ipv4.ip_forward=0 2>/dev/null || true
log "removed pi-eth-lan-router configuration snippets and NM profile '$NM_CON_NAME' (if present)."
}
usage() {
cat <<EOF
Usage: sudo $0 install|remove
WAN (Wi-Fi client): $IF_WAN
LAN (Ethernet to AP): $IF_LAN
LAN address: ${LAN_IP}/${LAN_PREFIX}
DHCP range: $DHCP_START $DHCP_END
Override with environment variables (see script header).
EOF
}
case "${1:-}" in
install) do_install ;;
remove) do_remove ;;
*) usage; exit 1 ;;
esac

View File

@@ -2,10 +2,14 @@ from microdot import Microdot
from models.device import ( from models.device import (
Device, Device,
derive_device_mac, derive_device_mac,
normalize_mac,
validate_device_transport, validate_device_transport,
validate_device_type, validate_device_type,
) )
from models.group import Group
from models.transport import get_current_sender from models.transport import get_current_sender
from settings import Settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import ( from models.wifi_ws_clients import (
normalize_tcp_peer_ip, normalize_tcp_peer_ip,
send_json_line_to_ip, send_json_line_to_ip,
@@ -52,8 +56,28 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch). # Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0 IDENTIFY_OFF_DELAY_S = 2.0
def _validate_output_brightness(value):
if value is None:
return None
try:
b = int(value)
except (TypeError, ValueError):
raise ValueError("output_brightness must be an integer 0255")
if b < 0 or b > 255:
raise ValueError("output_brightness must be between 0 and 255")
return b
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
controller = Microdot() controller = Microdot()
devices = Device() devices = Device()
_group_registry = Group()
_pi_settings = Settings()
def _device_live_connected(dev_dict): def _device_live_connected(dev_dict):
@@ -154,6 +178,42 @@ async def list_devices(request):
return json.dumps(devices_data), 200, {"Content-Type": "application/json"} return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.post("/resolve-brightness")
async def resolve_brightness_batch(request):
"""
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = request.json or {}
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return json.dumps({"error": "macs must be an array"}), 400, {
"Content-Type": "application/json",
}
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
values = {}
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
values[m] = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
m,
zone_brightness=zb,
)
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
@controller.get("/<id>") @controller.get("/<id>")
async def get_device(request, id): async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence).""" """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
@@ -239,7 +299,17 @@ async def update_device(request, id):
data["transport"] = validate_device_transport(data.get("transport")) data["transport"] = validate_device_transport(data.get("transport"))
if "zones" in data and isinstance(data["zones"], list): if "zones" in data and isinstance(data["zones"], list):
data["zones"] = [str(t) for t in data["zones"]] data["zones"] = [str(t) for t in data["zones"]]
if "output_brightness" in data:
data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
prev_doc = devices.read(id)
if devices.update(id, data): if devices.update(id, data):
if prev_doc and "name" in data:
on = str(prev_doc.get("name") or "").strip()
nn = str(data.get("name") or "").strip()
if on and nn and on != nn:
from util.beat_driver_route import remap_beat_route_device_name
remap_beat_route_device_name(on, nn)
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, { return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -320,6 +390,120 @@ async def identify_device(request, id):
} }
@controller.post("/<id>/brightness")
async def push_device_output_brightness(request, id):
"""
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
in JSON body — single ``b`` (``v``/``b``/``save``). WiFi or ESPNOW.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
body = request.json or {}
zb = None
if isinstance(body, dict) and body.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(body.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
b_val = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
id,
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
ok = await send_json_line_to_ip(ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
"""
Push ``device_config`` to a WiFi LED driver over WebSocket.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
body = request.json or {}
dc = {}
if isinstance(body.get("name"), str) and body["name"].strip():
dc["name"] = body["name"].strip()
if "num_leds" in body:
try:
n = int(body["num_leds"])
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
if isinstance(body.get("color_order"), str):
co = body["color_order"].strip().lower()
if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = co
if isinstance(body.get("startup_mode"), str):
sm = body["startup_mode"].strip().lower()
if sm in ("default", "last", "off"):
dc["startup_mode"] = sm
if not dc:
return json.dumps(
{
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "driver-config sent"}), 200, {
"Content-Type": "application/json",
}
@controller.post("/<id>/patterns/push") @controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id): async def push_patterns_ota(request, id):
""" """

View File

@@ -1,9 +1,16 @@
from microdot import Microdot from microdot import Microdot
from models.group import Group from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from util.brightness_combine import effective_brightness_for_mac
import json import json
controller = Microdot() controller = Microdot()
groups = Group() groups = Group()
devices = Device()
_pi_settings = Settings()
@controller.get('') @controller.get('')
async def list_groups(request): async def list_groups(request):
@@ -48,3 +55,150 @@ async def delete_group(request, id):
if groups.delete(id): if groups.delete(id):
return json.dumps({"message": "Group deleted successfully"}), 200 return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
def _group_driver_config_payload(doc):
"""Build ``device_config`` dict from stored group WiFi defaults (non-empty only)."""
dc = {}
if not isinstance(doc, dict):
return dc
nm = doc.get("wifi_driver_display_name")
if isinstance(nm, str) and nm.strip():
dc["name"] = nm.strip()
nled = doc.get("wifi_driver_num_leds")
if nled is not None:
try:
n = int(nled)
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
co = doc.get("wifi_color_order")
if isinstance(co, str):
c = co.strip().lower()
if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = c
sm = doc.get("wifi_startup_mode")
if isinstance(sm, str):
s = sm.strip().lower()
if s in ("default", "last", "off"):
dc["startup_mode"] = s
return dc
@controller.post('/<id>/driver-config')
async def push_group_driver_config(request, id):
"""
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = groups.read(id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
body = request.json or {}
merged = dict(gdoc)
if isinstance(body, dict):
for k in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if k in body:
merged[k] = body[k]
dc = _group_driver_config_payload(merged)
if not dc:
return json.dumps(
{"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"}
), 400, {"Content-Type": "application/json"}
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
if (dev.get("transport") or "").lower() != "wifi":
continue
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent += 1
else:
errors.append({"mac": m, "error": "driver not connected"})
return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post('/<id>/brightness')
async def push_group_output_brightness(request, id):
"""
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
"""
gdoc = groups.read(id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
continue
dev = devices.read(m)
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
b_val = effective_brightness_for_mac(
_pi_settings,
groups,
devices,
m,
zone_brightness=None,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
ok = await send_json_line_to_ip(ip, msg)
if ok:
sent += 1
else:
errors.append({"mac": m, "error": "driver not connected"})
else:
sender = get_current_sender()
if not sender:
errors.append({"mac": m, "error": "transport not configured"})
continue
try:
await sender.send(msg, addr=m)
sent += 1
except Exception as e:
errors.append({"mac": m, "error": str(e)})
return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}

View File

@@ -367,6 +367,8 @@ async def create_driver_pattern(request):
Body JSON: Body JSON:
name, code (required), name, code (required),
min_delay, max_delay, max_colors (optional numbers), min_delay, max_delay, max_colors (optional numbers),
has_background (optional bool),
supports_manual (optional bool, default true if omitted in db),
n1..n8 (optional string labels), n1..n8 (optional string labels),
overwrite (optional, default true). overwrite (optional, default true).
""" """
@@ -409,6 +411,12 @@ async def create_driver_pattern(request):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
if "supports_manual" in data:
meta["supports_manual"] = bool(data.get("supports_manual"))
for i in range(1, 9): for i in range(1, 9):
nk = "n%d" % i nk = "n%d" % i
if nk not in data: if nk not in data:

View File

@@ -315,6 +315,13 @@ async def push_driver_messages(request, session):
except Exception: except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
try:
from util.beat_driver_route import sync_beat_route_from_push_sequence
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
except Exception:
pass
return json.dumps({ return json.dumps({
"message": "Delivered", "message": "Delivered",
"deliveries": deliveries, "deliveries": deliveries,

View File

@@ -1,7 +1,11 @@
from microdot import Microdot, send_file import asyncio
from settings import Settings
import json import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
controller = Microdot() controller = Microdot()
settings = Settings() settings = Settings()
@@ -63,17 +67,36 @@ def _validate_wifi_channel(value):
return ch return ch
def _validate_global_brightness(value):
"""Return int 0255 or raise ValueError."""
v = int(value)
if v < 0 or v > 255:
raise ValueError("global_brightness must be between 0 and 255")
return v
@controller.put('/settings') @controller.put('/settings')
async def update_settings(request): async def update_settings(request):
"""Update general settings.""" """Update general settings."""
try: try:
data = request.json data = request.json
global_brightness_changed = False
for key, value in data.items(): for key, value in data.items():
if key == 'wifi_channel' and value is not None: if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value) settings[key] = _validate_wifi_channel(value)
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
else: else:
settings[key] = value settings[key] = value
settings.save() settings.save()
if global_brightness_changed:
try:
asyncio.get_running_loop().create_task(
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
)
except RuntimeError:
pass
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e: except ValueError as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -290,6 +290,7 @@ async def create_zone(request, session):
ids_str = request.form.get("ids", "1").strip() ids_str = request.form.get("ids", "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()] names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None preset_ids = None
group_ids = []
else: else:
data = request.json or {} data = request.json or {}
name = data.get("name", "") name = data.get("name", "")
@@ -297,11 +298,18 @@ async def create_zone(request, session):
if names is None: if names is None:
names = data.get("ids") names = data.get("ids")
preset_ids = data.get("presets", None) preset_ids = data.get("presets", None)
group_ids = data.get("group_ids")
if group_ids is None:
group_ids = []
if isinstance(group_ids, list):
group_ids = [str(x) for x in group_ids if x is not None]
else:
group_ids = []
if not name: if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400 return json.dumps({"error": "Zone name cannot be empty"}), 400
zid = zones.create(name, names, preset_ids) zid = zones.create(name, names, preset_ids, group_ids)
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
if profile_id: if profile_id:
@@ -333,7 +341,12 @@ async def clone_zone(request, session, id):
data = request.json or {} data = request.json or {}
source_name = source.get("name") or f"Zone {id}" source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy" new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(new_name, source.get("names"), source.get("presets")) clone_id = zones.create(
new_name,
source.get("names"),
source.get("presets"),
source.get("group_ids"),
)
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")} extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra: if extra:
zones.update(clone_id, extra) zones.update(clone_id, extra)

View File

@@ -31,6 +31,7 @@ from util.device_status_broadcaster import (
register_device_status_ws, register_device_status_ws,
unregister_device_status_ws, unregister_device_status_ws,
) )
from util.audio_detector import AudioBeatDetector
_tcp_device_lock = threading.Lock() _tcp_device_lock = threading.Lock()
@@ -246,6 +247,10 @@ async def main(port=80):
set_sender(sender) set_sender(sender)
app = Microdot() app = Microdot()
audio_detector = AudioBeatDetector()
from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
# Initialize sessions with a secret key from settings # Initialize sessions with a secret key from settings
secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production') secret_key = settings.get('session_secret_key', 'led-controller-secret-key-change-in-production')
@@ -284,18 +289,51 @@ async def main(port=80):
def index(request): def index(request):
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('templates/index.html') return send_file('templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed) # Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(request): def favicon(request):
return '', 204 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
else:
try:
device = int(device)
except (TypeError, ValueError):
pass
try:
audio_detector.start(device=device)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@app.route('/api/audio/stop', methods=['POST'])
async def audio_stop(request):
_ = request
audio_detector.stop()
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
return {"status": audio_detector.status()}
# Static file route # Static file route
@app.route("/static/<path:path>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):
@@ -354,6 +392,10 @@ async def main(port=80):
def _graceful_shutdown(*_args): def _graceful_shutdown(*_args):
print("[server] shutting down...") print("[server] shutting down...")
udp_holder["closing"] = True udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
u = udp_holder.get("sock") u = udp_holder.get("sock")
if u is not None: if u is not None:
try: try:
@@ -389,6 +431,10 @@ async def main(port=80):
) )
raise raise
finally: finally:
try:
audio_detector.stop()
except Exception:
pass
srv = getattr(app, "server", None) srv = getattr(app, "server", None)
if srv is not None: if srv is not None:
try: try:

View File

@@ -38,6 +38,29 @@ def normalize_mac(mac):
return None return None
def resolve_device_mac_for_select_routing(devices, name_key):
"""
Map a v1 ``select`` map key to device storage id (MAC).
Matches the registry **name**, or ``led-<12hex>`` as a MAC hint (default driver
name form) so routing still works after the device is renamed in the registry.
"""
k = str(name_key or "").strip()
if not k:
return None
for did in devices.list():
doc = devices.read(did) or {}
if str(doc.get("name") or "").strip() == k:
m = normalize_mac(did)
if m:
return m
if k.startswith("led-"):
m = normalize_mac(k[4:])
if m and devices.read(m):
return m
return None
def derive_device_mac(mac=None, address=None, transport="espnow"): def derive_device_mac(mac=None, address=None, transport="espnow"):
""" """
Resolve the device MAC used as storage id. Resolve the device MAC used as storage id.

View File

@@ -1,14 +1,66 @@
from models.model import Model from models.model import Model
class Group(Model): class Group(Model):
"""Device groups (members + optional WiFi driver defaults); also pattern fields for sequences."""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
def load(self):
super().load()
changed = False
for gid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if self._migrate_record(doc):
changed = True
if changed:
self.save()
def _migrate_record(self, doc):
changed = False
raw_dev = doc.get("devices")
if raw_dev is None:
doc["devices"] = []
changed = True
elif isinstance(raw_dev, list):
norm = []
for x in raw_dev:
if x is None:
continue
s = str(x).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
norm.append(s)
else:
norm.append(str(x).strip())
if norm != raw_dev:
doc["devices"] = norm
changed = True
for key in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if key not in doc:
doc[key] = None
changed = True
if "output_brightness" not in doc:
doc["output_brightness"] = 255
changed = True
return changed
def create(self, name=""): def create(self, name=""):
next_id = self.get_next_id() next_id = self.get_next_id()
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"devices": [], "devices": [],
"wifi_driver_display_name": None,
"wifi_driver_num_leds": None,
"wifi_color_order": None,
"wifi_startup_mode": None,
"output_brightness": 255,
"pattern": "on", "pattern": "on",
"colors": ["000000", "FF0000"], "colors": ["000000", "FF0000"],
"brightness": 100, "brightness": 100,
@@ -22,7 +74,7 @@ class Group(Model):
"n5": 0, "n5": 0,
"n6": 0, "n6": 0,
"n7": 0, "n7": 0,
"n8": 0 "n8": 0,
} }
self.save() self.save()
return next_id return next_id

View File

@@ -26,6 +26,7 @@ class Preset(Model):
"name": "", "name": "",
"pattern": "", "pattern": "",
"colors": [], "colors": [],
"background": "#000000",
"brightness": 0, "brightness": 0,
"delay": 0, "delay": 0,
"n1": 0, "n1": 0,
@@ -36,6 +37,7 @@ class Preset(Model):
"n6": 0, "n6": 0,
"n7": 0, "n7": 0,
"n8": 0, "n8": 0,
"manual_beat_n": 1,
"profile_id": str(profile_id) if profile_id is not None else None, "profile_id": str(profile_id) if profile_id is not None else None,
} }
self.save() self.save()

View File

@@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None:
_schedule_status_broadcast(ip, False) _schedule_status_broadcast(ip, False)
def _global_brightness_message_text() -> str | None:
"""v1 JSON line for saved zone UI brightness; works with shipping driver firmware (applies ``b`` in RAM)."""
global _settings
if _settings is None:
return None
try:
b = int(_settings.get("global_brightness", 255))
except (TypeError, ValueError):
b = 255
b = max(0, min(255, b))
return json.dumps({"v": "1", "b": b})
async def sync_global_brightness_to_driver(ip: str) -> bool:
"""Push Pi-stored global brightness to one Wi-Fi driver over the outbound WebSocket."""
text = _global_brightness_message_text()
if not text:
return False
return await send_json_line_to_ip(ip, text)
async def broadcast_global_brightness_to_tcp_drivers() -> None:
"""Push saved global brightness to every connected Wi-Fi driver."""
text = _global_brightness_message_text()
if not text:
return
for ip in list_connected_ips():
await send_json_line_to_ip(ip, text)
def _register_ws(ip: str, ws) -> None: def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip) key = normalize_tcp_peer_ip(ip)
if not key: if not key:
@@ -231,33 +261,33 @@ async def _driver_connection_loop(ip: str) -> None:
retry_interval_s = 2.0 retry_interval_s = 2.0
retry_interval_s = max(0.2, retry_interval_s) retry_interval_s = max(0.2, retry_interval_s)
try: try:
retry_window_s = float(_settings.get("wifi_driver_connect_retry_window_s", 120.0)) max_boot_attempts = int(_settings.get("wifi_driver_initial_connect_attempts", 4))
except (TypeError, ValueError): except (TypeError, ValueError):
retry_window_s = 120.0 max_boot_attempts = 4
retry_window_s = max(5.0, retry_window_s) max_boot_attempts = max(1, max_boot_attempts)
try: try:
open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0)) open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0))
except (TypeError, ValueError): except (TypeError, ValueError):
open_timeout = 45.0 open_timeout = 45.0
open_timeout = max(5.0, open_timeout) open_timeout = max(5.0, open_timeout)
loop = asyncio.get_running_loop()
stagger = _stagger_delay_s_for_ip(ip) stagger = _stagger_delay_s_for_ip(ip)
if stagger > 0: if stagger > 0:
await asyncio.sleep(stagger) await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots). # Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False connected_once = False
deadline = loop.time() + retry_window_s boot_attempts = 0
try: try:
while True: while True:
now = loop.time() if not connected_once:
if not connected_once and now >= deadline: if boot_attempts >= max_boot_attempts:
print( print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s " f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"(initial window); stopping until next UDP hello / registry prime" f"initial dial attempt(s); stopping until next UDP hello / registry prime"
) )
break break
boot_attempts += 1
try: try:
print(f"[WS] connecting to {uri!r}") print(f"[WS] connecting to {uri!r}")
async with websockets.connect( async with websockets.connect(

View File

@@ -27,13 +27,30 @@ class Zone(Model):
Zone._migration_checked = True Zone._migration_checked = True
super().__init__() super().__init__()
def create(self, name="", names=None, presets=None): def load(self):
super().load()
changed = False
for zid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if "group_ids" not in doc:
doc["group_ids"] = []
changed = True
if changed:
self.save()
def create(self, name="", names=None, presets=None, group_ids=None):
next_id = self.get_next_id() next_id = self.get_next_id()
gid_list = []
if isinstance(group_ids, list):
gid_list = [str(x) for x in group_ids if x is not None]
self[next_id] = { self[next_id] = {
"name": name, "name": name,
"names": names if names else [], "names": names if names else [],
"group_ids": gid_list,
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255,
} }
self.save() self.save()
return next_id return next_id

View File

@@ -57,8 +57,8 @@ class Settings(dict):
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766. # down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self: if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0 self['wifi_driver_hello_interval_s'] = 10.0
# Outbound WebSocket dial: total seconds to keep trying before first success # Legacy key (no longer read): initial outbound dial limit uses
# (many devices booting at once need more than a short window). # wifi_driver_initial_connect_attempts instead.
if 'wifi_driver_connect_retry_window_s' not in self: if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0 self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once. # Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
@@ -70,9 +70,15 @@ class Settings(dict):
# Pause between outbound WebSocket dial attempts (seconds). # Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self: if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0 self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial). # UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self: if 'serial_enabled' not in self:
self['serial_enabled'] = False self['serial_enabled'] = False
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
def save(self): def save(self):
try: try:

299
src/static/audio.js Normal file
View File

@@ -0,0 +1,299 @@
(() => {
let pollTimer = null;
let lastBeatSeq = 0;
const STORAGE_KEY = "led-controller-audio-restore";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
function el(id) {
return document.getElementById(id);
}
function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value");
if (!node) return;
node.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
const topNode = el("audio-top-bpm-value");
if (topNode) {
topNode.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
}
}
function updateBeatCounter(seq) {
const topNode = el("audio-top-beat-count");
if (!topNode) return;
const n = Number(seq);
topNode.textContent = Number.isFinite(n) && n >= 0 ? `#${Math.floor(n)}` : "#0";
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
const label = String(hitType || "unknown").toLowerCase();
const conf = Number.isFinite(confidence) ? ` (${confidence.toFixed(2)})` : "";
node.textContent = `${label}${conf}`;
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
}
}
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
setTopBpmVisible(false);
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
updateBeatCounter(0);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
console.warn("audio stop failed", e);
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
try {
const res = await fetch("/api/audio/status");
const data = await res.json();
const status = data?.status || {};
if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value");
if (node) {
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
setTopBpmVisible(!!status.running);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
const seq = Number(status.beat_seq || 0);
updateBeatCounter(seq);
if (seq > lastBeatSeq) {
lastBeatSeq = seq;
flashBeat();
}
} catch (e) {
console.warn("audio status poll failed", e);
}
}
async function startAudio() {
await stopAudioOnly();
const override = (el("audio-device-override")?.value || "").trim();
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = { device: rawDevice === "" ? null : numeric };
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
updateBeatCounter(0);
pollTimer = setInterval(pollStatus, 250);
await pollStatus();
}
async function refreshDevices() {
const select = el("audio-device-select");
const debug = el("audio-devices-debug");
if (!select) return;
const current = select.value;
const res = await fetch("/api/audio/devices");
const data = await res.json();
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
if (debug) {
debug.value = JSON.stringify(data?.diagnostics || data, null, 2);
}
inputs.sort((a, b) => {
const am = String(a?.name || "").toLowerCase().includes("monitor");
const bm = String(b?.name || "").toLowerCase().includes("monitor");
if (am !== bm) return am ? -1 : 1;
return Number(a?.id || 0) - Number(b?.id || 0);
});
select.innerHTML = '<option value="">System default input</option>';
let defaultId = "";
inputs.forEach((d, idx) => {
const option = document.createElement("option");
option.value = String(d.id);
option.textContent = d.label || d.name || `Input ${idx + 1}`;
if (d.is_default) {
defaultId = String(d.id);
}
select.appendChild(option);
});
if (current) {
select.value = current;
} else if (defaultId) {
select.value = defaultId;
}
}
function bind() {
const modal = el("audio-modal");
const openBtn = el("audio-btn");
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
try {
await refreshDevices();
} catch (e) {
console.warn("audio device refresh failed", e);
}
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
modal.classList.remove("active");
});
}
if (startBtn) {
startBtn.addEventListener("click", async () => {
try {
await startAudio();
await refreshDevices();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
});
}
if (stopBtn) {
stopBtn.addEventListener("click", async () => {
await stopAudio();
});
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
await refreshDevices();
} catch (e) {
console.error("refresh devices failed", e);
}
});
}
}
async function resumePollingIfDetectorRunning() {
try {
const res = await fetch("/api/audio/status");
const data = await res.json();
const status = data?.status || {};
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
updateBeatCounter(lastBeatSeq);
await pollStatus();
}
} catch (e) {
console.warn("audio resume poll check failed", e);
}
}
async function restoreAudioIfNeeded() {
if (pollTimer) return;
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
try {
await refreshDevices();
} catch (e) {
console.warn("audio restore refresh devices failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
await startAudio();
} catch (e) {
console.warn("audio auto-restart failed", e);
clearRestorePrefs();
}
}
document.addEventListener("DOMContentLoaded", async () => {
bind();
await resumePollingIfDetectorRunning();
await restoreAudioIfNeeded();
});
})();

View File

@@ -149,8 +149,10 @@ function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi'; const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow'); const esp = document.getElementById('edit-device-address-espnow');
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap'); const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
if (esp) esp.hidden = isWifi; if (esp) esp.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi; if (wifiWrap) wifiWrap.hidden = !isWifi;
if (drvWrap) drvWrap.hidden = !isWifi;
} }
function getAddressForPayload(transport) { function getAddressForPayload(transport) {
@@ -166,6 +168,63 @@ function getAddressForPayload(transport) {
return hex || null; return hex || null;
} }
function collectDeviceEditPayload() {
const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const obr = document.getElementById('edit-device-output-brightness');
let output_brightness = 255;
if (obr && obr.value !== '') {
const n = parseInt(obr.value, 10);
output_brightness = !Number.isNaN(n) ? Math.max(0, Math.min(255, n)) : 255;
}
const payload = {
name: nameInput ? nameInput.value.trim() : '',
type: (typeSel && typeSel.value) || 'led',
transport,
address,
output_brightness,
};
if (transport === 'wifi') {
const dn = document.getElementById('edit-device-wifi-driver-name');
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
}
if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
}
return { devId, payload };
}
function refreshEditDeviceDebug() {
const ta = document.getElementById('edit-device-debug');
if (!ta) return;
try {
const { devId, payload } = collectDeviceEditPayload();
const loaded = window.__editDeviceLoadedSnapshot;
ta.value = JSON.stringify(
{
device_id: devId || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
async function loadDevicesModal() { async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal'); const container = document.getElementById('devices-list-modal');
if (!container) return; if (!container) return;
@@ -307,6 +366,11 @@ function renderDevicesList(devices) {
} }
function openEditDeviceModal(devId, dev) { function openEditDeviceModal(devId, dev) {
try {
window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null;
} catch (e) {
window.__editDeviceLoadedSnapshot = dev || null;
}
const modal = document.getElementById('edit-device-modal'); const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id'); const storageLabel = document.getElementById('edit-device-storage-id');
@@ -325,20 +389,83 @@ function openEditDeviceModal(devId, dev) {
applyTransportVisibility(tr); applyTransportVisibility(tr);
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : ''); setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : ''; if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
const wName = document.getElementById('edit-device-wifi-driver-name');
const wLeds = document.getElementById('edit-device-wifi-num-leds');
const wCo = document.getElementById('edit-device-wifi-color-order');
const wStart = document.getElementById('edit-device-wifi-startup-mode');
if (wName) {
const savedDisp =
dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name')
? dev.wifi_driver_display_name
: undefined;
if (savedDisp != null && String(savedDisp).trim() !== '') {
wName.value = String(savedDisp).trim();
} else {
wName.value = dev && dev.name ? String(dev.name) : '';
}
}
if (wLeds) {
wLeds.value =
dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== ''
? String(dev.wifi_driver_num_leds)
: '';
}
if (wCo) {
const co = (dev && dev.wifi_color_order) || 'rgb';
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
? String(co).toLowerCase()
: 'rgb';
}
if (wStart) {
const sm = (dev && dev.wifi_startup_mode) || 'default';
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
? String(sm).toLowerCase()
: 'default';
}
const obr = document.getElementById('edit-device-output-brightness');
const obv = document.getElementById('edit-device-output-brightness-value');
if (obr) {
let bv = 255;
if (dev && dev.output_brightness != null && dev.output_brightness !== '') {
const n = parseInt(String(dev.output_brightness), 10);
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
}
obr.value = String(bv);
if (obv) obv.textContent = String(bv);
}
refreshEditDeviceDebug();
modal.classList.add('active'); modal.classList.add('active');
} }
async function updateDevice(devId, name, type, transport, address) { async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
try { try {
const payload = {
name,
type: type || 'led',
transport: transport || 'espnow',
address,
};
if (typeof outputBrightness === 'number') {
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
}
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
if (wifiDriverFields.wifi_driver_display_name != null) {
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
}
if (wifiDriverFields.wifi_driver_num_leds != null) {
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
}
if (wifiDriverFields.wifi_color_order != null) {
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
}
if (wifiDriverFields.wifi_startup_mode != null) {
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
}
}
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
name,
type: type || 'led',
transport: transport || 'espnow',
address,
}),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (res.ok) { if (res.ok) {
@@ -354,6 +481,41 @@ async function updateDevice(devId, name, type, transport, address) {
} }
} }
async function pushWifiDriverConfig(devId, fields) {
const push = {};
if (fields.name != null && String(fields.name).trim()) push.name = String(fields.name).trim();
if (fields.num_leds != null && fields.num_leds !== '') {
const n = parseInt(String(fields.num_leds), 10);
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
}
if (fields.color_order != null && String(fields.color_order).trim()) {
push.color_order = String(fields.color_order).trim().toLowerCase();
}
if (fields.startup_mode != null && String(fields.startup_mode).trim()) {
const sm = String(fields.startup_mode).trim().toLowerCase();
if (sm === 'default' || sm === 'last' || sm === 'off') push.startup_mode = sm;
}
if (Object.keys(push).length === 0) return { ok: true, skipped: true };
try {
const res = await fetch(`/devices/${encodeURIComponent(devId)}/driver-config`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(push),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Could not send settings to the driver (is it connected?)');
return { ok: false };
}
return { ok: true };
} catch (e) {
console.error('pushWifiDriverConfig:', e);
alert('Could not send settings to the driver');
return { ok: false };
}
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('deviceTcpStatus', (ev) => { window.addEventListener('deviceTcpStatus', (ev) => {
const { ip, connected } = ev.detail || {}; const { ip, connected } = ev.detail || {};
@@ -380,10 +542,19 @@ document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devOutBr = document.getElementById('edit-device-output-brightness');
const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
if (devOutBr && devOutBrVal) {
devOutBr.addEventListener('input', () => {
devOutBrVal.textContent = devOutBr.value;
});
}
const transportEdit = document.getElementById('edit-device-transport'); const transportEdit = document.getElementById('edit-device-transport');
if (transportEdit) { if (transportEdit) {
transportEdit.addEventListener('change', () => { transportEdit.addEventListener('change', () => {
applyTransportVisibility(transportEdit.value); applyTransportVisibility(transportEdit.value);
refreshEditDeviceDebug();
}); });
} }
@@ -420,24 +591,67 @@ document.addEventListener('DOMContentLoaded', () => {
} }
if (editForm) { if (editForm) {
editForm.addEventListener('input', () => refreshEditDeviceDebug());
editForm.addEventListener('change', () => refreshEditDeviceDebug());
editForm.addEventListener('submit', async (e) => { editForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const idInput = document.getElementById('edit-device-id'); const { devId, payload } = collectDeviceEditPayload();
const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport');
const devId = idInput && idInput.value;
if (!devId) return; if (!devId) return;
const transport = (transportSel && transportSel.value) || 'espnow'; const transport = payload.transport || 'espnow';
const address = getAddressForPayload(transport); let wifiDriverFields = null;
if (transport === 'wifi') {
wifiDriverFields = {};
if (payload.wifi_driver_display_name != null) {
wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name;
}
if (payload.wifi_driver_num_leds != null) {
wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds;
}
if (payload.wifi_color_order != null) {
wifiDriverFields.wifi_color_order = payload.wifi_color_order;
}
if (payload.wifi_startup_mode != null) {
wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode;
}
}
const ok = await updateDevice( const ok = await updateDevice(
devId, devId,
nameInput ? nameInput.value.trim() : '', payload.name,
(typeSel && typeSel.value) || 'led', payload.type,
transport, transport,
address payload.address,
wifiDriverFields,
payload.output_brightness,
); );
if (ok) editDeviceModal.classList.remove('active'); if (!ok) return;
try {
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (!brRes.ok && brRes.status !== 503) {
const brData = await brRes.json().catch(() => ({}));
console.warn('brightness push:', brData.error || brRes.status);
}
} catch (e) {
console.warn('brightness push failed', e);
}
if (transport === 'wifi' && wifiDriverFields) {
const dn = document.getElementById('edit-device-wifi-driver-name');
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
const pushRes = await pushWifiDriverConfig(devId, {
name: dn ? dn.value : '',
num_leds: nl ? nl.value : '',
color_order: co ? co.value : '',
startup_mode: ws ? ws.value : '',
});
if (!pushRes.ok) return;
}
editDeviceModal.classList.remove('active');
}); });
} }
if (editCloseBtn) { if (editCloseBtn) {

452
src/static/groups.js Normal file
View File

@@ -0,0 +1,452 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', { headers: { Accept: 'application/json' } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchGroupsMap:', e);
return {};
}
}
async function fetchDevicesMapForGroups() {
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchDevicesMapForGroups:', e);
return {};
}
}
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
if (!containerEl) return;
containerEl.innerHTML = '';
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
macRows.forEach((row, idx) => {
const div = document.createElement('div');
div.className = 'zone-device-row profiles-row';
const label = document.createElement('span');
label.className = 'zone-device-row-label';
const strong = document.createElement('strong');
strong.textContent = row.label || row.mac || '—';
label.appendChild(strong);
label.appendChild(document.createTextNode(' '));
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.textContent = row.mac || '';
label.appendChild(sub);
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'btn btn-danger btn-small';
rm.textContent = 'Remove';
rm.addEventListener('click', () => {
macRows.splice(idx, 1);
renderGroupDevicesEditor(containerEl, macRows, devicesMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
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);
containerEl.appendChild(addWrap);
refreshEditGroupDebug();
}
function collectGroupEditPayload() {
const idInput = document.getElementById('edit-group-id');
const nameInput = document.getElementById('edit-group-name');
const gid = idInput && idInput.value;
const rows = window.__editGroupDeviceRows || [];
const devices = rows.map((r) => r.mac).filter(Boolean);
const payload = {
name: nameInput ? nameInput.value.trim() : '',
devices,
};
const dn = document.getElementById('edit-group-wifi-driver-name');
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;
const gob = document.getElementById('edit-group-output-brightness');
if (gob && gob.value !== '') {
const nb = parseInt(gob.value, 10);
if (!Number.isNaN(nb)) payload.output_brightness = Math.max(0, Math.min(255, nb));
}
return { gid, payload };
}
function refreshEditGroupDebug() {
const ta = document.getElementById('edit-group-debug');
if (!ta) return;
try {
const { gid, payload } = collectGroupEditPayload();
const loaded = window.__editGroupLoadedSnapshot;
ta.value = JSON.stringify(
{
group_id: gid || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
function loadWifiFieldsFromGroup(g) {
const wName = document.getElementById('edit-group-wifi-driver-name');
const wLeds = document.getElementById('edit-group-wifi-num-leds');
const wCo = document.getElementById('edit-group-wifi-color-order');
const wStart = document.getElementById('edit-group-wifi-startup-mode');
if (wName) {
const v = g && Object.prototype.hasOwnProperty.call(g, 'wifi_driver_display_name')
? g.wifi_driver_display_name
: null;
wName.value = v != null && String(v).trim() !== '' ? String(v).trim() : '';
}
if (wLeds) {
const v = g && g.wifi_driver_num_leds;
wLeds.value =
v != null && v !== '' && String(v).trim() !== ''
? String(v)
: '';
}
if (wCo) {
const co = (g && g.wifi_color_order) || 'rgb';
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
? String(co).toLowerCase()
: 'rgb';
}
if (wStart) {
const sm = (g && g.wifi_startup_mode) || 'default';
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
? String(sm).toLowerCase()
: 'default';
}
const gob = document.getElementById('edit-group-output-brightness');
const gobv = document.getElementById('edit-group-output-brightness-value');
if (gob) {
let bv = 255;
if (g && g.output_brightness != null && g.output_brightness !== '') {
const n = parseInt(String(g.output_brightness), 10);
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
}
gob.value = String(bv);
if (gobv) gobv.textContent = String(bv);
}
}
async function openEditGroupModal(groupId, groupDoc) {
const modal = document.getElementById('edit-group-modal');
const idInput = document.getElementById('edit-group-id');
const nameInput = document.getElementById('edit-group-name');
const editor = document.getElementById('edit-group-devices-editor');
let g = groupDoc;
if (!g || typeof g !== 'object') {
try {
const response = await fetch(`/groups/${encodeURIComponent(groupId)}`);
if (response.ok) g = await response.json();
} catch (e) {
console.error(e);
}
}
g = g || {};
try {
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
} catch (e) {
window.__editGroupLoadedSnapshot = g;
}
if (idInput) idInput.value = groupId;
if (nameInput) nameInput.value = g.name || '';
const dm = await fetchDevicesMapForGroups();
const macs = Array.isArray(g.devices) ? g.devices : [];
window.__editGroupDeviceRows = macs.map((m) => {
const mac = String(m).trim().toLowerCase().replace(/:/g, '').replace(/-/g, '');
const d = dm[mac];
return {
mac,
label: d && d.name ? String(d.name).trim() : mac,
};
});
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
async function loadGroupsModal() {
const container = document.getElementById('groups-list-modal');
if (!container) return;
container.innerHTML = '<span class="muted-text">Loading...</span>';
try {
const data = await fetchGroupsMap();
renderGroupsList(data || {});
} catch (e) {
console.error('loadGroupsModal:', e);
container.innerHTML = '<span class="muted-text">Failed to load groups.</span>';
}
}
function renderGroupsList(groups) {
const container = document.getElementById('groups-list-modal');
if (!container) return;
container.innerHTML = '';
const ids = Object.keys(groups).filter((k) => groups[k] && typeof groups[k] === 'object');
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No groups yet. Create one to assign devices and WiFi defaults.';
container.appendChild(p);
return;
}
ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
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';
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
label.style.flex = '1';
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditGroupModal(gid, g));
const brightBtn = document.createElement('button');
brightBtn.className = 'btn btn-secondary btn-small';
brightBtn.type = 'button';
brightBtn.textContent = 'Apply brightness';
brightBtn.title = 'Push group output brightness to WiFi drivers in this group';
brightBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply brightness failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent brightness to ${n} driver(s).`
: 'No WiFi drivers received brightness (check connections).',
);
} catch (err) {
console.error(err);
alert('Apply brightness failed');
}
});
const applyBtn = document.createElement('button');
applyBtn.className = 'btn btn-primary btn-small';
applyBtn.type = 'button';
applyBtn.textContent = 'Apply defaults to drivers';
applyBtn.title = 'Push WiFi defaults to each connected driver in this group';
applyBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent defaults to ${n} driver(s).`
: 'No WiFi drivers received the config (check defaults and connections).',
);
} catch (err) {
console.error(err);
alert('Apply failed');
}
});
const delBtn = document.createElement('button');
delBtn.className = 'btn btn-danger btn-small';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
if (!confirm(`Delete group "${g.name || gid}"? Zones referencing it may need updating.`)) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, { method: 'DELETE' });
if (res.ok) await loadGroupsModal();
else {
const data = await res.json().catch(() => ({}));
alert(data.error || 'Delete failed');
}
} catch (err) {
console.error(err);
alert('Delete failed');
}
});
row.appendChild(label);
row.appendChild(editBtn);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
row.appendChild(delBtn);
container.appendChild(row);
});
}
document.addEventListener('DOMContentLoaded', () => {
const groupsBtn = document.getElementById('groups-btn');
const groupsModal = document.getElementById('groups-modal');
const groupsCloseBtn = document.getElementById('groups-close-btn');
const newNameInput = document.getElementById('new-group-name');
const createBtn = document.getElementById('create-group-btn');
const editForm = document.getElementById('edit-group-form');
const editCloseBtn = document.getElementById('edit-group-close-btn');
const editModal = document.getElementById('edit-group-modal');
if (groupsBtn && groupsModal) {
groupsBtn.addEventListener('click', () => {
groupsModal.classList.add('active');
loadGroupsModal();
});
}
if (groupsCloseBtn && groupsModal) {
groupsCloseBtn.addEventListener('click', () => groupsModal.classList.remove('active'));
}
const grpOutBr = document.getElementById('edit-group-output-brightness');
const grpOutBrVal = document.getElementById('edit-group-output-brightness-value');
if (grpOutBr && grpOutBrVal) {
grpOutBr.addEventListener('input', () => {
grpOutBrVal.textContent = grpOutBr.value;
});
}
const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim();
if (!name) return;
try {
const res = await fetch('/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Create failed');
return;
}
if (newNameInput) newNameInput.value = '';
await loadGroupsModal();
} catch (e) {
console.error(e);
alert('Create failed');
}
};
if (createBtn) createBtn.addEventListener('click', createHandler);
if (newNameInput) {
newNameInput.addEventListener('keypress', (ev) => {
if (ev.key === 'Enter') createHandler();
});
}
if (editForm) {
editForm.addEventListener('input', () => refreshEditGroupDebug());
editForm.addEventListener('change', () => refreshEditGroupDebug());
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const { gid, payload } = collectGroupEditPayload();
if (!gid) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Save failed');
return;
}
try {
await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
} catch (_) {
/* ignore push errors after save */
}
if (editModal) editModal.classList.remove('active');
await loadGroupsModal();
} catch (err) {
console.error(err);
alert('Save failed');
}
});
}
if (editCloseBtn && editModal) {
editCloseBtn.addEventListener('click', () => editModal.classList.remove('active'));
}
});

View File

@@ -33,6 +33,33 @@ document.addEventListener('DOMContentLoaded', () => {
return Number.isFinite(t) ? t : def; return Number.isFinite(t) ? t : def;
}; };
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
return true;
}
const v =
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
if (typeof v === 'boolean') {
return v;
}
if (v === 0 || v === '0') {
return false;
}
if (v === 1 || v === '1') {
return true;
}
if (typeof v === 'string') {
const l = v.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(l)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(l)) {
return true;
}
}
return true;
};
const getCurrentProfileId = async () => { const getCurrentProfileId = async () => {
try { try {
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -531,6 +558,7 @@ document.addEventListener('DOMContentLoaded', () => {
const colors = Array.isArray(preset.colors) && preset.colors.length const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
: ['#FFFFFF']; : ['#FFFFFF'];
const presetAuto = coercePresetAuto(preset);
wirePresets[presetId] = { wirePresets[presetId] = {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
@@ -538,7 +566,8 @@ document.addEventListener('DOMContentLoaded', () => {
brightness: typeof preset.brightness === 'number' brightness: typeof preset.brightness === 'number'
? preset.brightness ? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127), : (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true, auto: presetAuto,
a: presetAuto,
n1: coercePresetInt(preset.n1), n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2), n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),

View File

@@ -190,6 +190,14 @@ document.addEventListener('DOMContentLoaded', () => {
const presetNewColorInput = document.getElementById('preset-new-color'); const presetNewColorInput = document.getElementById('preset-new-color');
const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayInput = document.getElementById('preset-delay-input');
const presetDelayField = presetDelayInput ? presetDelayInput.closest('.preset-editor-field') : null;
const presetBackgroundInput = document.getElementById('preset-background-input');
const presetBackgroundButton = document.getElementById('preset-background-btn');
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
const presetDefaultButton = document.getElementById('preset-default-btn'); const presetDefaultButton = document.getElementById('preset-default-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn');
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
@@ -219,6 +227,100 @@ document.addEventListener('DOMContentLoaded', () => {
return Infinity; // No limit if not specified return Infinity; // No limit if not specified
}; };
const resolvePatternConfig = (patternName) => {
const rawPatternName = String(patternName || '').trim();
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (
patternConfig &&
typeof patternConfig === 'object' &&
patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object'
) {
patternConfig = patternConfig.parameter_mappings;
}
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
};
/** From db/pattern.json; missing key means pattern allows manual / beat (backward compatible). */
const patternSupportsManual = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg) {
return true;
}
return cfg.supports_manual !== false;
};
const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) {
return;
}
const manualOn = presetManualModeInput && presetManualModeInput.checked;
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
const ok = !patternName || patternSupportsManual(patternName);
presetManualBeatNWrap.style.display = manualOn && ok ? '' : 'none';
};
const updatePresetBackgroundButton = () => {
if (!presetBackgroundButton || !presetBackgroundInput) return;
const color = coercePresetBackground({ background: presetBackgroundInput.value });
presetBackgroundInput.value = color;
presetBackgroundButton.textContent = color;
presetBackgroundButton.style.backgroundColor = color;
presetBackgroundButton.style.color = '#fff';
presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)';
};
const updateDelayVisibilityForManualMode = () => {
if (!presetDelayField) return;
const manualOn = presetManualModeInput && presetManualModeInput.checked;
presetDelayField.style.display = manualOn ? 'none' : '';
};
const updateManualModeAvailability = () => {
if (!presetManualModeInput) {
return;
}
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
const ok = !patternName || patternSupportsManual(patternName);
presetManualModeInput.disabled = !ok;
if (presetManualModeLabel) {
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
}
if (presetManualModeHint) {
if (!patternName || ok) {
presetManualModeHint.style.display = 'none';
presetManualModeHint.textContent = '';
} else {
presetManualModeHint.style.display = '';
presetManualModeHint.textContent =
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
}
}
if (!ok) {
presetManualModeInput.checked = false;
}
updateManualBeatNVisibility();
updateDelayVisibilityForManualMode();
};
// Function to show/hide color section based on max_colors // Function to show/hide color section based on max_colors
const updateColorSectionVisibility = () => { const updateColorSectionVisibility = () => {
const maxColors = getMaxColors(); const maxColors = getMaxColors();
@@ -296,7 +398,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const swatchContainer = document.createElement('div'); const swatchContainer = document.createElement('div');
swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
swatchContainer.classList.add('color-swatches-container'); swatchContainer.classList.add('color-swatches-container');
currentPresetColors.forEach((color, index) => { currentPresetColors.forEach((color, index) => {
@@ -468,7 +570,7 @@ document.addEventListener('DOMContentLoaded', () => {
presetColorsContainer.appendChild(swatchContainer); presetColorsContainer.appendChild(swatchContainer);
}; };
// Function to get drag after element for colors (horizontal layout) // Function to get drag after element for colors (horizontal layout)
const getDragAfterElementForColors = (container, x) => { const getDragAfterElementForColors = (container, x) => {
const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')]; const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')];
@@ -492,12 +594,27 @@ document.addEventListener('DOMContentLoaded', () => {
presetNameInput.value = preset.name || ''; presetNameInput.value = preset.name || '';
const patternName = preset.pattern || ''; const patternName = preset.pattern || '';
presetPatternInput.value = patternName; presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : []; const colors = Array.isArray(preset.colors) ? preset.colors.slice() : [];
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : []; const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs.slice() : [];
renderPresetColors(colors, paletteRefs); renderPresetColors(colors, paletteRefs);
presetBrightnessInput.value = preset.brightness || 0; presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0; presetDelayInput.value = preset.delay || 0;
if (presetBackgroundInput) {
presetBackgroundInput.value = coercePresetBackground(preset);
}
updatePresetBackgroundButton();
if (presetManualModeInput) {
const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true;
presetManualModeInput.checked = !autoVal;
}
if (presetManualBeatNInput) {
const raw = preset.manual_beat_n;
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
if (!Number.isFinite(n)) n = 1;
n = Math.max(1, Math.min(64, n));
presetManualBeatNInput.value = String(n);
}
// Update color section visibility based on pattern // Update color section visibility based on pattern
updateColorSectionVisibility(); updateColorSectionVisibility();
@@ -552,6 +669,7 @@ document.addEventListener('DOMContentLoaded', () => {
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs // After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName); updatePresetNLabels(patternName);
updateManualModeAvailability();
updatePresetEditorTabActionsVisibility(); updatePresetEditorTabActionsVisibility();
}; };
@@ -574,7 +692,21 @@ document.addEventListener('DOMContentLoaded', () => {
n6: 0, n6: 0,
n7: 0, n7: 0,
n8: 0, n8: 0,
background: '#000000',
auto: true,
manual_beat_n: 1,
}); });
if (presetManualModeInput) {
presetManualModeInput.checked = false;
}
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
if (presetBackgroundInput) {
presetBackgroundInput.value = '#000000';
}
updatePresetBackgroundButton();
updateManualModeAvailability();
// Re-enable name and pattern when clearing (for new preset) // Re-enable name and pattern when clearing (for new preset)
if (presetNameInput) { if (presetNameInput) {
presetNameInput.disabled = false; presetNameInput.disabled = false;
@@ -652,6 +784,14 @@ document.addEventListener('DOMContentLoaded', () => {
// Use canonical field names expected by the device / API // Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
background: presetBackgroundInput ? presetBackgroundInput.value : '#000000',
auto: presetManualModeInput ? !presetManualModeInput.checked : true,
manual_beat_n: (() => {
if (!presetManualBeatNInput) return 1;
let n = parseInt(presetManualBeatNInput.value, 10);
if (!Number.isFinite(n)) n = 1;
return Math.max(1, Math.min(64, n));
})(),
}; };
// Always store numeric parameters as n1..n8. // Always store numeric parameters as n1..n8.
@@ -812,6 +952,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (nGrid) { if (nGrid) {
nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none'; nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none';
} }
updateManualModeAvailability();
}; };
const renderPresets = (presets) => { const renderPresets = (presets) => {
@@ -1185,6 +1326,21 @@ document.addEventListener('DOMContentLoaded', () => {
updateColorSectionVisibility(); updateColorSectionVisibility();
// Re-render colors to show updated max colors limit // Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors, currentPresetPaletteRefs); renderPresetColors(currentPresetColors, currentPresetPaletteRefs);
updateManualModeAvailability();
});
}
if (presetManualModeInput) {
presetManualModeInput.addEventListener('change', () => {
updateManualBeatNVisibility();
updateDelayVisibilityForManualMode();
});
}
if (presetBackgroundButton && presetBackgroundInput) {
presetBackgroundButton.addEventListener('click', () => {
presetBackgroundInput.click();
});
presetBackgroundInput.addEventListener('input', () => {
updatePresetBackgroundButton();
}); });
} }
// Color picker auto-add handler // Color picker auto-add handler
@@ -1417,6 +1573,65 @@ const coercePresetInt = (v, def = 0) => {
return Number.isFinite(t) ? t : def; return Number.isFinite(t) ? t : def;
}; };
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
return true;
}
const v =
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
if (typeof v === 'boolean') {
return v;
}
if (v === 0 || v === '0') {
return false;
}
if (v === 1 || v === '1') {
return true;
}
if (typeof v === 'string') {
const l = v.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(l)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(l)) {
return true;
}
}
return true;
};
/** Preset background colour; accepts #RRGGBB or [r,g,b]. */
const coercePresetBackground = (preset) => {
if (!preset || typeof preset !== 'object') {
return '#000000';
}
const raw = preset.background !== undefined && preset.background !== null ? preset.background : preset.bg;
if (typeof raw === 'string') {
const s = raw.trim();
if (/^#[0-9a-fA-F]{6}$/.test(s)) {
return s.toUpperCase();
}
}
if (Array.isArray(raw) && raw.length === 3) {
const r = coercePresetInt(raw[0], 0);
const g = coercePresetInt(raw[1], 0);
const b = coercePresetInt(raw[2], 0);
const clamp = (n) => Math.max(0, Math.min(255, n));
return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`.toUpperCase();
}
return '#000000';
};
/** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */
const coerceManualBeatN = (preset) => {
if (!preset || typeof preset !== 'object') return 1;
const raw = preset.manual_beat_n;
let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10);
if (!Number.isFinite(n)) n = 1;
return Math.max(1, Math.min(64, n));
};
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP). // Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
// Send order: // Send order:
// 1) preset payload (optionally with save) // 1) preset payload (optionally with save)
@@ -1438,23 +1653,28 @@ const sendPresetViaEspNow = async (
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetAuto = coercePresetAuto(preset);
const presetBackground = coercePresetBackground(preset);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
[wirePresetId]: { [wirePresetId]: {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
bg: presetBackground,
delay: typeof preset.delay === 'number' ? preset.delay : 100, delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number' brightness: typeof preset.brightness === 'number'
? preset.brightness ? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127), : (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true, auto: presetAuto,
a: presetAuto,
n1: coercePresetInt(preset.n1), n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2), n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: coercePresetInt(preset.n6),
manual_beat_n: coerceManualBeatN(preset),
}, },
}, },
}; };
@@ -1474,7 +1694,8 @@ const sendPresetViaEspNow = async (
: []; : [];
const sequence = [presetMessage]; const sequence = [presetMessage];
if (names.length > 0) { // Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
if (names.length > 0 && presetAuto) {
const select = {}; const select = {};
names.forEach((name) => { names.forEach((name) => {
if (name) { if (name) {
@@ -1520,6 +1741,29 @@ const sendDefaultPreset = async (presetId, deviceNames) => {
} }
}; };
const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
if (!presetId) {
return;
}
const nameTargets = Array.isArray(deviceNames)
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
: [];
if (!nameTargets.length) {
return;
}
const select = {};
nameTargets.forEach((name) => {
select[name] = [String(presetId)];
});
const macTargets =
nameTargets.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
: [];
await postDriverSequence([{ v: '1', select }], macTargets);
};
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket. // Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
try { try {
window.sendPresetViaEspNow = sendPresetViaEspNow; window.sendPresetViaEspNow = sendPresetViaEspNow;
@@ -1534,6 +1778,8 @@ try {
// Store selected preset per zone // Store selected preset per zone
const selectedPresets = {}; 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) // Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode)
let presetUiMode = 'run'; let presetUiMode = 'run';
@@ -1823,6 +2069,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.className = 'pattern-button preset-tile-main'; button.className = 'pattern-button preset-tile-main';
if (isSelected) { if (isSelected) {
button.classList.add('active'); button.classList.add('active');
selectedPresetPayloads[zoneId] = preset;
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
@@ -1846,6 +2093,49 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
presetNameLabel.className = 'pattern-button-label'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel); button.appendChild(presetNameLabel);
const bgSwatch = document.createElement('span');
const bgColor = coercePresetBackground(preset);
bgSwatch.title = `Background: ${bgColor}`;
bgSwatch.style.cssText = `
position: absolute;
left: 4px;
bottom: 4px;
width: 12px;
height: 12px;
border-radius: 2px;
background: ${bgColor};
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 2;
`;
button.appendChild(bgSwatch);
const isManualPreset = preset && typeof preset.auto === 'boolean' ? !preset.auto : false;
if (isManualPreset) {
const manualBadge = document.createElement('span');
manualBadge.textContent = '1';
manualBadge.title = 'Manual preset';
manualBadge.style.cssText = `
position: absolute;
right: 4px;
bottom: 4px;
min-width: 16px;
height: 16px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 700;
line-height: 14px;
text-align: center;
pointer-events: none;
z-index: 2;
`;
button.appendChild(manualBadge);
}
button.addEventListener('click', () => { button.addEventListener('click', () => {
if (isDraggingPreset) return; if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-zone'); const presetsListEl = document.getElementById('presets-list-zone');
@@ -1854,6 +2144,7 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
} }
button.classList.add('active'); button.classList.add('active');
selectedPresets[zoneId] = presetId; selectedPresets[zoneId] = presetId;
selectedPresetPayloads[zoneId] = preset;
const section = row.closest('.presets-section'); const section = row.closest('.presets-section');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => { sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {

View File

@@ -94,10 +94,11 @@ header {
background-color: #1a1a1a; background-color: #1a1a1a;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; align-items: stretch;
justify-content: flex-start;
border-bottom: 2px solid #4a4a4a; border-bottom: 2px solid #4a4a4a;
gap: 0.75rem; gap: 0.65rem;
} }
header h1 { header h1 {
@@ -105,6 +106,18 @@ header h1 {
font-weight: 600; font-weight: 600;
} }
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
.header-end {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: 0;
width: 100%;
min-width: 0;
}
.header-actions { .header-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -115,6 +128,7 @@ header h1 {
.header-menu-mobile { .header-menu-mobile {
display: none; display: none;
position: relative; position: relative;
align-items: center;
} }
.main-menu-dropdown { .main-menu-dropdown {
@@ -183,6 +197,53 @@ header h1 {
width: 8.5rem; width: 8.5rem;
} }
.audio-top-indicator {
display: none;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
min-width: 6.5rem;
}
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
letter-spacing: 0.05em;
}
.audio-top-indicator-value {
font-size: 1rem;
font-weight: 700;
color: #ffd54f;
min-width: 2.4rem;
text-align: right;
}
.audio-top-indicator-subvalue {
font-size: 0.75rem;
color: #9e9e9e;
min-width: 2.2rem;
text-align: right;
}
.audio-top-indicator.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue {
color: #fff;
}
/* Header/menu actions that should only appear in Edit mode */ /* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only { body.preset-ui-run .edit-mode-only {
display: none !important; display: none !important;
@@ -239,8 +300,9 @@ body.preset-ui-run .edit-mode-only {
.zones-container { .zones-container {
background-color: transparent; background-color: transparent;
padding: 0.5rem 0; padding: 0.35rem 0 0;
flex: 1; flex: 0 0 auto;
width: 100%;
min-width: 0; min-width: 0;
align-self: stretch; align-self: stretch;
display: flex; display: flex;
@@ -550,14 +612,14 @@ body.preset-ui-run .edit-mode-only {
padding: 0; padding: 0;
} }
/* Zone preset selecting area: 3 columns, vertical scroll only */ /* Zone preset selecting area: 8 columns on desktop, vertical scroll only */
#presets-list-zone { #presets-list-zone {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
@@ -710,6 +772,46 @@ body.preset-ui-run .edit-mode-only {
display: block; display: block;
} }
.audio-bpm-readout {
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.05em;
color: #ffd54f;
text-align: center;
padding: 0.4rem;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 6px;
}
.audio-hit-type-readout {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.04em;
color: #81d4fa;
text-transform: lowercase;
text-align: center;
padding: 0.35rem;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 6px;
}
.audio-beat-flash {
width: 100%;
height: 56px;
border-radius: 6px;
border: 1px solid #4a4a4a;
background: #202020;
box-shadow: inset 0 0 0 0 rgba(255, 82, 82, 0.5);
transition: background-color 80ms linear, box-shadow 120ms linear;
}
.audio-beat-flash.active {
background: #ff5252;
box-shadow: inset 0 0 24px 6px rgba(255, 255, 255, 0.35);
}
.patterns-list { .patterns-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -992,20 +1094,38 @@ body.preset-ui-run .edit-mode-only {
/* Mobile-friendly layout */ /* Mobile-friendly layout */
@media (max-width: 1000px) { @media (max-width: 1000px) {
header { header {
flex-direction: row; flex-direction: column;
align-items: center; align-items: stretch;
gap: 0.25rem; gap: 0.5rem;
} header h1 { }
header h1 {
font-size: 1.1rem; 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 Tabs) are in the Menu dropdown */
.header-actions { .header-actions {
display: none; display: none;
} }
.header-menu-mobile { .header-menu-mobile {
display: block; display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0; margin-top: 0;
margin-left: auto; margin-left: 0;
}
.header-end {
gap: 0.35rem;
flex-shrink: 0;
}
.header-end .audio-top-indicator {
min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0;
} }
.btn { .btn {
@@ -1014,8 +1134,9 @@ body.preset-ui-run .edit-mode-only {
} }
.zones-container { .zones-container {
padding: 0.5rem 0; padding: 0.35rem 0 0;
border-bottom: none; border-bottom: none;
width: 100%;
} }
.zone-content { .zone-content {
@@ -1261,8 +1382,8 @@ body.preset-ui-run .edit-mode-only {
.color-swatches-container { .color-swatches-container {
min-height: 80px; min-height: 80px;
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns on phone-sized screens */
@media (max-width: 1000px) { @media (max-width: 600px) {
#presets-list-zone { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);

View File

@@ -2,32 +2,12 @@
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null; let brightnessSendTimeout = null;
const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness";
function clamp255(n) { function clamp255(n) {
const v = parseInt(n, 10); const v = parseInt(n, 10);
if (Number.isNaN(v)) return null; if (Number.isNaN(v)) return null;
return Math.max(0, Math.min(255, v)); return Math.max(0, Math.min(255, v));
} }
function loadSavedUiBrightness() {
try {
const raw = localStorage.getItem(UI_BRIGHTNESS_STORAGE_KEY);
if (raw == null) return null;
return clamp255(raw);
} catch (_) {
return null;
}
}
function persistUiBrightness(value) {
const v = clamp255(value);
if (v === null) return;
try {
localStorage.setItem(UI_BRIGHTNESS_STORAGE_KEY, String(v));
} catch (_) {}
}
function applyBrightnessSliders(val) { function applyBrightnessSliders(val) {
const v = clamp255(val); const v = clamp255(val);
if (v === null) return; if (v === null) return;
@@ -37,9 +17,27 @@ function applyBrightnessSliders(val) {
if (menuSlider) menuSlider.value = String(v); if (menuSlider) menuSlider.value = String(v);
} }
function sendZoneBrightness(value) { async function saveZoneBrightnessToServer(zoneId, val) {
if (!zoneId) return;
try {
const res = await fetch(`/zones/${zoneId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify({ brightness: val }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.warn("zone brightness save failed:", err.error || res.status);
}
} catch (e) {
console.warn("zone brightness save failed:", e);
}
}
function sendZoneBrightness(zoneId, value) {
if (!zoneId) return;
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0)); const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
persistUiBrightness(val);
const headerSlider = document.getElementById('header-brightness-slider'); const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider');
if (headerSlider && String(headerSlider.value) !== String(val)) { if (headerSlider && String(headerSlider.value) !== String(val)) {
@@ -54,6 +52,7 @@ function sendZoneBrightness(value) {
brightnessSendTimeout = setTimeout(() => { brightnessSendTimeout = setTimeout(() => {
(async () => { (async () => {
try { try {
await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function' const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section) ? window.parseTabDeviceNames(section)
@@ -65,6 +64,47 @@ function sendZoneBrightness(value) {
? await window.tabsManager.resolveTabDeviceMacs(names) ? await window.tabsManager.resolveTabDeviceMacs(names)
: []; : [];
if (typeof window.postDriverSequence === 'function') { if (typeof window.postDriverSequence === 'function') {
if (targetMacs.length > 0) {
let resolved = {};
try {
const rr = await fetch('/devices/resolve-brightness', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
macs: targetMacs,
zone_brightness: val,
}),
});
if (rr.ok) {
const pack = await rr.json().catch(() => ({}));
if (pack && pack.values && typeof pack.values === 'object') {
resolved = pack.values;
}
}
} catch (re) {
console.warn('resolve-brightness failed:', re);
}
for (const mac of targetMacs) {
const k = String(mac).toLowerCase();
const b =
resolved[k] != null && resolved[k] !== ''
? parseInt(resolved[k], 10)
: val;
const bv = Number.isNaN(b)
? val
: Math.max(0, Math.min(255, b));
await window.postDriverSequence(
[{ v: '1', b: bv, save: true }],
[mac],
0,
);
}
return;
}
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0); await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
return; return;
} }
@@ -108,8 +148,81 @@ async function fetchDevicesMap() {
} }
} }
async function fetchGroupsMap() {
try {
const response = await fetch("/groups", { headers: { Accept: "application/json" } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === "object" ? data : {};
} catch (e) {
console.error("fetchGroupsMap:", e);
return {};
}
}
/**
* Resolve registry names + MACs for a zone document (``group_ids`` expands groups;
* otherwise legacy ``names``).
*/
async function computeZoneTargets(zone) {
const dm = await fetchDevicesMap();
const gids = Array.isArray(zone && zone.group_ids)
? zone.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (gids.length > 0) {
const gm = await fetchGroupsMap();
const seen = new Set();
const names = [];
const macs = [];
for (const gid of gids) {
const g = gm[gid];
if (!g || !Array.isArray(g.devices)) continue;
for (const raw of g.devices) {
const m = String(raw || "")
.trim()
.toLowerCase()
.replace(/:/g, "")
.replace(/-/g, "");
if (m.length !== 12) continue;
if (seen.has(m)) continue;
seen.add(m);
const d = dm[m];
const n = d && String((d.name || "").trim()) ? String(d.name).trim() : m;
names.push(n);
macs.push(m);
}
}
return { names, macs };
}
const zoneNames = Array.isArray(zone && zone.names) ? zone.names : [];
const rows = namesToRows(zoneNames, dm);
return {
names: rowsToNames(rows),
macs: [...new Set(rows.map((r) => r.mac).filter(Boolean))],
};
}
async function resolveZoneDeviceMacsFromZoneData(zone) {
const t = await computeZoneTargets(zone);
return t.macs;
}
/** Registry MACs for zone device names (order matches zone names; skips unknown names). */ /** Registry MACs for zone device names (order matches zone names; skips unknown names). */
async function resolveZoneDeviceMacs(zoneNames) { async function resolveZoneDeviceMacs(zoneNames) {
const section = document.querySelector(".presets-section[data-zone-id]");
if (section) {
const enc = section.getAttribute("data-zone-target-macs-json");
if (enc) {
try {
const macs = JSON.parse(decodeURIComponent(enc));
if (Array.isArray(macs) && macs.length) {
return [...new Set(macs.map((m) => String(m).toLowerCase()))];
}
} catch (e) {
/* fall through */
}
}
}
const dm = await fetchDevicesMap(); const dm = await fetchDevicesMap();
const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm); const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm);
const macs = rows.map((r) => r.mac).filter(Boolean); const macs = rows.map((r) => r.mac).filter(Boolean);
@@ -198,15 +311,72 @@ function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
containerEl.appendChild(addWrap); containerEl.appendChild(addWrap);
} }
/** Default device name list when creating a zone (refined in Edit zone). */ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
async function defaultDeviceNamesForNewTab() { if (!containerEl) return;
const dm = await fetchDevicesMap(); containerEl.innerHTML = "";
const macs = Object.keys(dm); const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
if (macs.length > 0) {
const m0 = macs[0]; rows.forEach((row, idx) => {
return [String((dm[m0].name || "").trim() || m0)]; const div = document.createElement("div");
} div.className = "zone-device-row profiles-row";
return ["1"]; const label = document.createElement("span");
label.className = "zone-device-row-label";
const strong = document.createElement("strong");
strong.textContent = row.name || row.id || "—";
label.appendChild(strong);
label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span");
sub.className = "muted-text";
sub.textContent = `group ${row.id}`;
label.appendChild(sub);
const rm = document.createElement("button");
rm.type = "button";
rm.className = "btn btn-danger btn-small";
rm.textContent = "Remove";
rm.addEventListener("click", () => {
rows.splice(idx, 1);
renderZoneGroupsEditor(containerEl, rows, groupsMap);
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
});
const idsInRows = new Set(rows.map((r) => String(r.id)));
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 group…", ""));
entries.forEach(([gid, g]) => {
if (idsInRows.has(gid)) return;
const gn = g && g.name ? String(g.name).trim() : "";
const optLabel = gn ? `${gn} (${gid})` : `Group ${gid}`;
sel.appendChild(new Option(optLabel, gid));
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add";
addBtn.addEventListener("click", () => {
const gid = sel.value;
if (!gid || !groupsMap[gid]) return;
const gn = groupsMap[gid].name ? String(groupsMap[gid].name).trim() : gid;
rows.push({ id: gid, name: gn });
sel.value = "";
renderZoneGroupsEditor(containerEl, rows, groupsMap);
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
}
/** Default group for a new zone (empty if no groups exist yet). */
async function defaultGroupIdsForNewTab() {
const gm = await fetchGroupsMap();
const ids = Object.keys(gm || {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
return ids.length ? [ids[0]] : [];
} }
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */ /** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
@@ -540,12 +710,16 @@ async function loadZoneContent(zoneId) {
// Render zone content (presets section) // Render zone content (presets section)
const tabName = zone.name || `Zone ${zoneId}`; const tabName = zone.name || `Zone ${zoneId}`;
const names = Array.isArray(zone.names) ? zone.names : []; const targets = await computeZoneTargets(zone);
const namesJsonAttr = encodeURIComponent(JSON.stringify(names)); const namesJsonAttr = encodeURIComponent(JSON.stringify(targets.names));
const legacyOk = names.length > 0 && !names.some((n) => /[",]/.test(String(n))); const macsJsonAttr = encodeURIComponent(JSON.stringify(targets.macs));
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : ""; const legacyOk =
targets.names.length > 0 && !targets.names.some((n) => /[",]/.test(String(n)));
const legacyAttr = legacyOk
? ` data-device-names="${escapeHtmlAttr(targets.names.join(","))}"`
: "";
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}> <div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}" data-zone-target-macs-json="${macsJsonAttr}"${legacyAttr}>
<div id="presets-list-zone" class="presets-list"> <div id="presets-list-zone" class="presets-list">
<!-- Presets will be loaded here by presets.js --> <!-- Presets will be loaded here by presets.js -->
</div> </div>
@@ -553,11 +727,16 @@ async function loadZoneContent(zoneId) {
`; `;
// Keep header and menu brightness controls in sync. // Keep header and menu brightness controls in sync.
const brightnessSlider = document.getElementById('header-brightness-slider'); const zoneBrightness =
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); typeof zone.brightness === 'number'
if (menuBrightnessSlider && brightnessSlider) { ? zone.brightness
menuBrightnessSlider.value = brightnessSlider.value; : parseInt(String(zone.brightness ?? ''), 10);
} const normalizedBrightness = Number.isFinite(zoneBrightness)
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255;
applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones.
sendZoneBrightness(zoneId, normalizedBrightness);
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -635,8 +814,7 @@ async function sendProfilePresets() {
continue; continue;
} }
zonesWithPresets += 1; zonesWithPresets += 1;
const zoneNames = Array.isArray(tabData.names) ? tabData.names : []; const targets = await resolveZoneDeviceMacsFromZoneData(tabData);
const targets = await resolveZoneDeviceMacs(zoneNames);
const payload = { preset_ids: presetIds }; const payload = { preset_ids: presetIds };
if (tabData.default_preset) { if (tabData.default_preset) {
payload.default = tabData.default_preset; payload.default = tabData.default_preset;
@@ -827,31 +1005,25 @@ async function openEditZoneModal(zoneId, zone) {
if (idInput) idInput.value = zoneId; if (idInput) idInput.value = zoneId;
if (nameInput) nameInput.value = tabData.name || ""; if (nameInput) nameInput.value = tabData.name || "";
const devicesMap = await fetchDevicesMap(); const groupsMap = await fetchGroupsMap();
const zoneNames = const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"]; window.__editTabGroupRows = rawGids.map((gid) => {
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap); const id = String(gid);
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap); const g = groupsMap[id];
return { id, name: g && g.name ? String(g.name).trim() : id };
});
renderZoneGroupsEditor(editor, window.__editTabGroupRows, groupsMap);
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
} }
function normalizeTabNamesArg(namesOrString) {
if (Array.isArray(namesOrString)) {
return namesOrString.map((n) => String(n).trim()).filter((n) => n.length > 0);
}
if (typeof namesOrString === "string" && namesOrString.trim()) {
return namesOrString.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
}
return ["1"];
}
// Update an existing zone // Update an existing zone
async function updateZone(zoneId, name, namesOrString) { async function updateZone(zoneId, name, groupIds) {
try { try {
let names = normalizeTabNamesArg(namesOrString); const gids = Array.isArray(groupIds)
if (!names.length) names = ["1"]; ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -859,7 +1031,8 @@ async function updateZone(zoneId, name, namesOrString) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
names: names group_ids: gids,
names: [],
}) })
}); });
@@ -883,10 +1056,11 @@ async function updateZone(zoneId, name, namesOrString) {
} }
// Create a new zone // Create a new zone
async function createZone(name, namesOrString) { async function createZone(name, groupIds) {
try { try {
let names = normalizeTabNamesArg(namesOrString); const gids = Array.isArray(groupIds)
if (!names.length) names = ["1"]; ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
const response = await fetch('/zones', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -894,7 +1068,8 @@ async function createZone(name, namesOrString) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
names: names group_ids: gids,
names: [],
}) })
}); });
@@ -975,8 +1150,8 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim(); const name = newTabNameInput.value.trim();
if (name) { if (name) {
const deviceNames = await defaultDeviceNamesForNewTab(); const groupIds = await defaultGroupIdsForNewTab();
await createZone(name, deviceNames); await createZone(name, groupIds);
if (newTabNameInput) newTabNameInput.value = ""; if (newTabNameInput) newTabNameInput.value = "";
} }
}; };
@@ -1003,15 +1178,15 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneId = idInput ? idInput.value : null; const zoneId = idInput ? idInput.value : null;
const name = nameInput ? nameInput.value.trim() : ""; const name = nameInput ? nameInput.value.trim() : "";
const rows = window.__editTabDeviceRows || []; const rows = window.__editTabGroupRows || [];
const deviceNames = rowsToNames(rows); const groupIds = rows.map((r) => r.id).filter(Boolean);
if (zoneId && name) { if (zoneId && name) {
if (deviceNames.length === 0) { if (groupIds.length === 0) {
alert("Add at least one device."); alert("Add at least one device group.");
return; return;
} }
await updateZone(zoneId, name, deviceNames); await updateZone(zoneId, name, groupIds);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1027,22 +1202,20 @@ document.addEventListener('DOMContentLoaded', () => {
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider'); const headerBrightnessSlider = document.getElementById('header-brightness-slider');
const savedBr = loadSavedUiBrightness(); (async () => {
if (savedBr !== null) { if (menuBrightnessSlider) {
applyBrightnessSliders(savedBr); menuBrightnessSlider.addEventListener('input', (e) => {
} if (!currentZoneId) return;
if (menuBrightnessSlider) { sendZoneBrightness(currentZoneId, e.target.value);
menuBrightnessSlider.addEventListener('input', (e) => { });
sendZoneBrightness(e.target.value); }
}); if (headerBrightnessSlider) {
} headerBrightnessSlider.addEventListener('input', (e) => {
if (headerBrightnessSlider) { if (!currentZoneId) return;
headerBrightnessSlider.addEventListener('input', (e) => { sendZoneBrightness(currentZoneId, e.target.value);
sendZoneBrightness(e.target.value); });
}); }
// Apply saved (or default) level to devices once the page is ready. })();
sendZoneBrightness(headerBrightnessSlider.value);
}
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately. // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
@@ -1064,6 +1237,7 @@ window.zonesManager = {
updateZone, updateZone,
openEditZoneModal, openEditZoneModal,
resolveZoneDeviceMacs, resolveZoneDeviceMacs,
resolveZoneDeviceMacsFromZoneData,
resolveTabDeviceMacs: resolveZoneDeviceMacs, resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId, getCurrentZoneId: () => currentZoneId,
}; };

View File

@@ -14,23 +14,31 @@
Loading zones... Loading zones...
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-count" class="audio-top-indicator-subvalue">#0</span>
</div>
<div class="header-actions">
<div class="header-brightness-control"> <div class="header-brightness-control">
<label for="header-brightness-slider">Brightness</label> <label for="header-brightness-slider">Brightness</label>
<input type="range" id="header-brightness-slider" min="0" max="255" value="255"> <input type="range" id="header-brightness-slider" min="0" max="255" value="255">
</div> </div>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button> <button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button> <button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button> <button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button> <button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button> <button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button> <button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button> <button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</button>
<button class="btn btn-secondary" id="help-btn">Help</button> <button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button> <button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div> </div>
<div class="header-menu-mobile"> <div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
@@ -40,14 +48,17 @@
</div> </div>
<button type="button" data-target="profiles-btn">Profiles</button> <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="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">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button> <button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button> <button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button> <button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button> <button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button> <button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" data-target="help-btn">Help</button> <button type="button" data-target="help-btn">Help</button>
</div> </div>
</div>
</div> </div>
</header> </header>
@@ -87,7 +98,7 @@
</div> </div>
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<label class="zone-devices-label">Devices in this zone</label> <label class="zone-devices-label">Device groups in this zone</label>
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div> <div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
<label class="zone-presets-section-label">Presets on this zone</label> <label class="zone-presets-section-label">Presets on this zone</label>
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
@@ -129,6 +140,67 @@
</div> </div>
</div> </div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups) -->
<div id="groups-modal" class="modal">
<div class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to zones.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<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>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
<label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off">
<label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<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>
</form>
</div>
</div>
<div id="edit-device-modal" class="modal"> <div id="edit-device-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit device</h2> <h2>Edit device</h2>
@@ -154,6 +226,37 @@
<label for="edit-device-address-wifi">Address (IP or hostname)</label> <label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off"> <input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div> </div>
<div id="edit-device-wifi-driver-wrap" hidden>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">On-device settings (sent over WiFi when connected). For shared defaults across several drivers, use <strong>Groups</strong>.</p>
<label for="edit-device-wifi-driver-name">Display name</label>
<input type="text" id="edit-device-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-device-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-device-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-device-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-device-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-device-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-device-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
</div>
<label for="edit-device-output-brightness" style="margin-top:0.75rem;display:block;">Output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-device-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-device-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<small class="muted-text" style="display:block;margin-top:0.25rem;">Saved on the device; use <strong>Save</strong> to push to the driver (when connected).</small>
<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"> <div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button> <button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
@@ -202,6 +305,25 @@
<label for="preset-delay-input">Delay (ms)</label> <label for="preset-delay-input">Delay (ms)</label>
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0"> <input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
</div> </div>
<div class="preset-editor-field">
<label for="preset-background-input">Background</label>
<div class="profiles-actions" style="gap: 0.4rem;">
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
</div>
</div>
</div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label id="preset-manual-mode-label" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="preset-manual-mode-input">
Manual mode (single-shot where supported)
</label>
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
<label for="preset-manual-beat-n-input">Audio beat: every</label>
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div>
</div> </div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
@@ -360,19 +482,20 @@
<li><strong>Select zone</strong>: left-click a zone button in the top bar.</li> <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>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>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 or remove rows as needed.</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>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> <li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul> </ul>
<h3>Edit mode</h3> <h3>Edit mode</h3>
<ul> <ul>
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li> <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>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>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>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>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>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</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> <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> </ul>
@@ -389,6 +512,49 @@
</div> </div>
</div> </div>
<!-- Audio Modal -->
<div id="audio-modal" class="modal">
<div class="modal-content">
<h2>Audio Beat Detection</h2>
<p class="muted-text">Select an input device and start beat detection.</p>
<div class="form-group">
<label for="audio-device-select">Input device</label>
<div class="profiles-actions">
<select id="audio-device-select" style="flex: 1;">
<option value="">Default input</option>
</select>
<button type="button" class="btn btn-secondary" id="audio-refresh-btn">Refresh</button>
</div>
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
</div>
<div class="form-group">
<label for="audio-device-override">Manual device override (optional)</label>
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
</div>
<div class="form-group">
<label>Current BPM</label>
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
</div>
<div class="form-group">
<label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</div>
<div class="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="modal-actions">
<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-close-btn">Close</button>
</div>
<div class="form-group" style="margin-top: 0.75rem;">
<label for="audio-devices-debug">Detected devices (Python)</label>
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -534,6 +700,7 @@
</div> </div>
<!-- Styles moved to /static/style.css --> <!-- Styles moved to /static/style.css -->
<script src="/static/groups.js"></script>
<script src="/static/zones.js"></script> <script src="/static/zones.js"></script>
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script> <script src="/static/led_tool.js"></script>
@@ -543,5 +710,6 @@
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
</body> </body>
</html> </html>

282
src/util/audio_detector.py Normal file
View File

@@ -0,0 +1,282 @@
import collections
import importlib.util
import os
import queue
import threading
import time
class AudioBeatDetector:
def __init__(self):
self._lock = threading.Lock()
self._thread = None
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._status = {
"running": False,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": None,
}
def list_input_devices(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input_idx = None
try:
default_input_idx = int(sd.default.device[0])
except Exception:
default_input_idx = None
out = []
for idx, dev in enumerate(devices):
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
is_default = default_input_idx is not None and idx == default_input_idx
ch_label = f"{chans}ch" if chans > 0 else "0ch?"
label = f"[{idx}] {name} ({ch_label} @ {sr}Hz, {hostapi_name})"
if is_default:
label = f"{label} [default]"
if is_monitor_named:
label = f"{label} [monitor]"
out.append(
{
"id": idx,
"name": name,
"label": label,
"max_input_channels": chans,
"default_samplerate": sr,
"is_default": is_default,
"hostapi": hostapi_name,
}
)
return out
def diagnostics(self):
import sounddevice as sd
devices = sd.query_devices()
hostapis = sd.query_hostapis()
default_input = None
try:
default_input = sd.default.device[0]
except Exception:
default_input = None
return {
"default_input": default_input,
"hostapis": hostapis,
"devices": devices,
}
def start(self, device=None):
should_restart = False
with self._lock:
should_restart = self._running
if should_restart:
self.stop()
with self._lock:
self._stop_event.clear()
self._status.update(
{
"running": True,
"bpm": None,
"last_beat_ts": None,
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"error": None,
"device": device,
}
)
self._running = True
self._thread = threading.Thread(
target=self._run_loop, args=(device,), daemon=True
)
self._thread.start()
def stop(self):
with self._lock:
self._stop_event.set()
t = self._thread
stream = self._stream
try:
import sounddevice as sd
sd.stop(ignore_errors=True)
except Exception:
pass
if stream is not None:
try:
stream.abort()
except Exception:
pass
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
if t and t.is_alive():
t.join(timeout=3.0)
with self._lock:
self._running = False
self._thread = None
self._stream = None
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
def _set_error(self, msg):
print(f"[audio] {msg}")
with self._lock:
self._status["error"] = msg
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util.beat_driver_route import notify_beat_detected
notify_beat_detected()
except Exception as e:
print(f"[audio] beat driver route: {e}")
def _run_loop(self, device):
try:
import argparse
import numpy as np
import sounddevice as sd
except Exception as e:
self._set_error(f"audio deps unavailable: {e}")
return
try:
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
beat_detect_path = os.path.join(root_dir, "tests", "beat_detect.py")
spec = importlib.util.spec_from_file_location("beat_detect_runtime", beat_detect_path)
if spec is None or spec.loader is None:
raise RuntimeError("cannot load tests/beat_detect.py")
beat_mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(beat_mod)
if device is None:
try:
device = int(sd.default.device[0])
except Exception:
device = -1
if device is None or device < 0:
raise RuntimeError(
"no default input device; open Audio, pick an input, then Start"
)
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
args = argparse.Namespace(
mode="aubio",
device=device,
sample_rate=sample_rate,
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
def callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"[audio] status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
stream = sd.InputStream(
device=device,
channels=1,
samplerate=sample_rate,
blocksize=hop_size,
callback=callback,
)
with self._lock:
self._stream = stream
stream.start()
try:
while not self._stop_event.is_set():
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
else:
frame = np.pad(frame, (0, hop_size - frame.shape[0]))
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
continue
bpm = event.get("bpm")
self._record_beat(
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
)
finally:
try:
stream.stop()
except Exception:
pass
try:
stream.close()
except Exception:
pass
with self._lock:
if self._stream is stream:
self._stream = None
except Exception as e:
self._set_error(f"detector failed: {e}")
return
finally:
with self._lock:
self._running = False
self._status["running"] = False

View File

@@ -0,0 +1,336 @@
"""Server-side routing of audio beats to LED drivers (no browser required)."""
from __future__ import annotations
import asyncio
import json
import os
import threading
from typing import Any, Dict, List, Optional, Set
_route_lock = threading.Lock()
_beat_route: Dict[str, Any] = {
"enabled": False,
"device_names": [],
"wire_preset_id": "2",
"is_manual": False,
"pattern": "",
"manual_beat_n": 1,
}
_beat_counter: int = 0
_main_loop: Optional[asyncio.AbstractEventLoop] = None
def set_beat_route_main_loop(loop: asyncio.AbstractEventLoop) -> None:
global _main_loop
_main_loop = loop
def update_beat_route(payload: Dict[str, Any]) -> None:
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
global _beat_route, _beat_counter
if not isinstance(payload, dict):
return
with _route_lock:
if payload.get("enabled") is False:
_beat_route = {**_beat_route, "enabled": False}
_beat_counter = 0
return
names = payload.get("device_names")
if not isinstance(names, list):
names = []
try:
n_raw = int(payload.get("manual_beat_n", 1))
except (TypeError, ValueError):
n_raw = 1
manual_n = max(1, min(64, n_raw))
_beat_route = {
"enabled": bool(payload.get("enabled", False)),
"device_names": [str(n).strip() for n in names if str(n).strip()],
"wire_preset_id": str(payload.get("wire_preset_id") or "2"),
"is_manual": bool(payload.get("is_manual", False)),
"pattern": str(payload.get("pattern") or "").strip(),
"manual_beat_n": manual_n,
}
_beat_counter = 0
def get_beat_route() -> Dict[str, Any]:
with _route_lock:
return dict(_beat_route)
def _coerce_manual_beat_n(body: Any) -> int:
"""Beats between audio-triggered selects (led-controller only); default 1 = every beat."""
if not isinstance(body, dict):
return 1
raw = body.get("manual_beat_n")
if raw is None:
return 1
try:
n = int(raw)
except (TypeError, ValueError):
return 1
return max(1, min(64, n))
def _coerce_auto_from_body(body: Any) -> bool:
"""Match JS ``coercePresetAuto`` / ``build_preset_dict`` (default: auto-run)."""
if not isinstance(body, dict):
return True
raw = body.get("auto", body.get("a", True))
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lowered = raw.strip().lower()
if lowered in ("false", "0", "no", "off"):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return True
def _registry_names_for_macs(macs: Optional[List[str]]) -> List[str]:
"""Resolve push ``targets`` MAC list to registry device names (order preserved, de-duplicated)."""
if not macs:
return []
from models.device import Device, normalize_mac
devices = Device()
out: List[str] = []
seen: Set[str] = set()
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
doc = devices.read(m) or {}
nm = str(doc.get("name") or "").strip()
if nm and nm not in seen:
seen.add(nm)
out.append(nm)
return out
def _single_manual_wire_preset(
merged_presets: Dict[str, Any],
) -> tuple[Optional[str], Optional[Dict[str, Any]]]:
"""If exactly one manual (non-auto) preset is present, return its wire id and body."""
manual: List[tuple[str, Dict[str, Any]]] = []
for wid, body in merged_presets.items():
if not isinstance(body, dict):
continue
if _coerce_auto_from_body(body):
continue
manual.append((str(wid).strip(), body))
if len(manual) != 1:
return None, None
return manual[0][0], manual[0][1]
def _apply_manual_beat_route(
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Enable audio→driver routing for one manual preset, or disable if invalid."""
if not device_names:
update_beat_route({"enabled": False})
return
if not isinstance(preset_body, dict):
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
update_beat_route({"enabled": False})
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
update_beat_route({"enabled": False})
return
update_beat_route(
{
"enabled": True,
"device_names": device_names,
"wire_preset_id": wire_preset_id,
"is_manual": True,
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
}
)
def sync_beat_route_from_push_sequence(
sequence: List[Any], target_macs: Optional[List[str]] = None
) -> None:
"""
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
With a ``select`` map: use its keys as device names (existing behaviour).
Without ``select`` (e.g. manual preset loaded without immediate select): if ``target_macs``
is set and the merged ``presets`` contain exactly one manual preset, enable routing using
registry names for those MACs so the first advance is on the next audio beat.
"""
merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None
for item in sequence:
if isinstance(item, str):
try:
item = json.loads(item)
except (TypeError, ValueError):
continue
if not isinstance(item, dict) or item.get("v") != "1":
continue
pr = item.get("presets")
if isinstance(pr, dict):
merged_presets.update(pr)
sel = item.get("select")
if isinstance(sel, dict) and sel:
last_select = sel
if last_select:
device_names = [str(k).strip() for k in last_select.keys() if str(k).strip()]
if not device_names:
update_beat_route({"enabled": False})
return
wire_ids: Set[str] = set()
for name in device_names:
val = last_select.get(name)
if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip())
elif val is not None:
wire_ids.add(str(val).strip())
if len(wire_ids) != 1:
update_beat_route({"enabled": False})
return
wire_preset_id = wire_ids.pop()
preset_body = merged_presets.get(wire_preset_id)
if preset_body is None:
for k, v in merged_presets.items():
if str(k).strip() == wire_preset_id:
preset_body = v
break
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
return
wire_id, body = _single_manual_wire_preset(merged_presets)
if wire_id and body is not None:
names = _registry_names_for_macs(target_macs)
_apply_manual_beat_route(names, wire_id, body)
return
update_beat_route({"enabled": False})
def _pattern_supports_manual(pattern_key: str) -> bool:
if not pattern_key:
return True
try:
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.abspath(os.path.join(here, "..", ".."))
path = os.path.join(root, "db", "pattern.json")
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
meta = data.get(pattern_key)
if meta is None:
meta = data.get(pattern_key.lower())
if not isinstance(meta, dict):
return True
return meta.get("supports_manual") is not False
except OSError:
return True
def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
"""Update cached audio-beat target names after a device registry rename."""
global _beat_route
o = str(old_name or "").strip()
n = str(new_name or "").strip()
if not o or not n or o == n:
return
with _route_lock:
if not _beat_route.get("enabled"):
return
names = _beat_route.get("device_names") or []
new_list: List[str] = []
changed = False
for item in names:
if str(item).strip() == o:
new_list.append(n)
changed = True
else:
new_list.append(str(item))
if changed:
_beat_route = {**_beat_route, "device_names": new_list}
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
from models.device import Device
from models.device import resolve_device_mac_for_select_routing
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
return
devices = Device()
seen_macs: List[str] = []
seen_set: Set[str] = set()
for n in device_names:
mac = resolve_device_mac_for_select_routing(devices, n)
if mac and mac not in seen_set:
seen_set.add(mac)
seen_macs.append(mac)
if not seen_macs:
return
select: Dict[str, Any] = {}
for mac in seen_macs:
doc = devices.read(mac) or {}
nm = str(doc.get("name") or "").strip()
if nm:
select[nm] = [wire_preset_id]
if not select:
return
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
try:
await deliver_json_messages(sender, [msg], seen_macs, devices, delay_s=0.05)
except Exception as e:
print(f"[beat-route] deliver failed: {e}")
def notify_beat_detected() -> None:
"""Invoked from the audio thread when a beat is detected."""
global _beat_counter
with _route_lock:
r = dict(_beat_route)
if not r.get("enabled"):
return
if not r.get("is_manual"):
return
pattern = r.get("pattern") or ""
if pattern and not _pattern_supports_manual(pattern):
return
names = r.get("device_names") or []
if not names:
return
try:
n = int(r.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
_beat_counter += 1
if ((_beat_counter - 1) % n) != 0:
return
preset_id = str(r.get("wire_preset_id") or "2")
names_copy = list(names)
loop = _main_loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(_deliver_select(names_copy, preset_id), loop)
except Exception as e:
print(f"[beat-route] schedule failed: {e}")

View File

@@ -0,0 +1,86 @@
"""
Combine global, group, device (and optional zone) brightness into one 0255 wire value.
Formula: ``(f1/255) * (f2/255) * ... * (fn/255) * 255`` with integer rounding — one ``b`` sent to the driver.
"""
from __future__ import annotations
from models.device import normalize_mac
def clamp255(value) -> int:
try:
v = int(value)
except (TypeError, ValueError):
return 255
return max(0, min(255, v))
def multiply_brightness_factors(factors: list) -> int:
"""Product ``(f1/255)*(f2/255)*...*255``; each factor clamped to 0..255."""
if not factors:
return 255
fs = [clamp255(f) for f in factors]
if len(fs) == 1:
return fs[0]
num = 1
for f in fs:
num *= f
den = 255 ** (len(fs) - 1)
return max(0, min(255, (num + den // 2) // den))
def _mac_in_device_list(raw_list, target_mac: str) -> bool:
tm = normalize_mac(target_mac)
if not tm:
return False
if not isinstance(raw_list, list):
return False
for raw in raw_list:
if normalize_mac(str(raw)) == tm:
return True
return False
def effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
mac: str,
*,
zone_brightness=None,
) -> int:
"""
Factors (each 0..255): Pi **global_brightness**, each group's **output_brightness**
(neutral 255 if the device is in no group), device **output_brightness** (default 255),
optional **zone_brightness** from the zone slider when applying live.
"""
m = normalize_mac(mac)
if not m:
return 255
g_global = clamp255(settings_obj.get("global_brightness", 255))
dev_doc = devices_model.read(m)
if dev_doc is not None and dev_doc.get("output_brightness") is not None:
d_b = clamp255(dev_doc.get("output_brightness"))
else:
d_b = 255
group_factors = []
for _gid, gdoc in groups_model.items():
if not isinstance(gdoc, dict):
continue
if not _mac_in_device_list(gdoc.get("devices"), m):
continue
group_factors.append(clamp255(gdoc.get("output_brightness", 255)))
if not group_factors:
group_factors = [255]
factors = [g_global, *group_factors, d_b]
if zone_brightness is not None:
factors.append(clamp255(zone_brightness))
return multiply_brightness_factors(factors)

View File

@@ -119,13 +119,40 @@ def build_preset_dict(preset_data):
else: else:
colors = ["#FFFFFF"] colors = ["#FFFFFF"]
def _coerce_auto(raw):
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lowered = raw.strip().lower()
if lowered in ("false", "0", "no", "off"):
return False
if lowered in ("true", "1", "yes", "on"):
return True
return True
auto_raw = preset_data.get("auto", preset_data.get("a", True))
auto_bool = _coerce_auto(auto_raw)
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
elif isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
bg = f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
else:
bg = "#000000"
# Build payload using the short keys expected by led-driver # Build payload using the short keys expected by led-driver
preset = { preset = {
"p": preset_data.get("pattern", preset_data.get("p", "off")), "p": preset_data.get("pattern", preset_data.get("p", "off")),
"c": colors, "c": colors,
"d": preset_data.get("delay", preset_data.get("d", 100)), "d": preset_data.get("delay", preset_data.get("d", 100)),
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))), "b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
"a": preset_data.get("auto", preset_data.get("a", True)), "a": auto_bool,
"bg": bg,
"n1": preset_data.get("n1", 0), "n1": preset_data.get("n1", 0),
"n2": preset_data.get("n2", 0), "n2": preset_data.get("n2", 0),
"n3": preset_data.get("n3", 0), "n3": preset_data.get("n3", 0),

375
tests/beat_detect.py Normal file
View File

@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Live beat detection utility with custom/aubio/hybrid modes."""
from __future__ import annotations
import argparse
import collections
import queue
import sys
import time
from typing import Deque
try:
import numpy as np
except ImportError as exc:
raise SystemExit(
"Missing dependency: numpy. Install with `pip install numpy`."
) from exc
try:
import sounddevice as sd
except ImportError as exc:
raise SystemExit(
"Missing dependency: sounddevice. Install with `pip install sounddevice`."
) from exc
try:
import requests
except ImportError:
requests = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Beat detector utility")
parser.add_argument(
"--mode",
choices=("custom", "aubio", "hybrid"),
default="aubio",
help="Detection mode",
)
parser.add_argument("--device", default=None, help="Input device name or index")
parser.add_argument(
"--sample-rate",
type=int,
default=0,
help="Audio sample rate (0 = use selected device default)",
)
parser.add_argument("--hop-size", type=int, default=256, help="Frame hop size in samples")
parser.add_argument("--win-mult", type=int, default=2, help="Aubio window size multiplier")
parser.add_argument(
"--min-band-hz",
type=float,
default=45.0,
help="Low frequency bound used for beat energy",
)
parser.add_argument(
"--max-band-hz",
type=float,
default=180.0,
help="High frequency bound used for beat energy",
)
parser.add_argument(
"--energy-weight",
type=float,
default=0.7,
help="Weight for low-band energy component (0..1)",
)
parser.add_argument(
"--flux-weight",
type=float,
default=0.3,
help="Weight for spectral flux component (0..1)",
)
parser.add_argument(
"--threshold-multiplier",
type=float,
default=1.35,
help="Custom-mode threshold multiplier vs adaptive baseline",
)
parser.add_argument(
"--ema-alpha",
type=float,
default=0.08,
help="Adaptive baseline smoothing (higher reacts faster)",
)
parser.add_argument(
"--min-ioi-ms",
type=float,
default=85.0,
help="Minimum time between beats in milliseconds",
)
parser.add_argument(
"--bpm-window",
type=int,
default=8,
help="How many recent beat intervals to use for BPM estimate",
)
parser.add_argument(
"--post-url",
default="",
help="Optional HTTP URL to POST beat events",
)
parser.add_argument(
"--aubio-method",
default="default",
choices=("default", "specdiff", "hfc", "complex", "phase", "energy"),
help="Aubio tempo method",
)
parser.add_argument(
"--aubio-threshold",
type=float,
default=0.12,
help="Aubio detection threshold",
)
parser.add_argument(
"--silence-gate-db",
type=float,
default=-58.0,
help="Ignore beat triggers when frame RMS is below this dB level",
)
return parser.parse_args()
def _estimate_bpm(beat_times: Deque[float]) -> float | None:
if len(beat_times) < 3:
return None
intervals = np.diff(np.array(beat_times, dtype=np.float64))
valid = intervals[(intervals > 0.2) & (intervals < 2.0)]
if valid.size == 0:
return None
return 60.0 / float(np.median(valid))
def _load_aubio_if_needed(mode: str):
if mode == "custom":
return None
try:
import aubio
return aubio
except ImportError:
dist_packages = "/usr/lib/python3/dist-packages"
if dist_packages not in sys.path:
sys.path.append(dist_packages)
try:
import aubio
return aubio
except ImportError:
raise SystemExit("aubio not installed; use --mode custom or install aubio")
class BeatDetectRuntime:
"""Reusable detector runtime so web and CLI can share logic."""
def __init__(self, args):
self.args = args
self.aubio = _load_aubio_if_needed(args.mode)
self.sample_rate = 0
self.frame_size = 0
self.tempo = None
self.band_mask = None
self.freqs = None
self.window = None
self.prev_mag = None
self.kick_mask = None
self.snare_mask = None
self.hat_mask = None
self.baseline = 1e-6
self.beat_times: Deque[float] = collections.deque(
maxlen=max(2, args.bpm_window + 1)
)
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
self.frame_size = max(128, int(self.args.hop_size))
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
freqs = np.fft.rfftfreq(self.frame_size, d=1.0 / self.sample_rate)
self.freqs = freqs
self.band_mask = (freqs >= self.args.min_band_hz) & (
freqs <= self.args.max_band_hz
)
self.kick_mask = (freqs >= 40.0) & (freqs <= 140.0)
self.snare_mask = (freqs >= 140.0) & (freqs <= 3000.0)
self.hat_mask = (freqs >= 5000.0) & (freqs <= 12000.0)
if not np.any(self.band_mask):
raise ValueError("Invalid band range for current sample rate")
self.window = np.hanning(self.frame_size).astype(np.float32)
self.prev_mag = np.zeros(freqs.shape[0], dtype=np.float32)
self.baseline = 1e-6
self.last_trigger_s = 0.0
self.beat_times.clear()
self.tempo = None
if self.aubio is not None:
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
if hasattr(self.tempo, "set_threshold"):
self.tempo.set_threshold(float(self.args.aubio_threshold))
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
snare = float(np.mean(mag[self.snare_mask])) / total if np.any(self.snare_mask) else 0.0
hat = float(np.mean(mag[self.hat_mask])) / total if np.any(self.hat_mask) else 0.0
scores = {
"kick": kick,
"snare": snare,
"hat": hat,
}
label, value = max(scores.items(), key=lambda kv: kv[1])
if value < 1.15:
return "unknown", value
return label, value
def process_frame(self, frame: np.ndarray, now_s: float | None = None):
if self.window is None or self.band_mask is None:
raise RuntimeError("Runtime not setup")
if frame.shape[0] != self.frame_size:
if frame.shape[0] > self.frame_size:
frame = frame[: self.frame_size]
else:
frame = np.pad(frame, (0, self.frame_size - frame.shape[0]))
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
if db < float(self.args.silence_gate_db):
return None
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
self.prev_mag[:] = mag
weight_sum = max(1e-6, self.args.energy_weight + self.args.flux_weight)
score = ((self.args.energy_weight * band_energy) + (self.args.flux_weight * flux)) / weight_sum
self.baseline = ((1.0 - self.args.ema_alpha) * self.baseline) + (
self.args.ema_alpha * score
)
threshold = self.baseline * self.args.threshold_multiplier
custom_hit = score > threshold
aubio_hit = False
aubio_bpm = None
if self.tempo is not None:
aubio_hit = bool(self.tempo(f32)[0])
val = float(self.tempo.get_bpm())
aubio_bpm = val if val > 0 else None
if now_s is None:
now_s = time.time()
if (now_s - self.last_trigger_s) < self.debounce_s:
return None
if self.args.mode == "custom":
should_trigger = custom_hit
elif self.args.mode == "aubio":
should_trigger = aubio_hit
else:
should_trigger = custom_hit or aubio_hit
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag)
if self.args.mode == "custom":
src = "custom"
elif self.args.mode == "aubio":
src = "aubio"
elif custom_hit and aubio_hit:
src = "both"
elif custom_hit:
src = "custom"
else:
src = "aubio"
return {
"ts": now_s,
"bpm": bpm,
"src": src,
"score": score,
"threshold": threshold,
"strength": strength,
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": db,
}
def main() -> int:
args = parse_args()
runtime = BeatDetectRuntime(args)
if args.post_url and requests is None:
raise SystemExit("`requests` is required for --post-url (pip install requests)")
if args.sample_rate > 0:
sample_rate = args.sample_rate
else:
dev_info = sd.query_devices(args.device, "input")
sample_rate = int(dev_info["default_samplerate"])
runtime.setup(sample_rate=sample_rate)
frame_size = runtime.frame_size
audio_q: "queue.Queue[np.ndarray]" = queue.Queue(maxsize=64)
def audio_callback(indata, frames, _time_info, status):
_ = frames
if status:
print(f"audio status: {status}")
mono = np.asarray(indata[:, 0], dtype=np.float32)
if not audio_q.full():
audio_q.put_nowait(mono)
print(
"Listening... Ctrl+C to stop. "
f"mode={args.mode} sr={sample_rate} hop={frame_size} "
f"band={args.min_band_hz:.0f}-{args.max_band_hz:.0f}Hz "
f"custom_th={args.threshold_multiplier:.2f} aubio_th={args.aubio_threshold:.2f} "
f"min_ioi={args.min_ioi_ms:.0f}ms"
)
with sd.InputStream(
device=args.device,
channels=1,
samplerate=sample_rate,
blocksize=frame_size,
callback=audio_callback,
):
try:
while True:
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
if frame.shape[0] != frame_size:
if frame.shape[0] > frame_size:
frame = frame[:frame_size]
else:
frame = np.pad(frame, (0, frame_size - frame.shape[0]))
event = runtime.process_frame(frame, now_s=time.time())
if event is None:
continue
now_s = event["ts"]
bpm = event["bpm"]
bpm_text = f"{bpm:.1f}" if isinstance(bpm, (float, int)) else "--"
src = event["src"]
print(
f"[{args.mode}] BEAT bpm={bpm_text} src={src} type={event['beat_type']} "
f"type_conf={event['beat_type_confidence']:.2f} strength={event['strength']:.2f} "
f"db={event['db']:.1f} "
f"score={event['score']:.3e} threshold={event['threshold']:.3e}"
)
if args.post_url and requests is not None:
try:
requests.post(
args.post_url,
json={"beat": True, "source": src, "ts": now_s, "bpm": bpm},
timeout=0.5,
)
except Exception as exc:
print(f"post failed: {exc}")
except KeyboardInterrupt:
print("\nStopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Generate a click-track WAV file at a known BPM.
Example:
python tests/make_bpm_test_audio.py --bpm 128 --seconds 60 --output tests/audio_128bpm.wav
"""
from __future__ import annotations
import argparse
import math
import struct
import wave
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate known-BPM click track")
parser.add_argument("--bpm", type=float, required=True, help="Target BPM (e.g. 120)")
parser.add_argument("--seconds", type=float, default=30.0, help="Audio duration in seconds")
parser.add_argument("--sample-rate", type=int, default=44100, help="Sample rate")
parser.add_argument("--click-ms", type=float, default=25.0, help="Click length in milliseconds")
parser.add_argument(
"--output",
default="tests/bpm_test.wav",
help="Output WAV path",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
if args.bpm <= 0:
raise SystemExit("--bpm must be > 0")
if args.seconds <= 0:
raise SystemExit("--seconds must be > 0")
sample_rate = int(args.sample_rate)
total_samples = int(args.seconds * sample_rate)
beat_interval = 60.0 / float(args.bpm)
click_samples = max(1, int((args.click_ms / 1000.0) * sample_rate))
data = [0.0] * total_samples
beat_index = 0
t = 0.0
while t < args.seconds:
start = int(t * sample_rate)
# Slight accent every 4 beats to help human counting.
freq = 1760.0 if beat_index % 4 == 0 else 1320.0
amp = 0.9 if beat_index % 4 == 0 else 0.6
for i in range(click_samples):
idx = start + i
if idx >= total_samples:
break
env = math.exp(-8.0 * (i / click_samples))
s = amp * env * math.sin((2.0 * math.pi * freq * i) / sample_rate)
data[idx] += s
t += beat_interval
beat_index += 1
with wave.open(args.output, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
frames = bytearray()
for s in data:
clipped = max(-1.0, min(1.0, s))
frames.extend(struct.pack("<h", int(clipped * 32767)))
wf.writeframes(bytes(frames))
print(f"Wrote {args.output} at {args.bpm:.2f} BPM for {args.seconds:.1f}s")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Play a click track with tempo variation for BPM detector testing.
Examples:
python tests/play_varying_click_track.py --start-bpm 90 --end-bpm 150 --seconds 60
python tests/play_varying_click_track.py --pattern steps --step-bpms 100,120,140,160 --step-seconds 8
"""
from __future__ import annotations
import argparse
import math
import time
import numpy as np
import sounddevice as sd
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Play varying-BPM click track")
parser.add_argument("--device", default=None, help="Output audio device name or index")
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate")
parser.add_argument("--seconds", type=float, default=60.0, help="Playback duration")
parser.add_argument(
"--stabilize-seconds",
type=float,
default=5.0,
help="Play initial BPM for this many seconds before varying",
)
parser.add_argument("--pattern", choices=("sweep", "steps"), default="sweep")
# Sweep options
parser.add_argument("--start-bpm", type=float, default=90.0, help="Sweep start BPM")
parser.add_argument("--end-bpm", type=float, default=150.0, help="Sweep end BPM")
# Step options
parser.add_argument(
"--step-bpms",
default="100,120,140,160",
help="Comma-separated BPMs for step mode",
)
parser.add_argument("--step-seconds", type=float, default=8.0, help="Seconds per BPM step")
# Click tone options
parser.add_argument("--click-ms", type=float, default=25.0, help="Click duration")
parser.add_argument("--accent-every", type=int, default=4, help="Accent every N beats")
parser.add_argument(
"--hold-beats",
type=int,
default=1,
help="Hold BPM for this many beats before recalculating",
)
return parser.parse_args()
def bpm_for_time(t_s: float, args: argparse.Namespace, step_bpms: list[float]) -> float:
if t_s < args.stabilize_seconds:
return args.start_bpm if args.pattern == "sweep" else (step_bpms[0] if step_bpms else 120.0)
adj_t = t_s - args.stabilize_seconds
if args.pattern == "sweep":
active_seconds = max(1e-6, args.seconds - args.stabilize_seconds)
if active_seconds <= 0:
return args.start_bpm
alpha = min(1.0, max(0.0, adj_t / active_seconds))
return args.start_bpm + (args.end_bpm - args.start_bpm) * alpha
if not step_bpms:
return 120.0
if args.step_seconds <= 0:
return step_bpms[0]
idx = int(adj_t // args.step_seconds) % len(step_bpms)
return step_bpms[idx]
def main() -> int:
args = parse_args()
if args.seconds <= 0:
raise SystemExit("--seconds must be > 0")
if args.sample_rate <= 0:
raise SystemExit("--sample-rate must be > 0")
step_bpms = [float(x.strip()) for x in args.step_bpms.split(",") if x.strip()]
click_samples = max(1, int(args.click_ms * args.sample_rate / 1000.0))
state = {
"t": 0.0, # playback time in seconds
"next_beat": 0.0,
"beat_idx": 0,
"current_bpm": 0.0,
"held_bpm": 0.0,
"hold_counter": 0,
"click_remaining": 0,
"click_phase": 0,
"click_freq": 1320.0,
"click_amp": 0.6,
}
def callback(outdata, frames, _time_info, status):
if status:
print(f"audio status: {status}")
block = np.zeros(frames, dtype=np.float32)
for i in range(frames):
t = state["t"]
dynamic_bpm = bpm_for_time(t, args, step_bpms)
state["current_bpm"] = dynamic_bpm
bpm = state["held_bpm"] if state["held_bpm"] > 0 else dynamic_bpm
beat_interval = 60.0 / max(1e-6, bpm)
if t >= state["next_beat"]:
hold_beats = max(1, int(args.hold_beats))
if state["hold_counter"] <= 0:
state["held_bpm"] = dynamic_bpm
state["hold_counter"] = hold_beats
state["hold_counter"] -= 1
bpm = state["held_bpm"]
beat_interval = 60.0 / max(1e-6, bpm)
state["beat_idx"] += 1
state["next_beat"] = t + beat_interval
state["click_remaining"] = click_samples
state["click_phase"] = 0
accented = (
args.accent_every > 0 and state["beat_idx"] % args.accent_every == 1
)
state["click_freq"] = 1760.0 if accented else 1320.0
state["click_amp"] = 0.9 if accented else 0.6
if state["click_remaining"] > 0:
p = state["click_phase"]
env = math.exp(-8.0 * (p / click_samples))
sample = state["click_amp"] * env * math.sin(
2.0 * math.pi * state["click_freq"] * (p / args.sample_rate)
)
block[i] = sample
state["click_phase"] += 1
state["click_remaining"] -= 1
state["t"] += 1.0 / args.sample_rate
outdata[:, 0] = block
print(
f"Playing varying click track for {args.seconds:.1f}s ({args.pattern}), "
f"stabilize={args.stabilize_seconds:.1f}s"
)
with sd.OutputStream(
samplerate=args.sample_rate,
channels=1,
dtype="float32",
callback=callback,
device=args.device,
blocksize=0,
):
start = time.time()
last_printed_beat = 0
while (time.time() - start) < args.seconds:
beat_idx = int(state["beat_idx"])
if beat_idx != last_printed_beat:
last_printed_beat = beat_idx
print(
f"beat={beat_idx:04d} bpm={state['held_bpm']:.2f} "
f"(target={state['current_bpm']:.2f})"
)
time.sleep(0.05)
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -2,7 +2,7 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device in an actual browser. Target host defaults to Tests run against the device in an actual browser. Target host defaults to
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname, ``127.0.0.1:5000``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
or a full ``http://`` / ``https://`` base URL). or a full ``http://`` / ``https://`` base URL).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE`` Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
@@ -49,7 +49,7 @@ from selenium.common.exceptions import (
ElementNotInteractableException, ElementNotInteractableException,
) )
_DEFAULT_DEVICE_HOST = "192.168.4.1" _DEFAULT_DEVICE_HOST = "127.0.0.1:5000"
def _device_base_url() -> str: def _device_base_url() -> str:

View File

@@ -347,6 +347,15 @@ def test_settings_controller(server):
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12}) resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400 assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
assert resp.status_code == 400
def test_profiles_presets_zones_endpoints(server, monkeypatch): def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"] c: requests.Session = server["client"]