11 Commits

Author SHA1 Message Date
879db2a7df chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:14:57 +12:00
96d1e1b5fd feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to
wire n6 with a mode dropdown for multi-mode patterns; zone edit shows
presets or sequences only with content_kind on save; update catalogue
and tests for merged pattern names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:12:42 +12:00
6286297646 feat(patterns): register northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:33 +12:00
ca3fef3f8a feat(patterns): winter icicles blizzard rime in controller catalogue
Register pattern metadata and test presets for new led-driver effects; bump led-driver submodule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:10:02 +12:00
6c9e06f33b feat(zones): profile-scoped groups, zone modes, sequence brightness
- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 01:58:00 +12:00
c1c3e5d71b feat(ui): edit tab zones, audio readout, live reload
- Zones/presets/sequence strip and Pipfile dev command fix
- Optional live reload and beat test audio asset + generator

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:20 +12:00
c64dd736f2 feat(api): parallel group sends and batch identify
- asyncio.gather for group brightness and driver-config Wi-Fi pushes
- Batch identify envelope for group members

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:13 +12:00
cad0aa7e59 feat(sequences): multi-lane playback and per-lane manual beats
- Add sequence_playback with beat and time advance, zone targeting fixes
- Per-lane manual beat routing in beat_driver_route (parallel lanes)
- Sequence API, editor JS, fix sequence model filename, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 00:44:08 +12:00
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
61 changed files with 10480 additions and 722 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.

12
.gitignore vendored
View File

@@ -28,7 +28,17 @@ Thumbs.db
scripts/.led-controller-venv scripts/.led-controller-venv
docs/.help-print.html docs/.help-print.html
settings.json settings.json
db/ # 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

View File

@@ -14,6 +14,8 @@ selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*" microdot = "*"
websockets = "*" websockets = "*"
numpy = "*"
sounddevice = "*"
[dev-packages] [dev-packages]
pytest = "*" pytest = "*"
@@ -25,8 +27,7 @@ python_version = "3.11"
web = "python 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"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
test = "python -m pytest" 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 = "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'" 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 +1 @@
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}} {"1": {"name": "group1", "devices": ["e8f60a16fb00", "e8f60a170794"], "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}, "2": {"name": "group2", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "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}, "3": {"name": "group3", "devices": ["e8f60a16f288"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "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}, "4": {"name": "group4", "devices": ["e8f60a16e79c"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "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}, "5": {"name": "desk", "devices": ["188b0e1560a8"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "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, "profile_id": null}}

View File

@@ -1 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]} {"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []}

View File

@@ -1 +1,273 @@
{"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, "has_background": true}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": 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}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "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 1030 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "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}, "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}, "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, "has_background": true}, "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, "has_background": true}, "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, "has_background": true}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "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, "has_background": true}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "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, "has_background": 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}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "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
},
"colour_cycle": {
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
"1": "Rainbow wheel (preset colours ignored)"
},
"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,
"mode": {
"0": "Two-colour chase",
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
}
},
"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 10\u201330 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": false
},
"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,
"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": 10,
"has_background": true,
"supports_manual": true
},
"plasma": {
"n1": "Scale",
"n2": "Speed",
"n3": "Contrast",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"bar_graph": {
"n1": "Level percent",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"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
},
"clock_sweep": {
"n1": "Hand width",
"n2": "Marker interval",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"aurora": {
"n1": "Band count (0) or spatial period LEDs (1)",
"n2": "Shimmer (0) or blend strength (1)",
"n3": "Unused (0) or drift speed (1)",
"mode": {
"0": "Colour bands + shimmer",
"1": "Sine northern wave"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"icicles": {
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"blizzard": {
"n1": "Flake density",
"n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"rime": {
"n1": "Crystallisation rate",
"n2": "Melt (decay) per refresh",
"n3": "Spark cap (LEDs refreshed per cycle)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"candle_glow": {
"n1": "Candle count",
"n2": "Glow width (LEDs)",
"n3": "Flicker strength",
"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
},
"meteor": {
"n1": "Tail length (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"particles": {
"n1": "Flake density (0) or spawn rate (1)",
"n2": "Fall speed (LEDs per frame)",
"n3": "Unused (0) or streak length (1)",
"mode": {
"0": "Snowfall flakes",
"1": "Starfall streaks"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"sparkle": {
"n1": "Spark density (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}} {"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}

View File

@@ -1 +1 @@
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}} {}

View File

@@ -1,3 +1,5 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"] python_files = ["test_*.py"]
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
norecursedirs = ["models"]

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):
@@ -143,6 +167,107 @@ async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, nam
pass pass
async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
"""
Send the same identify blink as ``POST /devices/<id>/identify``.
Returns ``(http_status, "")`` on success, or ``(status, error_message)`` on failure
(status matches the single-device route).
"""
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
sender = get_current_sender()
if not sender:
return 503, "Transport not configured"
name = str(dev.get("name") or "").strip()
if not name:
return 400, "Device must have a name to identify"
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return 400, "Device has no IP address"
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return 503, "Wi-Fi driver not connected"
else:
await sender.send(msg, addr=dev_id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
except Exception as e:
return 503, str(e)
return 200, ""
async def send_identify_to_group_devices(macs: list[str]) -> tuple[int, list[dict]]:
"""
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
``deliver_json_messages``.
"""
from util.driver_delivery import deliver_json_messages
errors: list[dict] = []
sender = get_current_sender()
if not sender:
return 0, [{"mac": "*", "error": "Transport not configured"}]
merged_select: dict[str, list[str]] = {}
valid_macs: list[str] = []
for dev_id in macs:
dev = devices.read(dev_id)
if not dev:
errors.append({"mac": dev_id, "error": "Device not found"})
continue
name = str(dev.get("name") or "").strip()
if not name:
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
continue
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
if not dev.get("address"):
errors.append({"mac": dev_id, "error": "Device has no IP address"})
continue
merged_select[name] = [_IDENTIFY_PRESET_KEY]
valid_macs.append(dev_id)
if not merged_select:
return 0, errors
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select=merged_select,
)
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}]
for dev_id in valid_macs:
dev = devices.read(dev_id) or {}
name = str(dev.get("name") or "").strip()
transport = (dev.get("transport") or "espnow").strip().lower()
wifi_ip = dev.get("address") if transport == "wifi" else None
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
return len(valid_macs), errors
@controller.get("") @controller.get("")
async def list_devices(request): async def list_devices(request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence).""" """List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
@@ -154,6 +279,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 +400,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",
@@ -271,51 +442,124 @@ async def identify_device(request, id):
this device name — same combined shape as profile sends the driver already accepts over TCP this device name — same combined shape as profile sends the driver already accepts over TCP
/ ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``. / ESP-NOW. No ``save``. After ``IDENTIFY_OFF_DELAY_S``, a background task selects ``off``.
""" """
status, err = await send_identify_to_device(id)
if status == 200:
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
@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) dev = devices.read(id)
if not dev: if not dev:
return json.dumps({"error": "Device not found"}), 404, { return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
sender = get_current_sender() body = request.json or {}
if not sender: zb = None
return json.dumps({"error": "Transport not configured"}), 503, { if isinstance(body, dict) and body.get("zone_brightness") is not None:
"Content-Type": "application/json", try:
} zb = _validate_output_brightness(body.get("zone_brightness"))
name = str(dev.get("name") or "").strip() except ValueError as e:
if not name: return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return json.dumps({"error": "Device must have a name to identify"}), 400, { b_val = effective_brightness_for_mac(
"Content-Type": "application/json", _pi_settings,
} _group_registry,
devices,
id,
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi": if transport == "wifi":
wifi_ip = dev.get("address") ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not wifi_ip: if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, { return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
ok = await send_json_line_to_ip(ip, msg)
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok: if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, { return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
else: 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) await sender.send(msg, addr=id)
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"} return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "Identify sent"}), 200, {
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", "Content-Type": "application/json",
} }

View File

@@ -1,50 +1,359 @@
from microdot import Microdot from microdot import Microdot
from microdot.session import with_session
import asyncio
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('')
async def list_groups(request):
"""List all groups."""
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') def _group_doc_visible_for_profile(doc, profile_id):
async def get_group(request, id): if not isinstance(doc, dict):
"""Get a specific group by ID.""" return False
scoped = doc.get("profile_id")
if scoped is None:
scoped = doc.get("profileId")
if scoped is None or str(scoped).strip() == "":
return True
if not profile_id:
return False
return str(scoped).strip() == str(profile_id).strip()
def _filtered_groups_dict(session):
from controllers.zone import get_current_profile_id
pid = get_current_profile_id(session)
out = {}
for gid, doc in groups.items():
if not isinstance(doc, dict):
continue
if _group_doc_visible_for_profile(doc, pid):
out[str(gid)] = doc
return out
@controller.get("")
@with_session
async def list_groups(request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_group(request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id) group = groups.read(id)
if group: if not group or not isinstance(group, dict):
return json.dumps(group), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
@controller.post('') if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
async def create_group(request): return json.dumps({"error": "Group not found"}), 404
"""Create a new group.""" return json.dumps(group), 200, {"Content-Type": "application/json"}
def _sanitize_group_profile_id_write(data, session):
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
if not isinstance(data, dict):
return
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if "profile_id" not in data and "profileId" not in data:
return
raw = data.get("profile_id")
if raw is None and "profileId" in data:
raw = data.get("profileId")
if raw is None or raw == "":
data.pop("profileId", None)
data["profile_id"] = None
return
if not cur or str(raw).strip() != str(cur).strip():
data.pop("profileId", None)
data.pop("profile_id", None)
@controller.post("")
@with_session
async def create_group(request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try: try:
data = request.json or {} data = dict(request.json or {})
name = data.get("name", "") name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
group_id = groups.create(name) group_id = groups.create(name)
if data: if data:
groups.update(group_id, data) groups.update(group_id, data)
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'} if profile_scoped:
from controllers.zone import get_current_profile_id
cur = get_current_profile_id(session)
if cur:
groups.update(group_id, {"profile_id": str(cur)})
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_group(request, id): @controller.put("/<id>")
@with_session
async def update_group(request, session, id):
"""Update an existing group.""" """Update an existing group."""
try: try:
data = request.json data = request.json
if not isinstance(data, dict):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
data = dict(data)
_sanitize_group_profile_id_write(data, session)
if groups.update(id, data): if groups.update(id, data):
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'} g = groups.read(id)
if g:
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404 return json.dumps({"error": "Group not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>') @controller.delete("/<id>")
async def delete_group(request, id): @with_session
"""Delete a group.""" async def delete_group(request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return json.dumps({"error": "Group not found"}), 404
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
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
def _read_group_for_session(session, id):
g = groups.read(id)
if not g or not isinstance(g, dict):
return None
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return None
return g
@controller.post("/<id>/driver-config")
@with_session
async def push_group_driver_config(request, session, 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 = _read_group_for_session(session, 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 = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
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
tasks.append(send_json_line_to_ip(ip, msg))
meta_macs.append(m)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for m, r in zip(meta_macs, results):
if r is True:
sent += 1
elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
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")
@with_session
async def push_group_output_brightness(request, session, id):
"""
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
"""
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
sender = get_current_sender()
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
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:
return m, False, "no IP"
ok = await send_json_line_to_ip(ip, msg)
return m, bool(ok), None if ok else "driver not connected"
if not sender:
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
return m, True, None
except Exception as e:
return m, False, str(e)
tasks: list = []
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
tasks.append(_push_brightness_one(m, dev))
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for r in results:
if isinstance(r, Exception):
errors.append({"mac": "*", "error": str(r)})
continue
m, ok, err = r
if ok:
sent += 1
elif err:
errors.append({"mac": m, "error": err})
return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
@controller.post("/<id>/identify")
@with_session
async def identify_group_devices(request, session, id):
"""
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
in parallel so all drivers in the group blink together.
"""
_ = request
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
if not mac_list:
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
from controllers.device import send_identify_to_group_devices
normalized: list[str] = []
errors: list[dict] = []
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(m) != 12:
errors.append({"mac": str(mac), "error": "invalid MAC"})
continue
normalized.append(m)
if not normalized:
return json.dumps(
{"message": "identify group done", "sent": 0, "errors": errors}
), 200, {"Content-Type": "application/json"}
sent, batch_errors = await send_identify_to_group_devices(normalized)
errors.extend(batch_errors)
return json.dumps(
{"message": "identify group done", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}

View File

@@ -368,6 +368,7 @@ async def create_driver_pattern(request):
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), 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).
""" """
@@ -413,6 +414,9 @@ async def create_driver_pattern(request):
if "has_background" in data: if "has_background" in data:
meta["has_background"] = bool(data.get("has_background")) 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

@@ -2,16 +2,30 @@ from microdot import Microdot
from microdot.session import with_session from microdot.session import with_session
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac from models.device import Device, normalize_mac
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.espnow_message import build_message, build_preset_dict from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json import json
controller = Microdot() controller = Microdot()
presets = Preset() presets = Preset()
profiles = Profile() profiles = Profile()
def _palette_colors_for_profile(profile_id):
prof = profiles.read(str(profile_id))
if not isinstance(prof, dict):
return None
pid = prof.get("palette_id") or prof.get("paletteId")
if not pid:
return None
cols = Palette().read(str(pid))
return cols if isinstance(cols, list) else None
def get_current_profile_id(session=None): def get_current_profile_id(session=None):
"""Get the current active profile ID from session or fallback to first.""" """Get the current active profile ID from session or fallback to first."""
profile_list = profiles.list() profile_list = profiles.list()
@@ -37,6 +51,41 @@ async def list_presets(request, session):
} }
return json.dumps(scoped), 200, {'Content-Type': 'application/json'} return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>/export')
@with_session
async def export_preset(request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
try:
bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
@controller.post('/import')
@with_session
async def import_preset(request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>') @controller.get('/<preset_id>')
@with_session @with_session
async def get_preset(request, session, preset_id): async def get_preset(request, session, preset_id):
@@ -153,6 +202,7 @@ async def send_presets(request, session):
# Build API-compliant preset map keyed by preset ID, include name # Build API-compliant preset map keyed by preset ID, include name
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
palette_colors = _palette_colors_for_profile(current_profile_id)
presets_by_name = {} presets_by_name = {}
for pid in preset_ids: for pid in preset_ids:
preset_data = presets.read(str(pid)) preset_data = presets.read(str(pid))
@@ -161,7 +211,7 @@ async def send_presets(request, session):
if str(preset_data.get("profile_id")) != str(current_profile_id): if str(preset_data.get("profile_id")) != str(current_profile_id):
continue continue
preset_key = str(pid) preset_key = str(pid)
preset_payload = build_preset_dict(preset_data) preset_payload = build_preset_dict(preset_data, palette_colors)
preset_payload["name"] = preset_data.get("name", "") preset_payload["name"] = preset_data.get("name", "")
presets_by_name[preset_key] = preset_payload presets_by_name[preset_key] = preset_payload
@@ -315,6 +365,17 @@ 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 import sequence_playback as seq_pb
from util.beat_driver_route import sync_beat_route_from_push_sequence
preserve = bool(seq_pb.playback_status().get("active"))
sync_beat_route_from_push_sequence(
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
)
except Exception:
pass
return json.dumps({ return json.dumps({
"message": "Delivered", "message": "Delivered",
"deliveries": deliveries, "deliveries": deliveries,

View File

@@ -3,12 +3,15 @@ from microdot.session import with_session
from models.profile import Profile from models.profile import Profile
from models.zone import Zone from models.zone import Zone
from models.preset import Preset from models.preset import Preset
from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json import json
controller = Microdot() controller = Microdot()
profiles = Profile() profiles = Profile()
zones = Zone() zones = Zone()
presets = Preset() presets = Preset()
sequences = Sequence()
@controller.get('') @controller.get('')
@with_session @with_session
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'} return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404 return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id) @controller.post('/import')
if profile: @with_session
return json.dumps(profile), 200, {'Content-Type': 'application/json'} async def import_profile(request, session):
return json.dumps({"error": "Profile not found"}), 404 """Import a profile bundle (optionally apply as current profile)."""
try:
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
name = body.get("name") if isinstance(body, dict) else None
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
if isinstance(apply_raw, str):
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
else:
apply = bool(apply_raw)
new_profile_id, profile_data = import_profile_bundle(
bundle,
profiles,
zones,
presets,
sequences,
profiles._palette_model,
name=str(name).strip() if name else None,
)
if apply:
session['current_profile'] = str(new_profile_id)
session.save()
return (
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
201,
{'Content-Type': 'application/json'},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<id>/export')
async def export_profile(request, id):
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
try:
bundle = export_profile_bundle(
str(id),
profiles,
zones,
presets,
sequences,
profiles._palette_model,
)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.post('/<id>/apply') @controller.post('/<id>/apply')
@with_session @with_session
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
session.save() session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone') @controller.post('/<id>/clone')
async def clone_profile(request, id): async def clone_profile(request, id):
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
"mode": 1,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
"mode": 1,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current') @controller.put('/current')
@with_session @with_session
async def update_current_profile(request, session): async def update_current_profile(request, session):

View File

@@ -1,51 +1,261 @@
from microdot import Microdot from microdot import Microdot
from models.squence import Sequence from microdot.session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_sender
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json import json
controller = Microdot() controller = Microdot()
sequences = Sequence() sequences = Sequence()
profiles = Profile()
presets = Preset()
@controller.get('')
async def list_sequences(request):
"""List all sequences."""
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
@controller.get('/<id>') def get_current_profile_id(session=None):
async def get_sequence(request, id): """Get the current active profile ID from session or fallback to first."""
"""Get a specific sequence by ID.""" profile_list = profiles.list()
sequence = sequences.read(id) session_profile = None
if sequence: if session is not None:
return json.dumps(sequence), 200, {'Content-Type': 'application/json'} session_profile = session.get("current_profile")
if session_profile and session_profile in profile_list:
return session_profile
if profile_list:
return profile_list[0]
return None
@controller.get("")
@with_session
async def list_sequences(request, session):
"""List sequences for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
scoped = {
sid: sdata
for sid, sdata in sequences.items()
if isinstance(sdata, dict)
and str(sdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
@controller.get("/<id>/export")
@with_session
async def export_sequence(request, session, id):
"""Export a sequence and referenced presets as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
try:
bundle = export_sequence_bundle(
id,
sequences,
presets,
profile_id=current_profile_id,
)
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
@controller.post("/import")
@with_session
async def import_sequence(request, session):
"""Import a sequence bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return (
json.dumps({"error": "Expected JSON bundle"}),
400,
{"Content-Type": "application/json"},
)
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
return (
json.dumps({new_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.get("/<id>")
@with_session
async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
seq
and current_profile_id
and str(seq.get("profile_id")) == str(current_profile_id)
):
return json.dumps(seq), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
@controller.post('')
async def create_sequence(request): @controller.post("")
"""Create a new sequence.""" @with_session
async def create_sequence(request, session):
"""Create a new sequence for the current profile."""
try:
try: try:
data = request.json or {} data = request.json or {}
group_name = data.get("group_name", "") except Exception:
preset_names = data.get("presets", None) return (
sequence_id = sequences.create(group_name, preset_names) json.dumps({"error": "Invalid JSON"}),
if data: 400,
sequences.update(sequence_id, data) {"Content-Type": "application/json"},
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'} )
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
sequence_id = sequences.create(current_profile_id)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(sequence_id, data):
seq_data = sequences.read(sequence_id)
return (
json.dumps({sequence_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Failed to create sequence"}),
400,
{"Content-Type": "application/json"},
)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put('/<id>')
async def update_sequence(request, id): @controller.put("/<id>")
"""Update an existing sequence.""" @with_session
async def update_sequence(request, session, id):
"""Update an existing sequence (current profile only)."""
try: try:
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404
data = request.json data = request.json
if not isinstance(data, dict):
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"Content-Type": "application/json"},
)
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(id, data): if sequences.update(id, data):
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'} try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete('/<id>')
async def delete_sequence(request, id): @controller.delete("/<id>")
"""Delete a sequence.""" @with_session
if sequences.delete(id): async def delete_sequence(request, session, id):
return json.dumps({"message": "Sequence deleted successfully"}), 200 """Delete a sequence (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404 return json.dumps({"error": "Sequence not found"}), 404
try:
from util.sequence_playback import stop_if_playing_sequence
stop_if_playing_sequence(str(id))
except Exception:
pass
if sequences.delete(id):
return (
json.dumps({"message": "Sequence deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop
stop()
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("/<id>/play")
@with_session
async def play_sequence(request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_sender():
return (
json.dumps({"error": "Transport not configured"}),
503,
{"Content-Type": "application/json"},
)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
try:
data = request.json or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
zone_id = data.get("zone_id") or data.get("zoneId")
if zone_id is None or str(zone_id).strip() == "":
return (
json.dumps({"error": "zone_id required"}),
400,
{"Content-Type": "application/json"},
)
zone_id = str(zone_id).strip()
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}

View File

@@ -290,6 +290,8 @@ 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 = []
content_kind = None
else: else:
data = request.json or {} data = request.json or {}
name = data.get("name", "") name = data.get("name", "")
@@ -297,11 +299,20 @@ 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 = []
raw_kind = data.get("content_kind")
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
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, content_kind)
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
if profile_id: if profile_id:
@@ -333,7 +344,13 @@ 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"),
source.get("content_kind"),
)
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

@@ -2,6 +2,7 @@ import asyncio
import errno import errno
import json import json
import os import os
import secrets
import signal import signal
import socket import socket
import threading import threading
@@ -31,12 +32,18 @@ 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()
DISCOVERY_UDP_PORT = 8766 DISCOVERY_UDP_PORT = 8766
def _live_reload_enabled() -> bool:
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
return v not in ("", "0", "false", "no")
def _register_udp_device_sync( def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None device_name: str, peer_ip: str, mac, device_type=None
) -> None: ) -> None:
@@ -246,6 +253,29 @@ async def main(port=80):
set_sender(sender) set_sender(sender)
app = Microdot() app = Microdot()
audio_detector = AudioBeatDetector()
try:
from util import audio_detector as audio_detector_module
audio_detector_module.set_shared_beat_detector(audio_detector)
except Exception as e:
print(f"[startup] audio detector shared registration skipped: {e!r}")
try:
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
persisted = read_audio_run_state()
if persisted.get("enabled"):
dev = coerce_audio_device(persisted.get("device"))
audio_detector.start(device=dev)
print("[startup] audio beat detector started from saved run state")
except Exception as e:
print(f"[startup] audio auto-start skipped: {e!r}")
from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
from util import sequence_playback as seq_pb
seq_pb.ensure_beat_consumer_started()
# Initialize sessions with a secret key from settings # 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')
@@ -279,17 +309,125 @@ async def main(port=80):
tcp_client_registry.set_settings(settings) tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status) tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
live_reload = _live_reload_enabled()
dev_build_id = secrets.token_hex(12) if live_reload else None
if live_reload:
print(
"[dev] LED_CONTROLLER_LIVE_RELOAD: browser refreshes when the server process restarts"
)
if dev_build_id:
@app.route("/__dev/build-id")
def dev_build_id_route(request):
_ = request
return (
dev_build_id,
200,
{
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
)
# Serve index.html at root (cwd is src/ when run via pipenv run run) # Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/') @app.route("/")
def index(request): def index(request):
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('templates/index.html') if dev_build_id:
try:
with open("templates/index.html", encoding="utf-8") as f:
html = f.read()
tag = '<script src="/static/dev-live-reload.js" defer></script>'
if "</body>" in html:
html = html.replace("</body>", tag + "\n</body>", 1)
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
except OSError:
pass
return send_file("templates/index.html")
# Favicon: avoid 404 in browser console (no file needed) # 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)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=True, 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()
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
from util import beat_driver_route
from util import sequence_playback
st = audio_detector.status()
st["sequence"] = sequence_playback.playback_status()
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
seq = st.get("sequence")
beat_readout = ""
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
beat_readout = str(seq.get("beat_readout") or "").strip()
elif st.get("running"):
mb = st.get("manual_beat_stride")
if isinstance(mb, dict) and mb.get("active"):
try:
n = int(mb.get("stride_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
try:
bi = int(mb.get("beat_in_stride") or 1)
except (TypeError, ValueError):
bi = 1
pos = min(n, max(1, bi))
beat_readout = f"{pos}/{n}"
else:
try:
bs = int(st.get("beat_seq") or 0)
except (TypeError, ValueError):
bs = 0
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
return {"status": st}
# 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):
@@ -348,6 +486,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:
@@ -383,6 +525,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,71 @@
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.
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
profile is active (still one global record in ``group.json``).
"""
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 +79,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

@@ -15,6 +15,9 @@ class Preset(Model):
if default_profile_id is not None: if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id) preset_data["profile_id"] = str(default_profile_id)
changed = True changed = True
if isinstance(preset_data, dict) and "group_ids" in preset_data:
preset_data.pop("group_ids", None)
changed = True
if changed: if changed:
self.save() self.save()
except Exception: except Exception:
@@ -26,6 +29,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 +40,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()

159
src/models/sequence.py Normal file
View File

@@ -0,0 +1,159 @@
from models.model import Model
class Sequence(Model):
def load(self):
super().load()
self._migrate_after_load()
def _migrate_after_load(self):
try:
from models.profile import Profile
profiles = Profile()
profile_list = profiles.list()
default_profile_id = profile_list[0] if profile_list else None
except Exception:
default_profile_id = None
changed = False
for _sid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if not isinstance(doc.get("steps"), list):
presets = doc.get("presets")
if isinstance(presets, list) and presets:
doc["steps"] = [
{"preset_id": str(p), "group_ids": []} for p in presets
]
else:
doc["steps"] = []
changed = True
if "step_duration_ms" not in doc:
dur = doc.get("sequence_duration")
doc["step_duration_ms"] = (
int(dur) if isinstance(dur, (int, float)) else 3000
)
changed = True
if "loop" not in doc:
doc["loop"] = bool(doc.get("sequence_loop", False))
changed = True
if "name" not in doc:
doc["name"] = str(doc.get("group_name") or "")
changed = True
if "profile_id" not in doc and default_profile_id is not None:
doc["profile_id"] = str(default_profile_id)
changed = True
if not isinstance(doc.get("lanes"), list):
steps = doc.get("steps")
if isinstance(steps, list) and steps:
doc["lanes"] = [list(steps)]
else:
doc["lanes"] = [[]]
changed = True
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
doc["group_ids"] = []
changed = True
if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "beats"
changed = True
if "simulated_bpm" not in doc:
doc["simulated_bpm"] = 120
changed = True
else:
try:
sb = int(float(doc["simulated_bpm"]))
doc["simulated_bpm"] = max(30, min(300, sb))
except (TypeError, ValueError):
doc["simulated_bpm"] = 120
changed = True
if "sequence_transition" not in doc:
doc["sequence_transition"] = 500
changed = True
# Ensure each step has beats (beat-based advance); default 1
for lane in doc.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
if "beats" not in step:
step["beats"] = 1
changed = True
# Per-lane group ids (parallel to ``lanes``)
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
n_lanes = len(lanes_list)
lg = doc.get("lanes_group_ids")
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
if n_lanes == 1 and lanes_list[0]:
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
step_g = (
first.get("group_ids")
if isinstance(first.get("group_ids"), list)
else []
)
step_s = [
str(x).strip() for x in step_g if x is not None and str(x).strip()
]
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
else:
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
changed = True
if changed:
self.save()
def create(self, profile_id=None):
next_id = self.get_next_id()
self[next_id] = {
"name": "",
"profile_id": str(profile_id) if profile_id is not None else None,
"group_ids": [],
"lanes": [[]],
"lanes_group_ids": [[]],
"advance_mode": "beats",
"steps": [],
"step_duration_ms": 3000,
"simulated_bpm": 120,
"sequence_transition": 500,
"loop": True,
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
if not isinstance(data, dict):
return False
data = dict(data)
steps = data.get("steps")
lanes = data.get("lanes")
if isinstance(steps, list) and steps:
lanes_ok = (
isinstance(lanes, list)
and lanes
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
)
if not lanes_ok:
data["lanes"] = [list(steps)]
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

@@ -1,44 +0,0 @@
from models.model import Model
class Sequence(Model):
def __init__(self):
super().__init__()
def create(self, group_name="", preset_names=None):
next_id = self.get_next_id()
self[next_id] = {
"group_name": group_name,
"presets": preset_names if preset_names else [],
"sequence_duration": 3000, # Duration per preset in ms
"sequence_transition": 500, # Transition time in ms
"sequence_loop": False,
"sequence_repeat_count": 0, # 0 = infinite
"sequence_active": False,
"sequence_index": 0,
"sequence_start_time": 0
}
self.save()
return next_id
def read(self, id):
id_str = str(id)
return self.get(id_str, None)
def update(self, id, data):
id_str = str(id)
if id_str not in self:
return False
self[id_str].update(data)
self.save()
return True
def delete(self, id):
id_str = str(id)
if id_str not in self:
return False
self.pop(id_str)
self.save()
return True
def list(self):
return list(self.keys())

View File

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

@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
class Zone(Model): class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.""" """Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
"""
def __init__(self): def __init__(self):
if not getattr(Zone, "_migration_checked", False): if not getattr(Zone, "_migration_checked", False):
@@ -27,15 +31,57 @@ 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 "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if changed:
self.save()
@staticmethod
def _normalized_content_kind(doc):
if not isinstance(doc, dict):
return None
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
def _enforce_content_kind_invariants(self, doc):
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
kind = self._normalized_content_kind(doc)
if kind == "presets":
doc["sequence_ids"] = []
elif kind == "sequences":
doc["presets"] = []
doc["presets_flat"] = []
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
next_id = self.get_next_id() next_id = self.get_next_id()
self[next_id] = { gid_list = []
if isinstance(group_ids, list):
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
doc = {
"name": name, "name": name,
"names": names if names else [], "names": names if names else [],
"group_ids": gid_list,
"preset_group_ids": {},
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255, "brightness": 255,
} }
if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind
if "sequence_ids" not in doc:
doc["sequence_ids"] = []
self._enforce_content_kind_invariants(doc)
self[next_id] = doc
self.save() self.save()
return next_id return next_id
@@ -47,7 +93,9 @@ class Zone(Model):
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
self[id_str].update(data) patch = data if isinstance(data, dict) else {}
self[id_str].update(patch)
self._enforce_content_kind_invariants(self[id_str])
self.save() self.save()
return True return True

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,6 +70,9 @@ 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

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

@@ -0,0 +1,477 @@
(() => {
let pollTimer = null;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
* After sequence playback ends/stops while audio keeps running, keep header # idle until the
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
let lastBeatConsoleKey = "";
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
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);
}
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = String((status && status.beat_readout) || "").trim();
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
/**
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
* same `beat_seq` + line).
* @param {Record<string, unknown>} status
*/
function logServerBeatConsoleOnPollEdge(status) {
const beatSeq = Number((status && status.beat_seq) || 0);
const line = String((status && status.beat_readout) || "").trim();
const key = `${beatSeq}\t${line}`;
if (key !== lastBeatConsoleKey) {
lastBeatConsoleKey = key;
if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats = !!seq && !!seq.active;
let out = line;
if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes);
const lanesNote =
Number.isFinite(nLanes) && nLanes > 1
? `lane 1 of ${nLanes} (readout is for this lane only)`
: "lane 1";
out = `${line}${lanesNote}`;
}
console.log(out);
}
}
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) : "--";
}
}
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
function sequencePlaybackActiveFromStatus(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
return !!(seq && seq.active);
}
/** Build sequence beat fractions for debug logging (browser console only). */
function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null;
const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
if (
!Number.isFinite(laneBeatAt) ||
laneBeatAt <= 0 ||
!Number.isFinite(laneBeatsPerStep) ||
laneBeatsPerStep <= 0
) {
return null;
}
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
const sequenceBeatAt = Number(seq.sequence_beat_at);
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
if (
!Number.isFinite(sequenceBeatAt) ||
sequenceBeatAt <= 0 ||
!Number.isFinite(sequenceBeatsPerPass) ||
sequenceBeatsPerPass <= 0
) {
return null;
}
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
return `${presetFraction} ${sequenceFraction}`;
}
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);
}
}
function clearBeatPhaseTimers() {
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
pendingBeatPhaseTimers.clear();
}
function getBeatPhaseDelayMs() {
const inp = el("audio-beat-phase-ms");
if (inp && String(inp.value).trim() !== "") {
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
}
}
function persistBeatPhaseMs() {
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
} catch (e) {
console.warn("beat phase ms save failed", e);
}
}
function scheduleBeatPhaseFire(seq, delayMs) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat();
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
);
} catch (e) {
/* ignore */
}
};
if (delayMs <= 0) {
run();
return;
}
tid = setTimeout(run, delayMs);
pendingBeatPhaseTimers.add(tid);
}
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
setTopBpmVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
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", { cache: "no-store" });
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);
}
updateBeatReadoutDisplays({});
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));
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
}
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
}
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
}
const beatFractions = formatSequenceBeatFractionsForLog(status);
if (beatFractions) {
if (beatFractions !== lastLoggedSequenceBeatFractions) {
lastLoggedSequenceBeatFractions = beatFractions;
}
} else {
lastLoggedSequenceBeatFractions = "";
}
updateBeatReadoutDisplays(status);
} 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);
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);
}
});
}
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
}
}
async function resumePollingIfDetectorRunning() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
}
} catch (e) {
console.warn("audio resume poll check failed", e);
}
}
/**
* Apply browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
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 device list refresh failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
}
document.addEventListener("DOMContentLoaded", async () => {
bind();
await resumePollingIfDetectorRunning();
await applySavedAudioDeviceFormOnly();
});
})();

48
src/static/bundle_io.js Normal file
View File

@@ -0,0 +1,48 @@
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
window.downloadJsonFile = function downloadJsonFile(filename, data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'bundle.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
window.pickJsonFile = function pickJsonFile() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
input.remove();
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
};
window.parseJsonFileText = function parseJsonFileText(text) {
if (text == null || text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
return null;
}
};

View File

@@ -0,0 +1,25 @@
/* Polls server build id; full reload when watchfiles restarts Python (new process = new id). */
(function () {
var prev = null;
function tick() {
fetch('/__dev/build-id', { cache: 'no-store', credentials: 'same-origin' })
.then(function (r) {
return r.ok ? r.text() : '';
})
.then(function (id) {
id = (id || '').trim();
if (!id) return;
if (prev === null) {
prev = id;
return;
}
if (id !== prev) {
prev = id;
window.location.reload();
}
})
.catch(function () {});
}
setInterval(tick, 750);
tick();
})();

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 res = await fetch(`/devices/${encodeURIComponent(devId)}`, { const payload = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name, name,
type: type || 'led', type: type || 'led',
transport: transport || 'espnow', transport: transport || 'espnow',
address, address,
}), };
if (typeof outputBrightness === 'number') {
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
}
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
if (wifiDriverFields.wifi_driver_display_name != null) {
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
}
if (wifiDriverFields.wifi_driver_num_leds != null) {
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
}
if (wifiDriverFields.wifi_color_order != null) {
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
}
if (wifiDriverFields.wifi_startup_mode != null) {
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
}
}
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}); });
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) {

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

@@ -0,0 +1,571 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
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 syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
const raw = g && (g.profile_id != null ? g.profile_id : g.profileId);
const scoped = raw != null && String(raw).trim() !== '';
cb.checked = !scoped;
}
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)}`, {
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
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);
syncGroupShareCheckboxFromDoc(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'})`;
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
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 identifyBtn = document.createElement('button');
identifyBtn.className = 'btn btn-secondary btn-small';
identifyBtn.type = 'button';
identifyBtn.textContent = 'Identify';
identifyBtn.title =
'Identify all devices in this group at once (red blink at 10 Hz)';
identifyBtn.addEventListener('click', async () => {
await identifyGroupById(gid);
});
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',
credentials: 'same-origin',
});
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');
}
});
const left = document.createElement('div');
left.style.flex = '1';
left.style.minWidth = '0';
left.appendChild(label);
left.appendChild(meta);
row.appendChild(left);
row.appendChild(editBtn);
row.appendChild(brightBtn);
row.appendChild(applyBtn);
row.appendChild(identifyBtn);
row.appendChild(delBtn);
container.appendChild(row);
});
}
async function identifyGroupById(gid) {
if (!gid) return;
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/identify`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Identify failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
const errs = Array.isArray(data.errors) ? data.errors : [];
const failed = errs.filter((e) => e && e.error).length;
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
if (failed) {
msg += ` ${failed} failed — see console for details.`;
console.warn('Group identify errors', errs);
}
alert(msg);
} catch (e) {
console.error(e);
alert('Identify failed');
}
}
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 editIdentifyBtn = document.getElementById('edit-group-identify-btn');
if (editIdentifyBtn) {
editIdentifyBtn.addEventListener('click', async () => {
const idInput = document.getElementById('edit-group-id');
const gid = idInput && idInput.value;
if (!gid) return;
await identifyGroupById(gid);
});
}
const createHandler = async () => {
const name = newNameInput && newNameInput.value.trim();
if (!name) return;
const profileOnly = document.getElementById('new-group-profile-only');
try {
const res = await fetch('/groups', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
name,
profile_scoped: !!(profileOnly && profileOnly.checked),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Create failed');
return;
}
if (newNameInput) newNameInput.value = '';
if (profileOnly) profileOnly.checked = false;
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;
const shareCb = document.getElementById('edit-group-share-all-profiles');
if (shareCb && shareCb.checked) {
payload.profile_id = null;
} else {
const pid = await getCurrentProfileIdForGroups();
payload.profile_id = pid || null;
}
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}`, {
method: 'PUT',
credentials: 'same-origin',
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'));
}
window.openDeviceGroupsModal = async () => {
const gm = document.getElementById('groups-modal');
if (!gm) return;
gm.classList.add('active');
try {
await loadGroupsModal();
} catch (e) {
console.error('openDeviceGroupsModal', e);
}
};
});

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,13 +566,19 @@ 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),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: (() => {
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode);
}
return coercePresetInt(preset.n6);
})(),
}; };
}); });
if (!Object.keys(wirePresets).length) { if (!Object.keys(wirePresets).length) {

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
const newProfileInput = document.getElementById("new-profile-name"); const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj"); const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn"); const createProfileButton = document.getElementById("create-profile-btn");
const importProfileButton = document.getElementById("import-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) { if (!profilesButton || !profilesModal || !profilesList) {
return; return;
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
const exportButton = document.createElement("button");
exportButton.className = "btn btn-secondary btn-small";
exportButton.textContent = "Export";
exportButton.addEventListener("click", async () => {
try {
const response = await fetch(`/profiles/${profileId}/export`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Export failed");
}
const bundle = await response.json();
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
} catch (error) {
console.error("Export profile failed:", error);
alert("Failed to export profile.");
}
});
const cloneButton = document.createElement("button"); const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small"; cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone"; cloneButton.textContent = "Clone";
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
if (editMode) { if (editMode) {
row.appendChild(exportButton);
row.appendChild(cloneButton); row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
} }
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
if (createProfileButton) { if (createProfileButton) {
createProfileButton.addEventListener("click", createProfile); createProfileButton.addEventListener("click", createProfile);
} }
const importProfile = async () => {
if (!isEditModeActive()) {
return;
}
const text = await window.pickJsonFile();
if (!text) {
return;
}
const bundle = window.parseJsonFileText(text);
if (!bundle || typeof bundle !== "object") {
alert("Invalid JSON file.");
return;
}
const defaultName =
(bundle.profile && bundle.profile.name) || "Imported profile";
const name = prompt("Profile name for import:", defaultName);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch("/profiles/import", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || "Import failed");
}
const data = await response.json();
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
await loadProfiles();
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Import profile failed:", error);
alert(error.message || "Failed to import profile.");
}
};
if (importProfileButton) {
importProfileButton.addEventListener("click", importProfile);
}
if (newProfileInput) { if (newProfileInput) {
newProfileInput.addEventListener("keypress", (event) => { newProfileInput.addEventListener("keypress", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {

1220
src/static/sequences.js Normal file

File diff suppressed because it is too large Load Diff

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,86 @@ header h1 {
width: 8.5rem; width: 8.5rem;
} }
.audio-top-indicator {
display: none;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
min-width: 9rem;
}
.audio-top-indicator-main {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.audio-top-indicator-extra {
font-size: 0.62rem;
color: #9e9e9e;
line-height: 1.25;
text-align: right;
max-width: 16rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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-beat-readout {
font-size: 0.62rem;
color: #b0bec5;
line-height: 1.25;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
text-align: left;
}
.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,
.audio-top-indicator.flash .audio-top-indicator-extra,
.audio-top-indicator.flash .audio-top-beat-readout {
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 +333,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;
@@ -503,6 +598,39 @@ body.preset-ui-run .edit-mode-only {
font-weight: 500; font-weight: 500;
} }
.preset-mode-field {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.preset-mode-field label {
display: block;
font-weight: 500;
margin-bottom: 0.35rem;
}
.preset-mode-input {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background-color: #3a3a3a;
color: #fff;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.preset-mode-input:focus {
outline: none;
border-color: #6a9fff;
}
#preset-editor-modal .preset-mode-field {
grid-column: 1 / -1;
}
.n-input { .n-input {
flex: 0 0 var(--n-input-width, 5ch); flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch); width: var(--n-input-width, 5ch);
@@ -558,7 +686,8 @@ body.preset-ui-run .edit-mode-only {
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; /* min-content height prevents taller tiles (edit actions, wrapping) from overlapping the next row and stealing clicks */
grid-auto-rows: minmax(5rem, auto);
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
align-content: start; align-content: start;
@@ -710,6 +839,66 @@ 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-bpm-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.audio-bpm-row .audio-bpm-readout {
flex: 0 0 auto;
min-width: 5rem;
}
.audio-modal-beat-readout {
flex: 1;
min-width: 10rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: left;
}
.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;
@@ -749,17 +938,43 @@ body.preset-ui-run .edit-mode-only {
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
min-width: 0; min-width: 0;
min-height: 0; min-height: 5rem;
} }
.preset-tile-row--run .preset-tile-actions { .preset-tile-row-top {
display: none; display: flex;
flex-direction: row;
align-items: stretch;
flex: 1;
min-width: 0;
min-height: 5rem;
} }
.preset-tile-main { .preset-tile-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 5rem; height: 5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.12rem;
}
.preset-tile-main .preset-tile-groups {
font-size: 0.68rem;
font-weight: 500;
line-height: 1.15;
opacity: 0.88;
text-align: center;
max-width: 100%;
padding: 0 0.35rem;
box-sizing: border-box;
word-break: break-word;
}
.preset-tile-row--run .preset-tile-actions {
display: none;
} }
/* Edit only beside the preset tile in edit mode. */ /* Edit only beside the preset tile in edit mode. */
@@ -928,6 +1143,46 @@ body.preset-ui-run .edit-mode-only {
display: flex; display: flex;
} }
/* Stack sequence modals below groups / preset editor so in-modal actions stay visible */
#sequence-editor-modal.active,
#sequences-modal.active {
z-index: 1040;
}
#groups-modal.active,
#edit-group-modal.active,
#presets-modal.active {
z-index: 1050;
}
#preset-editor-modal.active {
z-index: 1060;
}
/* Child / overlay modals: must paint above preset editor (1060) and list modals (1050). */
#color-palette-modal.active,
#pattern-editor-modal.active,
#edit-device-modal.active,
#edit-zone-modal.active {
z-index: 1070;
}
/* Patterns library (often used next to presets); below preset editor, above sequences. */
#patterns-modal.active {
z-index: 1055;
}
/* Header / global dialogs */
#help-modal.active,
#audio-modal.active,
#settings-modal.active,
#led-tool-modal.active {
z-index: 1080;
}
/* JS-appended overlays (e.g. preset “From Palette”, add-preset-to-zone) — must sit above #preset-editor-modal */
.modal.modal-child-overlay.active {
z-index: 1080;
}
.modal-content { .modal-content {
background-color: #2e2e2e; background-color: #2e2e2e;
padding: 2rem; padding: 2rem;
@@ -992,20 +1247,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 +1287,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 {
@@ -1142,6 +1416,22 @@ body.preset-ui-run .edit-mode-only {
min-width: 8rem; min-width: 8rem;
} }
.zone-content-kind-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
margin: 0.35rem 0 0.75rem;
}
.zone-content-kind-row label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
white-space: nowrap;
}
.zone-devices-label { .zone-devices-label {
display: block; display: block;
margin-top: 0.75rem; margin-top: 0.75rem;
@@ -1379,6 +1669,14 @@ body.preset-ui-run .edit-mode-only {
} }
} }
.sequence-step-drag-handle:active {
cursor: grabbing;
}
.sequence-step-row.dragging {
opacity: 0.65;
}
/* Settings modal */ /* Settings modal */
#settings-modal .modal-content { #settings-modal .modal-content {
max-width: 900px; max-width: 900px;

View File

@@ -1,6 +1,12 @@
// Zone management JavaScript // Zone management JavaScript
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null; let brightnessSendTimeout = null;
/**
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
*/
let suppressZoneContentDriverSideEffects = false;
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
let isFirstZoneContentHydration = true;
function clamp255(n) { function clamp255(n) {
const v = parseInt(n, 10); const v = parseInt(n, 10);
@@ -64,6 +70,47 @@ function sendZoneBrightness(zoneId, 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;
} }
@@ -107,8 +154,225 @@ async function fetchDevicesMap() {
} }
} }
async function fetchGroupsMap() {
try {
const response = await fetch("/groups", {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
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 ``names`` only).
*/
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))],
};
}
/** Tab device list for sequences: zone ``group_ids`` first, else legacy ``names`` only. */
async function computeZoneNamesTargets(zone) {
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 t = await resolveTargetsFromGroupIds(gids);
return {
names: Array.isArray(t.names) ? t.names : [],
macs: Array.isArray(t.macs) ? [...new Set(t.macs.filter(Boolean))] : [],
};
}
const dm = await fetchDevicesMap();
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))],
};
}
function normalizeDeviceMac(raw) {
return String(raw || "")
.trim()
.toLowerCase()
.replace(/:/g, "")
.replace(/-/g, "");
}
/** Flat preset ids on a zone document (grid or flat). */
function tabPresetIdsInZoneDoc(zoneDoc) {
let ids = [];
if (Array.isArray(zoneDoc && zoneDoc.presets_flat)) {
ids = zoneDoc.presets_flat.slice();
} else if (Array.isArray(zoneDoc && zoneDoc.presets)) {
if (zoneDoc.presets.length && typeof zoneDoc.presets[0] === "string") {
ids = zoneDoc.presets.slice();
} else if (zoneDoc.presets.length && Array.isArray(zoneDoc.presets[0])) {
ids = zoneDoc.presets.flat();
}
}
return (ids || []).filter(Boolean);
}
/** Group ids used for standalone presets on this zone: zone ``group_ids`` only. */
function effectiveGroupIdsForZonePreset(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
}
/** Resolve device names + MACs from a list of group ids (same rules as zone group expansion). */
async function resolveTargetsFromGroupIds(groupIds) {
const dm = await fetchDevicesMap();
const gids = Array.isArray(groupIds)
? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return { names: [], macs: [] };
}
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 = normalizeDeviceMac(raw);
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 };
}
/** Device names for standalone presets: zone ``group_ids``, or all devices on the tab (``names``). */
async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
void presetId;
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
if (gids.length) {
const t = await resolveTargetsFromGroupIds(gids);
if (t.names.length) return t.names;
}
const zt = await computeZoneTargets(zoneDoc);
return Array.isArray(zt.names) ? zt.names.slice() : [];
}
/** Union of devices targeted by standalone presets on the zone (same as zone preset targeting). */
async function computeZonePresetUnionTargets(zoneDoc) {
return await computeZoneTargets(zoneDoc);
}
/**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
*/
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone);
const names = Array.isArray(zoneT.names) ? zoneT.names : [];
const macs = Array.isArray(zoneT.macs) ? zoneT.macs : [];
const gids = Array.isArray(stepGroupIds)
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return names.slice();
}
const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
);
const zoneNameByMac = new Map();
for (let i = 0; i < macs.length; i++) {
const m = normalizeDeviceMac(macs[i]);
if (m.length === 12 && !zoneNameByMac.has(m)) {
zoneNameByMac.set(m, names[i] || m);
}
}
const gm = await fetchGroupsMap();
const stepMacs = new Set();
for (const gid of gids) {
const g = gm[gid];
if (!g || !Array.isArray(g.devices)) continue;
for (const raw of g.devices) {
const m = normalizeDeviceMac(raw);
if (m.length !== 12 || !zoneMacSet.has(m)) continue;
stepMacs.add(m);
}
}
const out = [];
for (const m of stepMacs) {
const n = zoneNameByMac.get(m);
if (n) out.push(n);
}
return out;
}
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);
@@ -136,10 +400,10 @@ function rowsToNames(rows) {
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0); return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
} }
function renderZoneDevicesEditor(containerEl, rows, devicesMap) { function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return; if (!containerEl) return;
containerEl.innerHTML = ""; containerEl.innerHTML = "";
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b)); const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => { rows.forEach((row, idx) => {
const div = document.createElement("div"); const div = document.createElement("div");
@@ -147,12 +411,12 @@ function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "zone-device-row-label"; label.className = "zone-device-row-label";
const strong = document.createElement("strong"); const strong = document.createElement("strong");
strong.textContent = row.name || "—"; strong.textContent = row.name || row.id || "—";
label.appendChild(strong); label.appendChild(strong);
label.appendChild(document.createTextNode(" ")); label.appendChild(document.createTextNode(" "));
const sub = document.createElement("span"); const sub = document.createElement("span");
sub.className = "muted-text"; sub.className = "muted-text";
sub.textContent = row.mac ? row.mac : "(not in registry)"; sub.textContent = `group ${row.id}`;
label.appendChild(sub); label.appendChild(sub);
const rm = document.createElement("button"); const rm = document.createElement("button");
@@ -161,53 +425,42 @@ function renderZoneDevicesEditor(containerEl, rows, devicesMap) {
rm.textContent = "Remove"; rm.textContent = "Remove";
rm.addEventListener("click", () => { rm.addEventListener("click", () => {
rows.splice(idx, 1); rows.splice(idx, 1);
renderZoneDevicesEditor(containerEl, rows, devicesMap); renderZoneGroupsEditor(containerEl, rows, groupsMap);
}); });
div.appendChild(label); div.appendChild(label);
div.appendChild(rm); div.appendChild(rm);
containerEl.appendChild(div); containerEl.appendChild(div);
}); });
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean)); const idsInRows = new Set(rows.map((r) => String(r.id)));
const addWrap = document.createElement("div"); const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions"; addWrap.className = "zone-devices-add profiles-actions";
const sel = document.createElement("select"); const sel = document.createElement("select");
sel.className = "zone-device-add-select"; sel.className = "zone-device-add-select";
sel.appendChild(new Option("Add device…", "")); sel.appendChild(new Option("Add group…", ""));
entries.forEach(([mac, d]) => { entries.forEach(([gid, g]) => {
if (macsInRows.has(mac)) return; if (idsInRows.has(gid)) return;
const labelName = d && d.name ? String(d.name).trim() : ""; const gn = g && g.name ? String(g.name).trim() : "";
const optLabel = labelName ? `${labelName} ${mac}` : mac; const optLabel = gn ? `${gn} (${gid})` : `Group ${gid}`;
sel.appendChild(new Option(optLabel, mac)); sel.appendChild(new Option(optLabel, gid));
}); });
const addBtn = document.createElement("button"); const addBtn = document.createElement("button");
addBtn.type = "button"; addBtn.type = "button";
addBtn.className = "btn btn-primary btn-small"; addBtn.className = "btn btn-primary btn-small";
addBtn.textContent = "Add"; addBtn.textContent = "Add";
addBtn.addEventListener("click", () => { addBtn.addEventListener("click", () => {
const mac = sel.value; const gid = sel.value;
if (!mac || !devicesMap[mac]) return; if (!gid || !groupsMap[gid]) return;
const n = String((devicesMap[mac].name || "").trim() || mac); const gn = groupsMap[gid].name ? String(groupsMap[gid].name).trim() : gid;
rows.push({ mac, name: n }); rows.push({ id: gid, name: gn });
sel.value = ""; sel.value = "";
renderZoneDevicesEditor(containerEl, rows, devicesMap); renderZoneGroupsEditor(containerEl, rows, groupsMap);
}); });
addWrap.appendChild(sel); addWrap.appendChild(sel);
addWrap.appendChild(addBtn); addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap); containerEl.appendChild(addWrap);
} }
/** Default device name list when creating a zone (refined in Edit zone). */
async function defaultDeviceNamesForNewTab() {
const dm = await fetchDevicesMap();
const macs = Object.keys(dm);
if (macs.length > 0) {
const m0 = macs[0];
return [String((dm[m0].name || "").trim() || m0)];
}
return ["1"];
}
/** 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). */
function parseTabDeviceNames(section) { function parseTabDeviceNames(section) {
if (!section) return []; if (!section) return [];
@@ -237,6 +490,67 @@ function escapeHtmlAttr(s) {
.replace(/</g, "&lt;"); .replace(/</g, "&lt;");
} }
/** @returns {null | 'presets' | 'sequences'} */
function normalizeZoneContentKind(zoneDoc) {
const k = zoneDoc && zoneDoc.content_kind;
if (k === 'presets' || k === 'sequences') return k;
return null;
}
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
function effectiveZoneContentKind(zoneDoc) {
const explicit = normalizeZoneContentKind(zoneDoc);
if (explicit) return explicit;
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
? zoneDoc.sequence_ids.filter(Boolean)
: [];
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
return 'presets';
}
/** @returns {'presets' | 'sequences'} */
function editModalContentKindSelected() {
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
}
function activeZoneContentKind(zoneDoc) {
const modal = document.getElementById('edit-zone-modal');
if (modal && modal.classList.contains('active')) {
return editModalContentKindSelected();
}
return effectiveZoneContentKind(zoneDoc);
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'sequences';
}
function applyZoneContentKindEditModal(kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups');
const seqBlock = document.getElementById('edit-zone-block-sequences');
const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none';
};
const k = kind === 'sequences' ? 'sequences' : 'presets';
vis(groupsBlock, true);
vis(presetsBlock, k === 'presets');
vis(seqBlock, k === 'sequences');
}
window.normalizeZoneContentKind = normalizeZoneContentKind;
window.effectiveZoneContentKind = effectiveZoneContentKind;
window.zoneAllowsPresets = zoneAllowsPresets;
window.zoneAllowsSequences = zoneAllowsSequences;
// Load tabs list // Load tabs list
async function loadZones() { async function loadZones() {
try { try {
@@ -294,13 +608,13 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = zoneId === currentZoneId ? 'active' : '';
const tabName = zone.name || `Zone ${zoneId}`; const disp = zone.name || `Zone ${zoneId}`;
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
data-zone-id="${zoneId}" data-zone-id="${zoneId}"
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}" title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
onclick="selectZone('${zoneId}')"> onclick="selectZone('${zoneId}')">
${tabName} ${escapeHtmlAttr(disp)}
</button> </button>
`; `;
} }
@@ -340,9 +654,10 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId); row.dataset.zoneId = String(zoneId);
const label = document.createElement("span"); const label = document.createElement("span");
label.textContent = (zone && zone.name) || zoneId; const disp = zone.name || `Zone ${zoneId}`;
label.textContent = disp;
if (String(zoneId) === String(currentZoneId)) { if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${label.textContent}`; label.textContent = `${disp}`;
label.style.fontWeight = "bold"; label.style.fontWeight = "bold";
label.style.color = "#FFD700"; label.style.color = "#FFD700";
} }
@@ -539,12 +854,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>
@@ -560,8 +879,14 @@ async function loadZoneContent(zoneId) {
? Math.max(0, Math.min(255, Math.round(zoneBrightness))) ? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255; : 255;
applyBrightnessSliders(normalizedBrightness); applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones. const initialHydration = isFirstZoneContentHydration;
if (isFirstZoneContentHydration) {
isFirstZoneContentHydration = false;
}
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
sendZoneBrightness(zoneId, normalizedBrightness); 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') {
@@ -639,8 +964,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;
@@ -683,17 +1007,7 @@ async function sendProfilePresets() {
} }
function tabPresetIdsInOrder(tabData) { function tabPresetIdsInOrder(tabData) {
let ids = []; return tabPresetIdsInZoneDoc(tabData);
if (Array.isArray(tabData.presets_flat)) {
ids = tabData.presets_flat.slice();
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
ids = tabData.presets.slice();
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
ids = tabData.presets.flat();
}
}
return (ids || []).filter(Boolean);
} }
// Presets already on the zone (remove) and presets available to add (select). // Presets already on the zone (remove) and presets available to add (select).
@@ -714,6 +1028,12 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
if (!zoneAllowsPresets(tabData)) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
return;
}
const inTabIds = tabPresetIdsInOrder(tabData); const inTabIds = tabPresetIdsInOrder(tabData);
const inTabSet = new Set(inTabIds.map((id) => String(id))); const inTabSet = new Set(inTabIds.map((id) => String(id)));
@@ -737,8 +1057,12 @@ async function refreshEditTabPresetsUi(zoneId) {
for (const presetId of inTabIds) { for (const presetId of inTabIds) {
const preset = allPresets[presetId] || {}; const preset = allPresets[presetId] || {};
const name = preset.name || presetId; const name = preset.name || presetId;
const row = makeRow(); const block = document.createElement("div");
block.style.cssText =
"border:1px solid rgba(255,255,255,0.12);border-radius:6px;padding:0.5rem 0.65rem;margin-bottom:0.65rem;";
const top = makeRow();
const label = document.createElement("span"); const label = document.createElement("span");
label.style.fontWeight = "600";
label.textContent = name; label.textContent = name;
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
@@ -750,9 +1074,11 @@ async function refreshEditTabPresetsUi(zoneId) {
await window.removePresetFromTab(zoneId, presetId); await window.removePresetFromTab(zoneId, presetId);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
}); });
row.appendChild(label); top.appendChild(label);
row.appendChild(removeBtn); top.appendChild(removeBtn);
currentEl.appendChild(row); block.appendChild(top);
currentEl.appendChild(block);
} }
} }
@@ -813,7 +1139,6 @@ async function openEditZoneModal(zoneId, zone) {
const modal = document.getElementById("edit-zone-modal"); const modal = document.getElementById("edit-zone-modal");
const idInput = document.getElementById("edit-zone-id"); const idInput = document.getElementById("edit-zone-id");
const nameInput = document.getElementById("edit-zone-name"); const nameInput = document.getElementById("edit-zone-name");
const editor = document.getElementById("edit-zone-devices-editor");
let tabData = zone; let tabData = zone;
if (!tabData || typeof tabData !== "object" || tabData.error) { if (!tabData || typeof tabData !== "object" || tabData.error) {
@@ -831,31 +1156,37 @@ 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 groupsEditor = document.getElementById("edit-zone-groups-editor");
const zoneNames = const groupsMap = await fetchGroupsMap();
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"]; const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap); window.__editTabGroupRows = rawGids.map((gid) => {
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap); const id = String(gid);
const g = groupsMap[id];
return { id, name: g && g.name ? String(g.name).trim() : id };
});
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.checked = radio.value === kind;
});
if (modal) modal.classList.add("active"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId);
}
} }
function normalizeTabNamesArg(namesOrString) { // Update an existing zone (name, group list; devices come from groups only).
if (Array.isArray(namesOrString)) { async function updateZone(zoneId, name, groupRows, contentKind) {
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
async function updateZone(zoneId, name, namesOrString) {
try { try {
let names = normalizeTabNamesArg(namesOrString); const gids = Array.isArray(groupRows)
if (!names.length) names = ["1"]; ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch(`/zones/${zoneId}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
@@ -863,7 +1194,10 @@ async function updateZone(zoneId, name, namesOrString) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
names: names names: [],
group_ids: gids,
preset_group_ids: {},
content_kind: ck,
}) })
}); });
@@ -886,11 +1220,11 @@ async function updateZone(zoneId, name, namesOrString) {
} }
} }
// Create a new zone // Create a new zone (add devices in Edit zone). ``contentKind`` is ``'presets'`` | ``'sequences'``.
async function createZone(name, namesOrString) { async function createZone(name, contentKind) {
try { try {
let names = normalizeTabNamesArg(namesOrString); const ck =
if (!names.length) names = ["1"]; contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
const response = await fetch('/zones', { const response = await fetch('/zones', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -898,7 +1232,9 @@ async function createZone(name, namesOrString) {
}, },
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
names: names names: [],
group_ids: [],
content_kind: ck,
}) })
}); });
@@ -979,8 +1315,12 @@ document.addEventListener('DOMContentLoaded', () => {
const name = newTabNameInput.value.trim(); const name = newTabNameInput.value.trim();
if (name) { if (name) {
const deviceNames = await defaultDeviceNamesForNewTab(); const kindRadio = document.querySelector(
await createZone(name, deviceNames); 'input[name="new-zone-content-kind"]:checked',
);
const contentKind =
kindRadio && kindRadio.value === 'sequences' ? 'sequences' : 'presets';
await createZone(name, contentKind);
if (newTabNameInput) newTabNameInput.value = ""; if (newTabNameInput) newTabNameInput.value = "";
} }
}; };
@@ -997,6 +1337,18 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form // Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form'); const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) { if (editZoneForm) {
@@ -1007,15 +1359,10 @@ 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 groupRows = window.__editTabGroupRows || [];
const deviceNames = rowsToNames(rows);
if (zoneId && name) { if (zoneId && name) {
if (deviceNames.length === 0) { await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
alert("Add at least one device.");
return;
}
await updateZone(zoneId, name, deviceNames);
editZoneForm.reset(); editZoneForm.reset();
} }
}); });
@@ -1049,10 +1396,15 @@ document.addEventListener('DOMContentLoaded', () => {
// 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) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
suppressZoneContentDriverSideEffects = true;
try {
await loadZones(); await loadZones();
if (zonesModal && zonesModal.classList.contains("active")) { if (zonesModal && zonesModal.classList.contains("active")) {
await loadZonesModal(); await loadZonesModal();
} }
} finally {
suppressZoneContentDriverSideEffects = false;
}
}); });
}); });
}); });
@@ -1066,8 +1418,17 @@ window.zonesManager = {
updateZone, updateZone,
openEditZoneModal, openEditZoneModal,
resolveZoneDeviceMacs, resolveZoneDeviceMacs,
resolveZoneDeviceMacsFromZoneData,
resolveTabDeviceMacs: resolveZoneDeviceMacs, resolveTabDeviceMacs: resolveZoneDeviceMacs,
getCurrentZoneId: () => currentZoneId, getCurrentZoneId: () => currentZoneId,
computeZoneTargets,
computeZoneNamesTargets,
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,
}; };
window.tabsManager = window.zonesManager; window.tabsManager = window.zonesManager;
window.tabsManager.getCurrentTabId = () => currentZoneId; window.tabsManager.getCurrentTabId = () => currentZoneId;

View File

@@ -14,6 +14,14 @@
Loading zones... Loading zones...
</div> </div>
</div> </div>
<div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<div class="audio-top-indicator-main">
<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-readout" class="audio-top-beat-readout" aria-live="polite"></span>
</div>
</div>
<div class="header-actions"> <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>
@@ -21,12 +29,15 @@
</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="sequences-btn">Sequences</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>
@@ -40,15 +51,19 @@
</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="sequences-btn">Sequences</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>
<div class="main-content"> <div class="main-content">
@@ -68,6 +83,10 @@
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<div class="zone-content-kind-row muted-text">
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -81,18 +100,32 @@
<h2>Edit Zone</h2> <h2>Edit Zone</h2>
<form id="edit-zone-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-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" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</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> <div class="zone-content-kind-row muted-text">
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div> <label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
</div>
<div id="edit-zone-block-presets">
<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>
<label class="zone-presets-section-label">Add presets to this zone</label> <label class="zone-presets-section-label">Add presets to this zone</label>
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
<div id="edit-zone-block-sequences">
<label class="zone-presets-section-label">Sequences on this zone</label>
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
<label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -104,6 +137,7 @@
<div class="profiles-actions"> <div class="profiles-actions">
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
</div> </div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;"> <label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
@@ -129,6 +163,78 @@
</div> </div>
</div> </div>
<!-- Device groups: members + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal">
<div class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
<input type="checkbox" id="new-group-profile-only"> This profile only
</label>
<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">
<label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
</label>
<label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
</div>
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
<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>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<div id="edit-device-modal" class="modal"> <div id="edit-device-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit device</h2> <h2>Edit device</h2>
@@ -154,6 +260,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>
@@ -168,6 +305,7 @@
<h2>Presets</h2> <h2>Presets</h2>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button> <button class="btn btn-primary" id="preset-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button> <button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div> </div>
<div id="presets-list" class="profiles-list"></div> <div id="presets-list" class="profiles-list"></div>
@@ -177,6 +315,50 @@
</div> </div>
</div> </div>
<!-- Sequences Modal -->
<div id="sequences-modal" class="modal">
<div class="modal-content">
<h2>Sequences</h2>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div>
<div id="sequences-list" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
</div>
</div>
</div>
<!-- Sequence Editor Modal -->
<div id="sequence-editor-modal" class="modal">
<div class="modal-content">
<h2>Sequence</h2>
<div class="preset-editor-field">
<label for="sequence-editor-name">Name</label>
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
</div>
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
Each step runs for the number of <strong>beats</strong> you set on that step.
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
</p>
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
</div>
<div id="sequence-editor-lanes"></div>
<div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
</div>
</div>
</div>
<!-- Preset Editor Modal --> <!-- Preset Editor Modal -->
<div id="preset-editor-modal" class="modal"> <div id="preset-editor-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -202,6 +384,30 @@
<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>
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</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" autocomplete="off">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div>
</div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
<select id="preset-mode-input" class="preset-mode-input"></select>
</div> </div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
@@ -360,19 +566,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 +596,57 @@
</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 class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
</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="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
</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,14 +792,18 @@
</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>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/bundle_io.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/zone_palette.js"></script> <script src="/static/zone_palette.js"></script>
<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/sequences.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
</body> </body>
</html> </html>

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

@@ -0,0 +1,301 @@
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 import sequence_playback as seq_pb
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] sequence beat queue: {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
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
_shared_beat_detector = None
def set_shared_beat_detector(det):
global _shared_beat_detector
_shared_beat_detector = det
def shared_beat_detector_running():
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.status().get("running"))
except Exception:
return False

View File

@@ -0,0 +1,52 @@
"""Persist whether the audio beat detector should be running (survives process restarts)."""
from __future__ import annotations
import json
import os
from typing import Any, Dict, Optional
def _db_path() -> str:
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(base, "db", "audio_run.json")
def coerce_audio_device(device: Any) -> Optional[Any]:
"""Match ``/api/audio/start`` body coercion (None = host default input)."""
if device in ("", None):
return None
try:
return int(device)
except (TypeError, ValueError):
return device
def read_audio_run_state() -> Dict[str, Any]:
path = _db_path()
try:
with open(path, "r", encoding="utf-8") as f:
raw = json.load(f)
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
path = _db_path()
prev = read_audio_run_state()
if enabled:
data = {"enabled": True, "device": device}
else:
data = {"enabled": False, "device": prev.get("device")}
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except OSError as e:
print(f"[audio_run_persist] save failed: {e!r}")

View File

@@ -0,0 +1,578 @@
"""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, Tuple
_route_lock = threading.Lock()
# Per-lane manual routes: key ``-1`` = legacy single-route (preset push / UI); keys ``0..n`` =
# zone sequence lanes so every manual lane gets its own stride counter and wire.
_lane_manual: Dict[int, Dict[str, Any]] = {}
# Public mirror for ``get_beat_route`` / header UI (derived from lane table).
_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
_preset_session_beats: 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 _pick_display_lane_key() -> Optional[int]:
"""Lane key used for header stride readout (prefer sequence lane 0)."""
if not _lane_manual:
return None
if 0 in _lane_manual:
return 0
seq_keys = [k for k in _lane_manual if isinstance(k, int) and k >= 0]
if seq_keys:
return min(seq_keys)
if -1 in _lane_manual:
return -1
return min(_lane_manual.keys())
def _sync_public_beat_route_from_lane_table() -> None:
"""Mirror ``_lane_manual`` into legacy ``_beat_route`` shape for API consumers."""
global _beat_route, _beat_counter
pick = _pick_display_lane_key()
if pick is None:
_beat_route = {
"enabled": False,
"device_names": [],
"wire_preset_id": "2",
"is_manual": False,
"pattern": "",
"manual_beat_n": 1,
}
_beat_counter = 0
return
e = _lane_manual[pick]
_beat_route = {
"enabled": True,
"device_names": list(e.get("device_names") or []),
"wire_preset_id": str(e.get("wire_preset_id") or "2"),
"is_manual": True,
"pattern": str(e.get("pattern") or ""),
"manual_beat_n": int(e.get("manual_beat_n") or 1),
}
_beat_counter = int(e.get("beat_counter", 0))
def update_beat_route(payload: Dict[str, Any]) -> None:
"""Internal: set or clear routing from explicit fields (tests / future APIs)."""
global _lane_manual, _beat_route, _beat_counter, _preset_session_beats
if not isinstance(payload, dict):
return
with _route_lock:
if payload.get("enabled") is False:
_lane_manual.clear()
_beat_route = {
**_beat_route,
"enabled": False,
"is_manual": False,
"device_names": [],
}
_beat_counter = 0
_preset_session_beats = 0
return
old = dict(_beat_route)
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))
new_wire = str(payload.get("wire_preset_id") or "2")
old_wire = str(old.get("wire_preset_id") or "2")
if not old.get("enabled") or old_wire != new_wire:
_preset_session_beats = 0
clean_names = [str(n).strip() for n in names if str(n).strip()]
_lane_manual.clear()
_lane_manual[-1] = {
"device_names": clean_names,
"wire_preset_id": new_wire,
"pattern": str(payload.get("pattern") or "").strip(),
"manual_beat_n": manual_n,
"beat_counter": 0,
}
_sync_public_beat_route_from_lane_table()
def get_beat_route() -> Dict[str, Any]:
with _route_lock:
return dict(_beat_route)
def manual_beat_stride_status() -> Dict[str, Any]:
"""Audio-beat stride for a live manual preset (not sequence). For UI readout with BPM.
``beat_in_stride`` is always in ``1..stride_n`` when ``active`` (1-based within the stride).
With multiple sequence manual lanes, reflects lane 0 (or the smallest lane index).
"""
with _route_lock:
pick = _pick_display_lane_key()
if pick is None or pick not in _lane_manual:
wid = str(_beat_route.get("wire_preset_id") or "").strip()
return {"active": False, "preset_session_beats": 0, "wire_preset_id": wid}
e = _lane_manual[pick]
c = int(e.get("beat_counter", 0))
psb = int(_preset_session_beats)
wid = str(e.get("wire_preset_id") or "").strip()
try:
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
if c <= 0:
return {
"active": True,
"beat_in_stride": 1,
"stride_n": n,
"preset_session_beats": psb,
"wire_preset_id": wid,
}
beat_in_stride = ((c - 1) % n) + 1
return {
"active": True,
"beat_in_stride": beat_in_stride,
"stride_n": n,
"preset_session_beats": psb,
"wire_preset_id": wid,
}
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 (clears all lanes, including sequence)."""
global _lane_manual
if not device_names:
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
if not isinstance(preset_body, dict):
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
if _coerce_auto_from_body(preset_body):
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
_lane_manual.clear()
_sync_public_beat_route_from_lane_table()
return
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
_lane_manual.clear()
_lane_manual[-1] = {
"device_names": names,
"wire_preset_id": str(wire_preset_id).strip(),
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
}
_sync_public_beat_route_from_lane_table()
def _apply_manual_beat_route_standalone_overlay(
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
global _lane_manual
if not device_names:
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if not isinstance(preset_body, dict):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
if _coerce_auto_from_body(preset_body):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
_lane_manual[-1] = {
"device_names": names,
"wire_preset_id": str(wire_preset_id).strip(),
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
}
_sync_public_beat_route_from_lane_table()
def set_sequence_manual_lane_route(
lane_index: int,
device_names: List[str],
wire_preset_id: str,
preset_body: Any,
) -> None:
"""Register or update one sequence lane's manual beat route (parallel lanes, independent strides)."""
global _lane_manual
names = [str(n).strip() for n in (device_names or []) if str(n).strip()]
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
return
pattern = str(preset_body.get("pattern") or preset_body.get("p") or "").strip()
if pattern and not _pattern_supports_manual(pattern):
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
return
mn = _coerce_manual_beat_n(preset_body)
wid = str(wire_preset_id).strip()
with _route_lock:
old = _lane_manual.get(lane_index)
bc = 0
if (
old
and str(old.get("wire_preset_id") or "") == wid
and int(old.get("manual_beat_n") or 1) == mn
and set(old.get("device_names") or []) == set(names)
):
bc = int(old.get("beat_counter", 0))
_lane_manual[lane_index] = {
"device_names": names,
"wire_preset_id": wid,
"pattern": pattern,
"manual_beat_n": mn,
"beat_counter": bc,
}
_sync_public_beat_route_from_lane_table()
def clear_sequence_manual_lane_route(lane_index: int) -> None:
"""Remove beat routing for one sequence lane (e.g. step switched to auto)."""
global _lane_manual
with _route_lock:
if lane_index in _lane_manual:
del _lane_manual[lane_index]
_sync_public_beat_route_from_lane_table()
def sync_beat_route_from_push_sequence(
sequence: List[Any],
target_macs: Optional[List[str]] = None,
*,
preserve_parallel_lane_routes: bool = False,
) -> 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.
When ``preserve_parallel_lane_routes`` is true (e.g. zone sequence playback is active), an
auto preset in ``select`` does not clear manual routing — other lanes still receive
``notify_beat_detected``. A manual preset in ``select`` is applied on lane ``-1`` only so
sequence lanes ``0..n`` keep their stride counters and wire ids.
"""
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:
if not preserve_parallel_lane_routes:
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:
if not preserve_parallel_lane_routes:
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
if preset_body is None:
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if _coerce_auto_from_body(preset_body):
if not preserve_parallel_lane_routes:
update_beat_route({"enabled": False})
return
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(
device_names, wire_preset_id, preset_body
)
else:
_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)
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(names, wire_id, body)
else:
_apply_manual_beat_route(names, wire_id, body)
return
if not preserve_parallel_lane_routes:
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 _lane_manual
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:
any_changed = False
for e in _lane_manual.values():
names = e.get("device_names") or []
if not isinstance(names, list):
continue
new_list: List[str] = []
row_changed = False
for item in names:
if str(item).strip() == o:
new_list.append(n)
row_changed = True
else:
new_list.append(str(item))
if row_changed:
e["device_names"] = new_list
any_changed = True
if any_changed:
_sync_public_beat_route_from_lane_table()
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}")
async def _deliver_select_batch(pairs: List[Tuple[List[str], str]]) -> None:
for names, pid in pairs:
await _deliver_select(names, pid)
def notify_beat_detected() -> None:
"""Invoked from the audio thread when a beat is detected."""
global _preset_session_beats
work: List[Tuple[List[str], str]] = []
with _route_lock:
if not _lane_manual:
return
work = []
for key in sorted(_lane_manual.keys()):
e = _lane_manual[key]
names = e.get("device_names") or []
if not isinstance(names, list) or not names:
continue
pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern):
continue
try:
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
n = 1
n = max(1, min(64, n))
e["beat_counter"] = int(e.get("beat_counter", 0)) + 1
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
if work:
_preset_session_beats += 1
if not work:
return
loop = _main_loop
if loop is None:
return
try:
asyncio.run_coroutine_threadsafe(_deliver_select_batch(work), loop)
except Exception as e:
print(f"[beat-route] schedule failed: {e}")

View File

@@ -43,6 +43,8 @@ import json
import struct import struct
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import wire_n6
BINARY_ENVELOPE_VERSION_1 = 1 BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2 BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5 HEADER_LEN = 5
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
n3 = _clamp_i16(preset.get("n3", 0)) n3 = _clamp_i16(preset.get("n3", 0))
n4 = _clamp_i16(preset.get("n4", 0)) n4 = _clamp_i16(preset.get("n4", 0))
n5 = _clamp_i16(preset.get("n5", 0)) n5 = _clamp_i16(preset.get("n5", 0))
n6 = _clamp_i16(preset.get("n6", 0)) n6 = _clamp_i16(wire_n6(preset))
parts.append( parts.append(
struct.pack( struct.pack(
"<HBBhhhhhh", "<HBBhhhhhh",

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

@@ -78,12 +78,63 @@ def build_select_message(device_name, preset_name, step=None):
return {device_name: select_list} return {device_name: select_list}
def build_preset_dict(preset_data): def _hex_from_background_raw(bg_raw):
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
if isinstance(bg_raw, str):
bg = bg_raw if bg_raw.startswith("#") else f"#{bg_raw}"
return bg
if isinstance(bg_raw, (list, tuple)) and len(bg_raw) == 3:
return f"#{int(bg_raw[0]):02x}{int(bg_raw[1]):02x}{int(bg_raw[2]):02x}"
return "#000000"
def resolve_preset_background_hex(preset_data, palette_colors=None):
"""
Resolved background as ``#RRGGBB``. When ``palette_colors`` is a non-empty list and
``background_palette_ref`` is set, uses that palette index; otherwise stored ``background`` / ``bg``.
"""
if not isinstance(preset_data, dict):
return "#000000"
pal = list(palette_colors) if isinstance(palette_colors, list) else []
ref = preset_data.get("background_palette_ref", preset_data.get("backgroundPaletteRef"))
if pal and ref is not None:
try:
idx = int(ref)
except (TypeError, ValueError):
idx = None
else:
if isinstance(idx, int) and 0 <= idx < len(pal):
c = pal[idx]
if isinstance(c, str) and c.strip().startswith("#"):
s = c.strip()
if len(s) == 7 and all(ch in "0123456789abcdefABCDEF" for ch in s[1:]):
return s.upper()
bg_raw = preset_data.get("background", preset_data.get("bg", "#000000"))
return _hex_from_background_raw(bg_raw)
def wire_n6(preset_data, default=0):
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
if not isinstance(preset_data, dict):
return default
if preset_data.get("mode") is not None:
try:
return max(0, int(preset_data["mode"]))
except (TypeError, ValueError):
pass
try:
return max(0, int(preset_data.get("n6", default) or 0))
except (TypeError, ValueError):
return default
def build_preset_dict(preset_data, palette_colors=None):
""" """
Convert preset data to API-compliant format. Convert preset data to API-compliant format.
Args: Args:
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.) preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
palette_colors: Optional list of ``#RRGGBB`` strings for ``background_palette_ref`` resolution.
Returns: Returns:
Dictionary with preset in API-compliant format (without name field) Dictionary with preset in API-compliant format (without name field)
@@ -119,30 +170,52 @@ 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 = resolve_preset_background_hex(preset_data, palette_colors)
# 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),
"n4": preset_data.get("n4", 0), "n4": preset_data.get("n4", 0),
"n5": preset_data.get("n5", 0), "n5": preset_data.get("n5", 0),
"n6": preset_data.get("n6", 0) "n6": wire_n6(preset_data),
} }
return preset return preset
def build_presets_dict(presets_data): def build_presets_dict(presets_data, palette_colors=None):
""" """
Convert multiple presets to API-compliant format. Convert multiple presets to API-compliant format.
Args: Args:
presets_data: Dictionary mapping preset names to preset data presets_data: Dictionary mapping preset names to preset data
palette_colors: Optional list of ``#RRGGBB`` strings for background palette ref resolution.
Returns: Returns:
Dictionary mapping preset names to API-compliant preset objects Dictionary mapping preset names to API-compliant preset objects
@@ -163,7 +236,7 @@ def build_presets_dict(presets_data):
""" """
result = {} result = {}
for preset_name, preset_data in presets_data.items(): for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data) result[preset_name] = build_preset_dict(preset_data, palette_colors)
return result return result

441
src/util/profile_bundle.py Normal file
View File

@@ -0,0 +1,441 @@
"""Export/import profile bundles (profile, zones, presets, sequences, palette)."""
from __future__ import annotations
import copy
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
BUNDLE_VERSION = 1
KIND_PROFILE = "profile"
KIND_PRESET = "preset"
KIND_SEQUENCE = "sequence"
def _allocate_id(model, cache: Dict[str, int]) -> str:
if "next" not in cache:
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
cache["next"] = max_id + 1
next_id = str(cache["next"])
cache["next"] += 1
return next_id
def _palette_colors(palette_model, palette_id) -> List:
if not palette_id:
return []
try:
colors = palette_model.read(str(palette_id))
except Exception:
colors = None
if isinstance(colors, list):
return list(colors)
if isinstance(colors, dict) and isinstance(colors.get("colors"), list):
return list(colors["colors"])
return []
def _walk_preset_refs(value, out: Set[str]) -> None:
if isinstance(value, list):
for item in value:
_walk_preset_refs(item, out)
elif value is not None and value != "":
out.add(str(value))
def _preset_ids_in_zone(zone: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(zone, dict):
return ids
_walk_preset_refs(zone.get("presets"), ids)
_walk_preset_refs(zone.get("presets_flat"), ids)
if zone.get("default_preset") not in (None, ""):
ids.add(str(zone["default_preset"]))
return ids
def _preset_ids_in_sequence(seq: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(seq, dict):
return ids
for lane in seq.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
for step in seq.get("steps") or []:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
return ids
def _map_preset_container(
value,
id_map: Dict[str, str],
preset_cache: Dict[str, int],
new_profile_id: str,
new_presets: Dict[str, Dict[str, Any]],
presets_model,
) -> Any:
if isinstance(value, list):
return [
_map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets, presets_model)
for v in value
]
if value is None:
return None
preset_id = str(value)
if preset_id in id_map:
return id_map[preset_id]
preset_data = presets_model.read(preset_id)
if not preset_data:
return None
new_preset_id = _allocate_id(presets_model, preset_cache)
clone_data = dict(preset_data)
clone_data["profile_id"] = str(new_profile_id)
new_presets[new_preset_id] = clone_data
id_map[preset_id] = new_preset_id
return new_preset_id
def _map_sequence_lanes(
seq: Dict[str, Any],
preset_id_map: Dict[str, str],
) -> Dict[str, Any]:
out = copy.deepcopy(seq)
lanes = out.get("lanes")
if isinstance(lanes, list):
for lane in lanes:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
steps = out.get("steps")
if isinstance(steps, list):
for step in steps:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
return out
def export_profile_bundle(
profile_id: str,
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
) -> Dict[str, Any]:
source = profiles_model.read(profile_id)
if not source:
raise ValueError("Profile not found")
zone_ids = source.get("zones")
if not isinstance(zone_ids, list) or not zone_ids:
zone_ids = source.get("zone_order") or []
zone_ids = [str(z) for z in zone_ids if z is not None]
zones_out: Dict[str, Any] = {}
preset_ids: Set[str] = set()
sequence_ids: Set[str] = set()
for zid in zone_ids:
zone = zones_model.read(zid)
if not zone:
continue
zones_out[zid] = copy.deepcopy(zone)
preset_ids |= _preset_ids_in_zone(zone)
for sid in zone.get("sequence_ids") or []:
if sid is not None and str(sid).strip():
sequence_ids.add(str(sid))
sequences_out: Dict[str, Any] = {}
for sid in sequence_ids:
seq = sequences_model.read(sid)
if not seq or str(seq.get("profile_id")) != str(profile_id):
continue
sequences_out[sid] = copy.deepcopy(seq)
preset_ids |= _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for pid in preset_ids:
pdata = presets_model.read(pid)
if pdata and str(pdata.get("profile_id")) == str(profile_id):
presets_out[pid] = copy.deepcopy(pdata)
profile_doc = copy.deepcopy(source)
profile_doc.pop("palette", None)
return {
"version": BUNDLE_VERSION,
"kind": KIND_PROFILE,
"profile": profile_doc,
"palette": {"colors": _palette_colors(palette_model, source.get("palette_id"))},
"zones": zones_out,
"presets": presets_out,
"sequences": sequences_out,
}
def import_profile_bundle(
bundle: Dict[str, Any],
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
*,
name: Optional[str] = None,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("version") not in (BUNDLE_VERSION, str(BUNDLE_VERSION)):
raise ValueError("Unsupported bundle version")
if bundle.get("kind") not in (KIND_PROFILE, None):
raise ValueError("Not a profile bundle")
source_profile = bundle.get("profile")
if not isinstance(source_profile, dict):
raise ValueError("Bundle missing profile")
source_name = source_profile.get("name") or "Imported profile"
new_name = (name or source_name).strip() or source_name
profile_type = source_profile.get("type", "zones")
profile_cache: Dict[str, int] = {}
palette_cache: Dict[str, int] = {}
zone_cache: Dict[str, int] = {}
preset_cache: Dict[str, int] = {}
sequence_cache: Dict[str, int] = {}
new_profile_id = _allocate_id(profiles_model, profile_cache)
new_palette_id = _allocate_id(palette_model, palette_cache)
palette_in = bundle.get("palette") or {}
palette_colors = palette_in.get("colors") if isinstance(palette_in, dict) else []
if not isinstance(palette_colors, list):
palette_colors = []
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
new_zones: Dict[str, Dict[str, Any]] = {}
new_sequences: Dict[str, Dict[str, Any]] = {}
sequence_id_map: Dict[str, str] = {}
zones_in = bundle.get("zones") if isinstance(bundle.get("zones"), dict) else {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
sequences_in = bundle.get("sequences") if isinstance(bundle.get("sequences"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(new_profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_sid, sdata in sequences_in.items():
if not isinstance(sdata, dict):
continue
new_sid = _allocate_id(sequences_model, sequence_cache)
clone = _map_sequence_lanes(sdata, preset_id_map)
clone["profile_id"] = str(new_profile_id)
new_sequences[new_sid] = clone
sequence_id_map[str(old_sid)] = new_sid
source_zone_order = source_profile.get("zones")
if not isinstance(source_zone_order, list):
source_zone_order = list(zones_in.keys())
cloned_zone_ids: List[str] = []
for old_zid in source_zone_order:
zone = zones_in.get(str(old_zid))
if not isinstance(zone, dict):
continue
new_zid = _allocate_id(zones_model, zone_cache)
clone_data: Dict[str, Any] = {
"name": zone.get("name") or f"Zone {old_zid}",
"names": list(zone.get("names") or []),
}
mapped_presets = _map_preset_container(
zone.get("presets"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if mapped_presets is not None:
clone_data["presets"] = mapped_presets
extra = {
k: v
for k, v in zone.items()
if k not in ("name", "names", "presets")
}
if "presets_flat" in extra:
extra["presets_flat"] = _map_preset_container(
extra.get("presets_flat"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if "default_preset" in extra and extra["default_preset"] is not None:
old_dp = str(extra["default_preset"])
if old_dp in preset_id_map:
extra["default_preset"] = preset_id_map[old_dp]
if "sequence_ids" in extra and isinstance(extra.get("sequence_ids"), list):
extra["sequence_ids"] = [
sequence_id_map.get(str(s), str(s))
for s in extra["sequence_ids"]
if s is not None
]
clone_data.update(extra)
new_zones[new_zid] = clone_data
cloned_zone_ids.append(new_zid)
new_profile_data = {
"name": new_name,
"type": profile_type,
"zones": cloned_zone_ids,
"scenes": list(source_profile.get("scenes", []))
if isinstance(source_profile.get("scenes"), list)
else [],
"palette_id": str(new_palette_id),
}
palette_model[str(new_palette_id)] = list(palette_colors)
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
for zid, zdata in new_zones.items():
zones_model[zid] = zdata
for sid, sdata in new_sequences.items():
sequences_model[sid] = sdata
profiles_model[str(new_profile_id)] = new_profile_data
palette_model.save()
presets_model.save()
zones_model.save()
sequences_model.save()
profiles_model.save()
return str(new_profile_id), new_profile_data
def export_preset_bundle(preset_id: str, presets_model) -> Dict[str, Any]:
preset = presets_model.read(preset_id)
if not preset:
raise ValueError("Preset not found")
return {
"version": BUNDLE_VERSION,
"kind": KIND_PRESET,
"preset": copy.deepcopy(preset),
}
def import_preset_bundle(
bundle: Dict[str, Any],
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_PRESET:
raise ValueError("Not a preset bundle")
preset = bundle.get("preset")
if not isinstance(preset, dict):
raise ValueError("Bundle missing preset")
new_id = presets_model.create(profile_id)
data = copy.deepcopy(preset)
data["profile_id"] = str(profile_id)
presets_model.update(new_id, data)
return str(new_id), presets_model.read(new_id) or data
def export_sequence_bundle(
sequence_id: str,
sequences_model,
presets_model,
*,
profile_id: Optional[str] = None,
) -> Dict[str, Any]:
seq = sequences_model.read(sequence_id)
if not seq:
raise ValueError("Sequence not found")
if profile_id is not None and str(seq.get("profile_id")) != str(profile_id):
raise ValueError("Sequence not found")
pid = str(profile_id or seq.get("profile_id") or "")
preset_ids = _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for old_pid in preset_ids:
pdata = presets_model.read(old_pid)
if pdata and (not pid or str(pdata.get("profile_id")) == pid):
presets_out[old_pid] = copy.deepcopy(pdata)
return {
"version": BUNDLE_VERSION,
"kind": KIND_SEQUENCE,
"sequence": copy.deepcopy(seq),
"presets": presets_out,
}
def import_sequence_bundle(
bundle: Dict[str, Any],
sequences_model,
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_SEQUENCE:
raise ValueError("Not a sequence bundle")
seq = bundle.get("sequence")
if not isinstance(seq, dict):
raise ValueError("Bundle missing sequence")
preset_cache: Dict[str, int] = {}
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_pid in _preset_ids_in_sequence(seq):
op = str(old_pid)
if op not in preset_id_map:
pdata = presets_model.read(op)
if pdata:
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[op] = new_pid
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
if new_presets:
presets_model.save()
new_seq_id = sequences_model.create(profile_id)
mapped = _map_sequence_lanes(seq, preset_id_map)
mapped["profile_id"] = str(profile_id)
sequences_model.update(new_seq_id, mapped)
return str(new_seq_id), sequences_model.read(new_seq_id) or mapped

File diff suppressed because it is too large Load Diff

Binary file not shown.

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

@@ -25,18 +25,20 @@ def test_preset():
print("\nTesting update preset") print("\nTesting update preset")
update_data = { update_data = {
"name": "test_preset", "name": "test_preset",
"pattern": "on", "pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00"], "colors": ["#FF0000", "#00FF00"],
"delay": 100, "delay": 100,
"brightness": 127, "brightness": 127,
"n1": 10, "n1": 10,
"n2": 20 "n2": 20,
"mode": 1,
} }
result = presets.update(preset_id, update_data) result = presets.update(preset_id, update_data)
assert result is True assert result is True
updated = presets.read(preset_id) updated = presets.read(preset_id)
assert updated["name"] == "test_preset" assert updated["name"] == "test_preset"
assert updated["pattern"] == "on" assert updated["pattern"] == "colour_cycle"
assert updated["mode"] == 1
assert updated["delay"] == 100 assert updated["delay"] == 100
print("\nTesting list presets") print("\nTesting list presets")

View File

@@ -1,16 +1,19 @@
from models.squence import Sequence from models.sequence import Sequence
import os import os
_HERE = os.path.dirname(os.path.abspath(__file__))
_PROJECT_DB = os.path.normpath(os.path.join(_HERE, "..", "..", "db", "sequence.json"))
def test_sequence(): def test_sequence():
"""Test Sequence model CRUD operations.""" """Test Sequence model CRUD operations."""
# Clean up any existing test file if os.path.exists(_PROJECT_DB):
if os.path.exists("Sequence.json"): os.remove(_PROJECT_DB)
os.remove("Sequence.json")
sequences = Sequence() sequences = Sequence()
print("Testing create sequence") print("Testing create sequence")
sequence_id = sequences.create("test_group", ["preset1", "preset2"]) sequence_id = sequences.create("1")
print(f"Created sequence with ID: {sequence_id}") print(f"Created sequence with ID: {sequence_id}")
assert sequence_id is not None assert sequence_id is not None
assert sequence_id in sequences assert sequence_id in sequences
@@ -19,27 +22,45 @@ def test_sequence():
sequence = sequences.read(sequence_id) sequence = sequences.read(sequence_id)
print(f"Read: {sequence}") print(f"Read: {sequence}")
assert sequence is not None assert sequence is not None
assert sequence["group_name"] == "test_group" assert sequence["profile_id"] == "1"
assert len(sequence["presets"]) == 2 assert sequence["steps"] == []
assert "sequence_duration" in sequence assert sequence["lanes"] == [[]]
assert "sequence_loop" in sequence assert sequence.get("lanes_group_ids") == [[]]
assert sequence.get("advance_mode") == "beats"
assert sequence.get("simulated_bpm") == 120
assert sequence["step_duration_ms"] == 3000
assert sequence["loop"] is True
assert sequence.get("sequence_transition") == 500
print("\nTesting update sequence") print("\nTesting update sequence")
update_data = { update_data = {
"group_name": "updated_group", "name": "updated_seq",
"presets": ["preset3", "preset4", "preset5"], "steps": [
"sequence_duration": 5000, {"preset_id": "5", "group_ids": ["1"], "beats": 2},
"sequence_transition": 1000, {"preset_id": "6", "group_ids": [], "beats": 4},
"sequence_loop": True, ],
"sequence_repeat_count": 3 "lanes_group_ids": [["1"]],
"step_duration_ms": 5000,
"loop": True,
"advance_mode": "beats",
"simulated_bpm": 128,
} }
result = sequences.update(sequence_id, update_data) result = sequences.update(sequence_id, update_data)
assert result is True assert result is True
updated = sequences.read(sequence_id) updated = sequences.read(sequence_id)
assert updated["group_name"] == "updated_group" assert updated["name"] == "updated_seq"
assert len(updated["presets"]) == 3 assert len(updated["steps"]) == 2
assert updated["sequence_duration"] == 5000 assert updated["steps"][0]["preset_id"] == "5"
assert updated["sequence_loop"] is True assert updated["steps"][0]["group_ids"] == ["1"]
assert updated["steps"][0].get("beats") == 2
assert isinstance(updated.get("lanes"), list)
assert len(updated["lanes"]) == 1
assert len(updated["lanes"][0]) == 2
assert updated["lanes"][0][0]["beats"] == 2
assert updated.get("advance_mode") == "beats"
assert updated.get("simulated_bpm") == 128
assert updated["step_duration_ms"] == 5000
assert updated["loop"] is True
print("\nTesting list sequences") print("\nTesting list sequences")
sequence_list = sequences.list() sequence_list = sequences.list()
@@ -58,5 +79,5 @@ def test_sequence():
print("\nAll sequence tests passed!") print("\nAll sequence tests passed!")
if __name__ == '__main__': if __name__ == "__main__":
test_sequence() test_sequence()

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

@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
assert data == {"v": "1", "b": 128} assert data == {"v": "1", "b": 128}
def test_pack_parse_v2_mode_maps_to_n6():
raw = pack_binary_envelope_v2(
presets={
"m": {
"p": "meteor",
"c": ["#aabbcc"],
"mode": 2,
"n6": 0,
}
},
)
data = parse_binary_envelope_v2(raw)
assert data["presets"]["m"]["n6"] == 2
def test_pack_parse_v2_full(): def test_pack_parse_v2_full():
raw = pack_binary_envelope_v2( raw = pack_binary_envelope_v2(
presets={ presets={

View File

@@ -750,6 +750,46 @@ def test_presets_ui(browser: BrowserTest) -> bool:
print(f"\nBrowser presets UI tests: {passed}/{total} passed") print(f"\nBrowser presets UI tests: {passed}/{total} passed")
return passed == total return passed == total
def test_preset_editor_palette_child_modal_zindex(browser: BrowserTest) -> bool:
"""
Regression: preset editor 'From Palette' builds a body-appended .modal without an id.
It must use .modal-child-overlay so z-index clears #preset-editor-modal (1060).
"""
print("\n=== Testing preset child overlay z-index ===")
if not browser.setup():
return False
try:
if not browser.navigate("/"):
return False
z_editor, z_child = browser.driver.execute_script(
"""
const ed = document.getElementById('preset-editor-modal');
if (!ed) { return [0, 0]; }
ed.classList.add('active');
const m = document.createElement('div');
m.className = 'modal active modal-child-overlay';
m.innerHTML = '<div class="modal-content"><p>stack probe</p></div>';
document.body.appendChild(m);
const ze = parseInt(getComputedStyle(ed).zIndex, 10) || 0;
const zc = parseInt(getComputedStyle(m).zIndex, 10) || 0;
m.remove();
ed.classList.remove('active');
return [ze, zc];
"""
)
print(f" preset-editor z-index={z_editor} modal-child-overlay z-index={z_child}")
if z_child > z_editor >= 1000:
print("✓ Child overlay stacks above preset editor")
return True
print("✗ Child overlay did not stack above preset editor")
return False
except Exception as e:
print(f"✗ z-index probe failed: {e}")
return False
finally:
browser.teardown()
def test_color_palette_ui(browser: BrowserTest) -> bool: def test_color_palette_ui(browser: BrowserTest) -> bool:
"""Test color palette UI in browser.""" """Test color palette UI in browser."""
print("\n=== Testing Color Palette UI in Browser ===") print("\n=== Testing Color Palette UI in Browser ===")
@@ -1105,6 +1145,9 @@ def main():
results.append(("Zones UI", test_zones_ui(browser))) results.append(("Zones UI", test_zones_ui(browser)))
results.append(("Profiles UI", test_profiles_ui(browser))) results.append(("Profiles UI", test_profiles_ui(browser)))
results.append(("Presets UI", test_presets_ui(browser))) results.append(("Presets UI", test_presets_ui(browser)))
results.append(
("Preset palette child z-index", test_preset_editor_palette_child_modal_zindex(browser))
)
results.append(("Color Palette UI", test_color_palette_ui(browser))) results.append(("Color Palette UI", test_color_palette_ui(browser)))
results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser))) results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser)))

View File

@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
raise AssertionError(f"Could not find id for {field}={value!r}") raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
profile_id = next(iter(resp.json().keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
return str(profile_id)
def _start_microdot_server(app: Microdot, host: str, port: int): def _start_microdot_server(app: Microdot, host: str, port: int):
""" """
Start Microdot server on a background thread. Start Microdot server on a background thread.
@@ -123,7 +134,7 @@ def server(monkeypatch, tmp_path_factory):
import models.pallet as models_pallet # noqa: E402 import models.pallet as models_pallet # noqa: E402
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402 import models.sequence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402 import models.device as models_device # noqa: E402
for cls in ( for cls in (
@@ -474,6 +485,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
resp = c.delete(f"{base_url}/zones/{zone_id}") resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
assert resp.status_code == 200
bundle = resp.json()
assert bundle.get("kind") == "profile"
assert isinstance(bundle.get("presets"), dict)
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/profiles/import",
json={"bundle": bundle, "name": import_name, "apply": False},
)
assert resp.status_code == 201
imported_profile_id = resp.json().get("id") or next(
k for k in resp.json().keys() if k != "id"
)
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
assert resp.status_code == 200
assert resp.json().get("kind") == "preset"
resp = c.post(
f"{base_url}/presets/import",
json={"bundle": resp.json()},
)
assert resp.status_code == 201
imported_preset_id = next(iter(resp.json().keys()))
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
assert resp.status_code == 200
# Profile clone + update endpoints. # Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}" clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name}) resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
@@ -508,6 +549,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"] sender: DummySender = server["sender"]
_create_and_apply_profile(c, base_url)
# Groups. # Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name}) resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
@@ -527,21 +570,24 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
# Sequences. # Sequences.
unique_seq_group_name = f"pytest-seq-group-{uuid.uuid4().hex[:8]}" unique_seq_name = f"pytest-seq-{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(
f"{base_url}/sequences", f"{base_url}/sequences",
json={"group_name": unique_seq_group_name, "presets": []}, json={
"name": unique_seq_name,
"steps": [{"preset_id": "1", "group_ids": []}],
},
) )
assert resp.status_code == 201 assert resp.status_code == 201
sequences_list = c.get(f"{base_url}/sequences").json() sequences_list = c.get(f"{base_url}/sequences").json()
seq_id = _find_id_by_field(sequences_list, "group_name", unique_seq_group_name) seq_id = _find_id_by_field(sequences_list, "name", unique_seq_name)
resp = c.get(f"{base_url}/sequences/{seq_id}") resp = c.get(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.put(f"{base_url}/sequences/{seq_id}", json={"sequence_duration": 1234}) resp = c.put(f"{base_url}/sequences/{seq_id}", json={"step_duration_ms": 1234})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["sequence_duration"] == 1234 assert resp.json()["step_duration_ms"] == 1234
resp = c.delete(f"{base_url}/sequences/{seq_id}") resp = c.delete(f"{base_url}/sequences/{seq_id}")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -712,6 +758,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
definitions = resp.json() definitions = resp.json()
assert isinstance(definitions, dict) assert isinstance(definitions, dict)
assert "colour_cycle" in definitions
cc_mode = definitions["colour_cycle"].get("mode")
assert isinstance(cc_mode, dict)
assert "0" in cc_mode and "1" in cc_mode
assert "blink" in definitions
blink_mode = definitions["blink"].get("mode")
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}" pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(

View File

@@ -0,0 +1,51 @@
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
from patterns.pattern_modes import style_mode # noqa: E402
from preset import Preset # noqa: E402
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
def test_wire_n6_prefers_mode_over_n6():
assert wire_n6({"mode": 2, "n6": 0}) == 2
assert wire_n6({"n6": 1}) == 1
assert wire_n6({}) == 0
def test_build_preset_dict_maps_mode_to_n6():
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
assert wire["n6"] == 2
assert wire["p"] == "meteor"
def test_preset_edit_accepts_mode_alias():
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
assert p.n6 == 1
def test_style_mode_reads_mode_and_legacy_pattern_id():
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
assert style_mode(p, 0, {"rainbow": 1}) == 0
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
def test_pattern_json_defines_mode_for_merged_patterns():
path = PROJECT_ROOT / "db" / "pattern.json"
definitions = json.loads(path.read_text(encoding="utf-8"))
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
assert name in definitions, name
mode = definitions[name].get("mode")
assert isinstance(mode, dict), name
assert len(mode) >= 2, name
blink = definitions.get("blink", {})
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2

View File

@@ -0,0 +1,133 @@
"""Unit tests for profile/preset/sequence bundle import/export."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.pallet import Palette # noqa: E402
from models.preset import Preset # noqa: E402
from models.profile import Profile # noqa: E402
from models.sequence import Sequence # noqa: E402
from models.zone import Zone # noqa: E402
from util.profile_bundle import ( # noqa: E402
export_preset_bundle,
export_profile_bundle,
export_sequence_bundle,
import_preset_bundle,
import_profile_bundle,
import_sequence_bundle,
)
def _fresh_models(tmp_path, monkeypatch):
import models.model as model_mod
db = tmp_path / "db"
db.mkdir()
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
for cls in (Profile, Zone, Preset, Sequence, Palette):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
palette = Palette()
return profiles, zones, presets, sequences, palette
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("Source")
zid = zones.create(name="main")
preset_id = presets.create(pid)
presets.update(
preset_id,
{
"name": "Test preset",
"pattern": "blink",
"colors": ["#ff0000"],
"brightness": 200,
"delay": 50,
},
)
zones.update(
zid,
{
"presets_flat": [str(preset_id)],
"default_preset": str(preset_id),
},
)
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{
"name": "Beat seq",
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
"lanes_group_ids": [[]],
},
)
zones.update(zid, {"sequence_ids": [str(seq_id)]})
profiles.update(pid, {"zones": [str(zid)]})
palette_id = profiles.read(pid)["palette_id"]
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
bundle = export_profile_bundle(
str(pid), profiles, zones, presets, sequences, palette
)
assert bundle["kind"] == "profile"
assert str(preset_id) in bundle["presets"]
assert str(seq_id) in bundle["sequences"]
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
new_pid, _ = import_profile_bundle(
bundle, profiles, zones, presets, sequences, palette, name="Imported"
)
assert new_pid != str(pid)
found = [
presets.read(k)
for k in presets.list()
if isinstance(presets.read(k), dict)
and str(presets.read(k).get("profile_id")) == str(new_pid)
and presets.read(k).get("name") == "Test preset"
]
assert found
def test_preset_export_import(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
bundle = export_preset_bundle(str(preset_id), presets)
assert bundle["kind"] == "preset"
new_id, data = import_preset_bundle(bundle, presets, str(pid))
assert new_id != str(preset_id)
assert data["name"] == "Solo"
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Step", "pattern": "off"})
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
)
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
assert str(preset_id) in bundle["presets"]
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
assert new_seq_id != str(seq_id)
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)

View File

@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Metronome-style mono click track for testing the audio beat detector.
Without ``-o``: streams S16LE PCM to ``aplay`` (stdin) until you press Ctrl+C.
With ``-o``: writes a WAV file of fixed length and exits.
Examples:
python3 tools/generate_beat_test_track.py
python3 tools/generate_beat_test_track.py --bpm 90
python3 tools/generate_beat_test_track.py -o tests/audio/beat_test_120bpm.wav --duration 30
"""
from __future__ import annotations
import argparse
import math
import shutil
import struct
import subprocess
import wave
from pathlib import Path
def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument(
"-o",
"--output",
type=Path,
default=None,
help="If set, write this WAV file and exit (no live playback)",
)
p.add_argument("--bpm", type=float, default=120.0, help="Beats per minute (default: 120)")
p.add_argument(
"--duration",
type=float,
default=30.0,
help="With -o only: click section length in seconds after intro (default: 30)",
)
p.add_argument(
"--intro-silence",
type=float,
default=0.5,
help="Leading silence in seconds (default: 0.5)",
)
p.add_argument("--sample-rate", type=int, default=44100, help="Sample rate Hz (default: 44100)")
p.add_argument(
"--click-ms",
type=float,
default=18.0,
help="Approximate click length in ms (default: 18)",
)
p.add_argument(
"--freq",
type=float,
default=1000.0,
help="Click sine frequency Hz (default: 1000)",
)
return p.parse_args()
def _click_int16_samples(sr: int, click_ms: float, freq: float) -> tuple[list[int], int]:
"""One click; returns samples and click_len (same as len(samples))."""
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
freq_clamped = max(200.0, min(4000.0, float(freq)))
floats: list[float] = []
for i in range(click_len):
t = i / sr
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
floats.append(env * math.sin(2.0 * math.pi * freq_clamped * t))
peak = max(abs(x) for x in floats) or 1.0
scale = 0.92 / peak
out: list[int] = []
for x in floats:
v = int(round(max(-1.0, min(1.0, x * scale)) * 32767.0))
out.append(max(-32767, min(32767, v)))
return out, click_len
def _render_scaled_samples(
sr: int,
bpm: float,
intro: float,
dur: float,
click_ms: float,
freq: float,
) -> tuple[list[float], int, float, int]:
beat_sec = 60.0 / bpm
click_len = max(1, int(sr * max(4.0, click_ms) / 1000.0))
freq_clamped = max(200.0, min(4000.0, float(freq)))
total_sec = intro + dur
n_samples = int(sr * total_sec)
intro_samples = int(sr * intro)
samples = [0.0] * n_samples
beat_samples = int(round(sr * beat_sec))
if beat_samples < click_len + 1:
raise SystemExit("BPM too high for this sample rate / click length")
beat_idx = 0
while True:
start = intro_samples + beat_idx * beat_samples
if start >= n_samples:
break
for i in range(click_len):
pos = start + i
if pos >= n_samples:
break
t = i / sr
env = math.sin(0.5 * math.pi * (i + 1) / click_len) ** 2
s = env * math.sin(2.0 * math.pi * freq_clamped * t)
samples[pos] += s
beat_idx += 1
peak = max(abs(x) for x in samples) or 1.0
scale = 0.92 / peak
for i in range(n_samples):
samples[i] = max(-1.0, min(1.0, samples[i] * scale))
return samples, sr, total_sec, beat_idx
def write_wav_mono16(path: Path, samples: list[float], sr: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(sr)
for x in samples:
v = int(round(x * 32767.0))
w.writeframes(struct.pack("<h", v))
def _stream_aplay_until_interrupt(
sr: int,
bpm: float,
intro_silence: float,
click_ms: float,
freq: float,
) -> None:
aplay = shutil.which("aplay")
if not aplay:
raise SystemExit("aplay not found; install alsa-utils, or use -o to write a WAV file.")
click_samps, click_len = _click_int16_samples(sr, click_ms, freq)
beat_samples = int(round(sr * 60.0 / bpm))
if beat_samples < click_len + 1:
raise SystemExit("BPM too high for this sample rate / click length")
silence_samples = beat_samples - click_len
beat_chunk = struct.pack("<" + "h" * len(click_samps), *click_samps) + (
b"\x00\x00" * silence_samples
)
intro_samples = int(sr * max(0.0, float(intro_silence)))
intro_chunk = b"\x00\x00" * intro_samples
argv = [aplay, "-q", "-t", "raw", "-f", "S16_LE", "-c", "1", "-r", str(sr)]
proc = subprocess.Popen(argv, stdin=subprocess.PIPE)
if proc.stdin is None:
raise SystemExit("aplay did not open stdin")
print(
f"Streaming {bpm} BPM, {sr} Hz mono -> aplay (raw). Ctrl+C to stop.",
flush=True,
)
try:
if intro_chunk:
proc.stdin.write(intro_chunk)
beats_written = 0
# Write several beats per syscall to reduce overhead
batch = 8
multi = beat_chunk * batch
while True:
proc.stdin.write(multi)
beats_written += batch
if beats_written % 256 == 0:
proc.stdin.flush()
except BrokenPipeError:
print("aplay exited.", flush=True)
except KeyboardInterrupt:
print("\nStopped.", flush=True)
finally:
try:
proc.stdin.close()
except BrokenPipeError:
pass
proc.wait(timeout=3)
def main() -> None:
args = _parse_args()
sr = max(8000, min(96000, int(args.sample_rate)))
bpm = max(40.0, min(240.0, float(args.bpm)))
if args.output is not None:
intro = max(0.0, float(args.intro_silence))
dur = max(1.0, float(args.duration))
samples, sr_u, total_sec, beats = _render_scaled_samples(
sr, bpm, intro, dur, float(args.click_ms), float(args.freq)
)
write_wav_mono16(args.output, samples, sr_u)
print(
f"Wrote {args.output} ({len(samples)} samples, {total_sec:.1f}s, {sr_u} Hz mono): "
f"{bpm} BPM, ~{beats} beats"
)
return
_stream_aplay_until_interrupt(
sr,
bpm,
float(args.intro_silence),
float(args.click_ms),
float(args.freq),
)
if __name__ == "__main__":
main()