Compare commits
7 Commits
beta-1.01
...
6c9e06f33b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c9e06f33b | |||
| c1c3e5d71b | |||
| c64dd736f2 | |||
| cad0aa7e59 | |||
| 0ae39ab94b | |||
| 822d9d8e01 | |||
| 1db905eaae |
@@ -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
12
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
5
Pipfile
5
Pipfile
@@ -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
224
Pipfile.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
292
db/pattern.json
292
db/pattern.json
@@ -1 +1,291 @@
|
|||||||
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "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 10–30 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1–255, higher = more changes)", "n2": "Density (0–255, 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
|
||||||
|
},
|
||||||
|
"rainbow": {
|
||||||
|
"n1": "Step Rate",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 0,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"colour_cycle": {
|
||||||
|
"n1": "Step Rate",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"transition": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"chase": {
|
||||||
|
"n1": "Colour 1 Length",
|
||||||
|
"n2": "Colour 2 Length",
|
||||||
|
"n3": "Step 1",
|
||||||
|
"n4": "Step 2",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"pulse": {
|
||||||
|
"n1": "Attack",
|
||||||
|
"n2": "Hold",
|
||||||
|
"n3": "Decay",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"n1": "Head Rate",
|
||||||
|
"n2": "Max Length",
|
||||||
|
"n3": "Tail Rate",
|
||||||
|
"n4": "Min Length",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"flicker": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"flame": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"n2": "Breath period (ms)",
|
||||||
|
"n3": "Spark gap min (ms, 0=default 10–30 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–255, higher = more changes)",
|
||||||
|
"n2": "Density (0–255, 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
|
||||||
|
},
|
||||||
|
"meteor_rain": {
|
||||||
|
"n1": "Tail length",
|
||||||
|
"n2": "Speed (LEDs per frame)",
|
||||||
|
"n3": "Fade amount (1-255)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"scanner": {
|
||||||
|
"n1": "Eye width",
|
||||||
|
"n2": "End pause (frames)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"gradient_scroll": {
|
||||||
|
"n1": "Scroll step rate",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"comet_dual": {
|
||||||
|
"n1": "Tail length",
|
||||||
|
"n2": "Speed",
|
||||||
|
"n3": "Gap",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"sparkle_trail": {
|
||||||
|
"n1": "Spark density",
|
||||||
|
"n2": "Decay",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"wave": {
|
||||||
|
"n1": "Wavelength",
|
||||||
|
"n2": "Amplitude",
|
||||||
|
"n3": "Drift speed",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"plasma": {
|
||||||
|
"n1": "Scale",
|
||||||
|
"n2": "Speed",
|
||||||
|
"n3": "Contrast",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"segment_chase": {
|
||||||
|
"n1": "Segment size",
|
||||||
|
"n2": "Phase step",
|
||||||
|
"n3": "Segment phase offset",
|
||||||
|
"n4": "Gap per segment",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"bar_graph": {
|
||||||
|
"n1": "Level percent",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"breathing_dual": {
|
||||||
|
"n1": "Phase offset",
|
||||||
|
"n2": "Ease",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"strobe_burst": {
|
||||||
|
"n1": "Burst count",
|
||||||
|
"n2": "Burst gap",
|
||||||
|
"n3": "Cooldown",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"rain_drops": {
|
||||||
|
"n1": "Drop rate",
|
||||||
|
"n2": "Ripple width",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"fireflies": {
|
||||||
|
"n1": "Count",
|
||||||
|
"n2": "Twinkle speed",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"clock_sweep": {
|
||||||
|
"n1": "Hand width",
|
||||||
|
"n2": "Marker interval",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"marquee": {
|
||||||
|
"n1": "On length",
|
||||||
|
"n2": "Off length",
|
||||||
|
"n3": "Step",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"aurora": {
|
||||||
|
"n1": "Band count",
|
||||||
|
"n2": "Shimmer",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"snowfall": {
|
||||||
|
"n1": "Flake density",
|
||||||
|
"n2": "Fall speed",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"n1": "Pulse 1 ms",
|
||||||
|
"n2": "Pulse 2 ms",
|
||||||
|
"n3": "Pause ms",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"orbit": {
|
||||||
|
"n1": "Orbit count",
|
||||||
|
"n2": "Base speed",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"palette_morph": {
|
||||||
|
"n1": "Morph ms",
|
||||||
|
"n2": "Warp rate",
|
||||||
|
"n3": "Turbulence",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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}}
|
{"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, "steps": [], "step_duration_ms": 3000, "loop": true, "name": "Main Group", "profile_id": "1", "lanes": [[{"preset_id": "42", "beats": 6}, {"preset_id": "5", "beats": 2}], [{"preset_id": "6", "beats": 1}]], "group_ids": ["1"], "advance_mode": "beats", "lanes_group_ids": [["1"], ["2"]]}, "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, "steps": [{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}], "step_duration_ms": 2000, "loop": true, "name": "Accent Group", "profile_id": "1", "lanes": [[{"preset_id": "2", "group_ids": [], "beats": 1}, {"preset_id": "3", "group_ids": [], "beats": 1}]], "group_ids": [], "advance_mode": "time", "lanes_group_ids": [[]]}}
|
||||||
Submodule led-driver updated: fbebe9f4f9...2a768376d0
@@ -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 0–255")
|
||||||
|
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 0–255 }``.
|
||||||
|
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``). Wi‑Fi or ESP‑NOW.
|
||||||
|
"""
|
||||||
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:
|
if not ok:
|
||||||
msg = _compact_v1_json(
|
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
"Content-Type": "application/json",
|
||||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
}
|
||||||
)
|
else:
|
||||||
if transport == "wifi":
|
sender = get_current_sender()
|
||||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
if not sender:
|
||||||
if not ok:
|
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
"Content-Type": "application/json",
|
||||||
"Content-Type": "application/json",
|
}
|
||||||
}
|
try:
|
||||||
else:
|
|
||||||
await sender.send(msg, addr=id)
|
await sender.send(msg, addr=id)
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
asyncio.create_task(
|
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
|
||||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
"Content-Type": "application/json",
|
||||||
)
|
}
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
|
||||||
return json.dumps({"message": "Identify sent"}), 200, {
|
@controller.post("/<id>/driver-config")
|
||||||
|
async def push_driver_config(request, id):
|
||||||
|
"""
|
||||||
|
Push ``device_config`` to a Wi‑Fi 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 Wi‑Fi 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 Wi‑Fi defaults to every Wi‑Fi 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"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -12,6 +13,18 @@ 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()
|
||||||
@@ -153,6 +166,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 +175,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 +329,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,
|
||||||
|
|||||||
@@ -1,51 +1,207 @@
|
|||||||
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
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
profiles = Profile()
|
||||||
|
|
||||||
@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>")
|
||||||
|
@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):
|
|
||||||
"""Create a new sequence."""
|
|
||||||
try:
|
|
||||||
data = request.json or {}
|
|
||||||
group_name = data.get("group_name", "")
|
|
||||||
preset_names = data.get("presets", None)
|
|
||||||
sequence_id = sequences.create(group_name, preset_names)
|
|
||||||
if data:
|
|
||||||
sequences.update(sequence_id, data)
|
|
||||||
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 400
|
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.post("")
|
||||||
async def update_sequence(request, id):
|
@with_session
|
||||||
"""Update an existing sequence."""
|
async def create_sequence(request, session):
|
||||||
|
"""Create a new sequence for the current profile."""
|
||||||
try:
|
try:
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
|
400,
|
||||||
|
{"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:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def update_sequence(request, session, id):
|
||||||
|
"""Update an existing sequence (current profile only)."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
data = request.json
|
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
|
||||||
|
async def delete_sequence(request, session, id):
|
||||||
|
"""Delete a sequence (current profile only)."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if sequences.delete(id):
|
if sequences.delete(id):
|
||||||
return json.dumps({"message": "Sequence deleted successfully"}), 200
|
return (
|
||||||
|
json.dumps({"message": "Sequence deleted successfully"}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
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"}
|
||||||
|
|||||||
@@ -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,12 @@ async def clone_zone(request, session, id):
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
source_name = source.get("name") or f"Zone {id}"
|
source_name = source.get("name") or f"Zone {id}"
|
||||||
new_name = data.get("name") or f"{source_name} Copy"
|
new_name = data.get("name") or f"{source_name} Copy"
|
||||||
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
|
clone_id = zones.create(
|
||||||
|
new_name,
|
||||||
|
source.get("names"),
|
||||||
|
source.get("presets"),
|
||||||
|
source.get("group_ids"),
|
||||||
|
)
|
||||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
if extra:
|
if extra:
|
||||||
zones.update(clone_id, extra)
|
zones.update(clone_id, extra)
|
||||||
|
|||||||
150
src/main.py
150
src/main.py
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,14 +1,71 @@
|
|||||||
from models.model import Model
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
class Group(Model):
|
class Group(Model):
|
||||||
|
"""Device groups (members + optional Wi‑Fi 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
|
||||||
|
|||||||
@@ -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
159
src/models/sequence.py
Normal 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())
|
||||||
@@ -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())
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,38 @@ 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()
|
||||||
|
|
||||||
|
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
|
||||||
|
self[next_id] = doc
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|
||||||
|
|||||||
@@ -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
477
src/static/audio.js
Normal 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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
25
src/static/dev-live-reload.js
Normal file
25
src/static/dev-live-reload.js
Normal 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();
|
||||||
|
})();
|
||||||
@@ -149,8 +149,10 @@ function applyTransportVisibility(transport) {
|
|||||||
const isWifi = transport === 'wifi';
|
const isWifi = transport === 'wifi';
|
||||||
const esp = document.getElementById('edit-device-address-espnow');
|
const esp = document.getElementById('edit-device-address-espnow');
|
||||||
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
|
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
|
||||||
|
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
|
||||||
if (esp) esp.hidden = isWifi;
|
if (esp) esp.hidden = isWifi;
|
||||||
if (wifiWrap) wifiWrap.hidden = !isWifi;
|
if (wifiWrap) wifiWrap.hidden = !isWifi;
|
||||||
|
if (drvWrap) drvWrap.hidden = !isWifi;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAddressForPayload(transport) {
|
function getAddressForPayload(transport) {
|
||||||
@@ -166,6 +168,63 @@ function getAddressForPayload(transport) {
|
|||||||
return hex || null;
|
return hex || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectDeviceEditPayload() {
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const typeSel = document.getElementById('edit-device-type');
|
||||||
|
const transportSel = document.getElementById('edit-device-transport');
|
||||||
|
const devId = idInput && idInput.value;
|
||||||
|
const transport = (transportSel && transportSel.value) || 'espnow';
|
||||||
|
const address = getAddressForPayload(transport);
|
||||||
|
const obr = document.getElementById('edit-device-output-brightness');
|
||||||
|
let output_brightness = 255;
|
||||||
|
if (obr && obr.value !== '') {
|
||||||
|
const n = parseInt(obr.value, 10);
|
||||||
|
output_brightness = !Number.isNaN(n) ? Math.max(0, Math.min(255, n)) : 255;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: nameInput ? nameInput.value.trim() : '',
|
||||||
|
type: (typeSel && typeSel.value) || 'led',
|
||||||
|
transport,
|
||||||
|
address,
|
||||||
|
output_brightness,
|
||||||
|
};
|
||||||
|
if (transport === 'wifi') {
|
||||||
|
const dn = document.getElementById('edit-device-wifi-driver-name');
|
||||||
|
const nl = document.getElementById('edit-device-wifi-num-leds');
|
||||||
|
const co = document.getElementById('edit-device-wifi-color-order');
|
||||||
|
const ws = document.getElementById('edit-device-wifi-startup-mode');
|
||||||
|
if (dn && dn.value.trim()) payload.wifi_driver_display_name = dn.value.trim();
|
||||||
|
if (nl && nl.value !== '') {
|
||||||
|
const n = parseInt(nl.value, 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1) payload.wifi_driver_num_leds = n;
|
||||||
|
}
|
||||||
|
if (co && co.value) payload.wifi_color_order = co.value;
|
||||||
|
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
|
||||||
|
}
|
||||||
|
return { devId, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEditDeviceDebug() {
|
||||||
|
const ta = document.getElementById('edit-device-debug');
|
||||||
|
if (!ta) return;
|
||||||
|
try {
|
||||||
|
const { devId, payload } = collectDeviceEditPayload();
|
||||||
|
const loaded = window.__editDeviceLoadedSnapshot;
|
||||||
|
ta.value = JSON.stringify(
|
||||||
|
{
|
||||||
|
device_id: devId || null,
|
||||||
|
loaded_from_server: loaded != null ? loaded : null,
|
||||||
|
save_payload_preview: payload,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ta.value = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDevicesModal() {
|
async function loadDevicesModal() {
|
||||||
const container = document.getElementById('devices-list-modal');
|
const container = document.getElementById('devices-list-modal');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -307,6 +366,11 @@ function renderDevicesList(devices) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditDeviceModal(devId, dev) {
|
function openEditDeviceModal(devId, dev) {
|
||||||
|
try {
|
||||||
|
window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null;
|
||||||
|
} catch (e) {
|
||||||
|
window.__editDeviceLoadedSnapshot = dev || null;
|
||||||
|
}
|
||||||
const modal = document.getElementById('edit-device-modal');
|
const modal = document.getElementById('edit-device-modal');
|
||||||
const idInput = document.getElementById('edit-device-id');
|
const idInput = document.getElementById('edit-device-id');
|
||||||
const storageLabel = document.getElementById('edit-device-storage-id');
|
const storageLabel = document.getElementById('edit-device-storage-id');
|
||||||
@@ -325,20 +389,83 @@ function openEditDeviceModal(devId, dev) {
|
|||||||
applyTransportVisibility(tr);
|
applyTransportVisibility(tr);
|
||||||
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
||||||
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
||||||
|
const wName = document.getElementById('edit-device-wifi-driver-name');
|
||||||
|
const wLeds = document.getElementById('edit-device-wifi-num-leds');
|
||||||
|
const wCo = document.getElementById('edit-device-wifi-color-order');
|
||||||
|
const wStart = document.getElementById('edit-device-wifi-startup-mode');
|
||||||
|
if (wName) {
|
||||||
|
const savedDisp =
|
||||||
|
dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name')
|
||||||
|
? dev.wifi_driver_display_name
|
||||||
|
: undefined;
|
||||||
|
if (savedDisp != null && String(savedDisp).trim() !== '') {
|
||||||
|
wName.value = String(savedDisp).trim();
|
||||||
|
} else {
|
||||||
|
wName.value = dev && dev.name ? String(dev.name) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wLeds) {
|
||||||
|
wLeds.value =
|
||||||
|
dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== ''
|
||||||
|
? String(dev.wifi_driver_num_leds)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
if (wCo) {
|
||||||
|
const co = (dev && dev.wifi_color_order) || 'rgb';
|
||||||
|
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
|
||||||
|
? String(co).toLowerCase()
|
||||||
|
: 'rgb';
|
||||||
|
}
|
||||||
|
if (wStart) {
|
||||||
|
const sm = (dev && dev.wifi_startup_mode) || 'default';
|
||||||
|
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
|
||||||
|
? String(sm).toLowerCase()
|
||||||
|
: 'default';
|
||||||
|
}
|
||||||
|
const obr = document.getElementById('edit-device-output-brightness');
|
||||||
|
const obv = document.getElementById('edit-device-output-brightness-value');
|
||||||
|
if (obr) {
|
||||||
|
let bv = 255;
|
||||||
|
if (dev && dev.output_brightness != null && dev.output_brightness !== '') {
|
||||||
|
const n = parseInt(String(dev.output_brightness), 10);
|
||||||
|
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
|
||||||
|
}
|
||||||
|
obr.value = String(bv);
|
||||||
|
if (obv) obv.textContent = String(bv);
|
||||||
|
}
|
||||||
|
refreshEditDeviceDebug();
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateDevice(devId, name, type, transport, address) {
|
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
|
||||||
try {
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
type: type || 'led',
|
||||||
|
transport: transport || 'espnow',
|
||||||
|
address,
|
||||||
|
};
|
||||||
|
if (typeof outputBrightness === 'number') {
|
||||||
|
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
|
||||||
|
}
|
||||||
|
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
|
||||||
|
if (wifiDriverFields.wifi_driver_display_name != null) {
|
||||||
|
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_driver_num_leds != null) {
|
||||||
|
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_color_order != null) {
|
||||||
|
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_startup_mode != null) {
|
||||||
|
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
name,
|
|
||||||
type: type || 'led',
|
|
||||||
transport: transport || 'espnow',
|
|
||||||
address,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -354,6 +481,41 @@ async function updateDevice(devId, name, type, transport, address) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function pushWifiDriverConfig(devId, fields) {
|
||||||
|
const push = {};
|
||||||
|
if (fields.name != null && String(fields.name).trim()) push.name = String(fields.name).trim();
|
||||||
|
if (fields.num_leds != null && fields.num_leds !== '') {
|
||||||
|
const n = parseInt(String(fields.num_leds), 10);
|
||||||
|
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
|
||||||
|
}
|
||||||
|
if (fields.color_order != null && String(fields.color_order).trim()) {
|
||||||
|
push.color_order = String(fields.color_order).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
if (fields.startup_mode != null && String(fields.startup_mode).trim()) {
|
||||||
|
const sm = String(fields.startup_mode).trim().toLowerCase();
|
||||||
|
if (sm === 'default' || sm === 'last' || sm === 'off') push.startup_mode = sm;
|
||||||
|
}
|
||||||
|
if (Object.keys(push).length === 0) return { ok: true, skipped: true };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${encodeURIComponent(devId)}/driver-config`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(push),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(data.error || 'Could not send settings to the driver (is it connected?)');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('pushWifiDriverConfig:', e);
|
||||||
|
alert('Could not send settings to the driver');
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.addEventListener('deviceTcpStatus', (ev) => {
|
window.addEventListener('deviceTcpStatus', (ev) => {
|
||||||
const { ip, connected } = ev.detail || {};
|
const { ip, connected } = ev.detail || {};
|
||||||
@@ -380,10 +542,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||||
|
|
||||||
|
const devOutBr = document.getElementById('edit-device-output-brightness');
|
||||||
|
const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
|
||||||
|
if (devOutBr && devOutBrVal) {
|
||||||
|
devOutBr.addEventListener('input', () => {
|
||||||
|
devOutBrVal.textContent = devOutBr.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const transportEdit = document.getElementById('edit-device-transport');
|
const transportEdit = document.getElementById('edit-device-transport');
|
||||||
if (transportEdit) {
|
if (transportEdit) {
|
||||||
transportEdit.addEventListener('change', () => {
|
transportEdit.addEventListener('change', () => {
|
||||||
applyTransportVisibility(transportEdit.value);
|
applyTransportVisibility(transportEdit.value);
|
||||||
|
refreshEditDeviceDebug();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,24 +591,67 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editForm) {
|
if (editForm) {
|
||||||
|
editForm.addEventListener('input', () => refreshEditDeviceDebug());
|
||||||
|
editForm.addEventListener('change', () => refreshEditDeviceDebug());
|
||||||
editForm.addEventListener('submit', async (e) => {
|
editForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const idInput = document.getElementById('edit-device-id');
|
const { devId, payload } = collectDeviceEditPayload();
|
||||||
const nameInput = document.getElementById('edit-device-name');
|
|
||||||
const typeSel = document.getElementById('edit-device-type');
|
|
||||||
const transportSel = document.getElementById('edit-device-transport');
|
|
||||||
const devId = idInput && idInput.value;
|
|
||||||
if (!devId) return;
|
if (!devId) return;
|
||||||
const transport = (transportSel && transportSel.value) || 'espnow';
|
const transport = payload.transport || 'espnow';
|
||||||
const address = getAddressForPayload(transport);
|
let wifiDriverFields = null;
|
||||||
|
if (transport === 'wifi') {
|
||||||
|
wifiDriverFields = {};
|
||||||
|
if (payload.wifi_driver_display_name != null) {
|
||||||
|
wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name;
|
||||||
|
}
|
||||||
|
if (payload.wifi_driver_num_leds != null) {
|
||||||
|
wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds;
|
||||||
|
}
|
||||||
|
if (payload.wifi_color_order != null) {
|
||||||
|
wifiDriverFields.wifi_color_order = payload.wifi_color_order;
|
||||||
|
}
|
||||||
|
if (payload.wifi_startup_mode != null) {
|
||||||
|
wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
const ok = await updateDevice(
|
const ok = await updateDevice(
|
||||||
devId,
|
devId,
|
||||||
nameInput ? nameInput.value.trim() : '',
|
payload.name,
|
||||||
(typeSel && typeSel.value) || 'led',
|
payload.type,
|
||||||
transport,
|
transport,
|
||||||
address
|
payload.address,
|
||||||
|
wifiDriverFields,
|
||||||
|
payload.output_brightness,
|
||||||
);
|
);
|
||||||
if (ok) editDeviceModal.classList.remove('active');
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
if (!brRes.ok && brRes.status !== 503) {
|
||||||
|
const brData = await brRes.json().catch(() => ({}));
|
||||||
|
console.warn('brightness push:', brData.error || brRes.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('brightness push failed', e);
|
||||||
|
}
|
||||||
|
if (transport === 'wifi' && wifiDriverFields) {
|
||||||
|
const dn = document.getElementById('edit-device-wifi-driver-name');
|
||||||
|
const nl = document.getElementById('edit-device-wifi-num-leds');
|
||||||
|
const co = document.getElementById('edit-device-wifi-color-order');
|
||||||
|
const ws = document.getElementById('edit-device-wifi-startup-mode');
|
||||||
|
const pushRes = await pushWifiDriverConfig(devId, {
|
||||||
|
name: dn ? dn.value : '',
|
||||||
|
num_leds: nl ? nl.value : '',
|
||||||
|
color_order: co ? co.value : '',
|
||||||
|
startup_mode: ws ? ws.value : '',
|
||||||
|
});
|
||||||
|
if (!pushRes.ok) return;
|
||||||
|
}
|
||||||
|
editDeviceModal.classList.remove('active');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (editCloseBtn) {
|
if (editCloseBtn) {
|
||||||
|
|||||||
571
src/static/groups.js
Normal file
571
src/static/groups.js
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
// Device groups: members (MAC ids) + Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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 Wi‑Fi 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -33,6 +33,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return Number.isFinite(t) ? t : def;
|
return Number.isFinite(t) ? t : def;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coercePresetAuto = (preset) => {
|
||||||
|
if (!preset || typeof preset !== 'object') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const v =
|
||||||
|
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
|
||||||
|
if (typeof v === 'boolean') {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (v === 0 || v === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (v === 1 || v === '1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
const l = v.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(l)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(l)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const getCurrentProfileId = async () => {
|
const getCurrentProfileId = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
|
||||||
@@ -531,6 +558,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
? preset.colors
|
? preset.colors
|
||||||
: ['#FFFFFF'];
|
: ['#FFFFFF'];
|
||||||
|
const presetAuto = coercePresetAuto(preset);
|
||||||
wirePresets[presetId] = {
|
wirePresets[presetId] = {
|
||||||
pattern: preset.pattern || 'off',
|
pattern: preset.pattern || 'off',
|
||||||
colors,
|
colors,
|
||||||
@@ -538,7 +566,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
brightness: typeof preset.brightness === 'number'
|
brightness: typeof preset.brightness === 'number'
|
||||||
? preset.brightness
|
? preset.brightness
|
||||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
auto: presetAuto,
|
||||||
|
a: presetAuto,
|
||||||
n1: coercePresetInt(preset.n1),
|
n1: coercePresetInt(preset.n1),
|
||||||
n2: coercePresetInt(preset.n2),
|
n2: coercePresetInt(preset.n2),
|
||||||
n3: coercePresetInt(preset.n3),
|
n3: coercePresetInt(preset.n3),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1176
src/static/sequences.js
Normal file
1176
src/static/sequences.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -558,7 +653,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 +806,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 +905,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 +1110,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 +1214,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 +1254,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 {
|
||||||
@@ -1379,6 +1620,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;
|
||||||
|
|||||||
@@ -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,32 @@ function escapeHtmlAttr(s) {
|
|||||||
.replace(/</g, "<");
|
.replace(/</g, "<");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @returns {null | 'presets' | 'sequences'} */
|
||||||
|
function normalizeZoneContentKind(zoneDoc) {
|
||||||
|
const k = zoneDoc && zoneDoc.content_kind;
|
||||||
|
if (k === 'presets' || k === 'sequences') return k;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
vis(groupsBlock, true);
|
||||||
|
if (!kind) {
|
||||||
|
vis(presetsBlock, true);
|
||||||
|
vis(seqBlock, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vis(presetsBlock, kind === 'presets');
|
||||||
|
vis(seqBlock, kind === 'sequences');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||||
|
|
||||||
// Load tabs list
|
// Load tabs list
|
||||||
async function loadZones() {
|
async function loadZones() {
|
||||||
try {
|
try {
|
||||||
@@ -294,13 +573,16 @@ 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}`;
|
let disp = zone.name || `Zone ${zoneId}`;
|
||||||
|
const kind = normalizeZoneContentKind(zone);
|
||||||
|
if (kind === 'presets') disp += ' · presets';
|
||||||
|
else if (kind === 'sequences') disp += ' · sequences';
|
||||||
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 +622,13 @@ 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;
|
let disp = (zone && zone.name) || zoneId;
|
||||||
|
const kind = normalizeZoneContentKind(zone);
|
||||||
|
if (kind === 'presets') disp += ' · presets';
|
||||||
|
else if (kind === 'sequences') disp += ' · sequences';
|
||||||
|
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 +825,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 +850,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;
|
||||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
if (typeof renderTabPresets === 'function') {
|
if (typeof renderTabPresets === 'function') {
|
||||||
@@ -639,8 +935,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 +978,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 +999,13 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabData = await tabRes.json();
|
const tabData = await tabRes.json();
|
||||||
|
const kind = normalizeZoneContentKind(tabData);
|
||||||
|
if (kind === 'sequences') {
|
||||||
|
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 +1029,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 +1046,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 +1111,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 +1128,30 @@ 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);
|
||||||
|
|
||||||
if (modal) modal.classList.add("active");
|
if (modal) modal.classList.add("active");
|
||||||
|
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData));
|
||||||
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) {
|
||||||
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 response = await fetch(`/zones/${zoneId}`, {
|
const response = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -863,7 +1159,9 @@ 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: {},
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -886,11 +1184,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 +1196,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 +1279,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 = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1007,15 +1311,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);
|
||||||
alert("Add at least one device.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await updateZone(zoneId, name, deviceNames);
|
|
||||||
editZoneForm.reset();
|
editZoneForm.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1049,9 +1348,14 @@ 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 () => {
|
||||||
await loadZones();
|
suppressZoneContentDriverSideEffects = true;
|
||||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
try {
|
||||||
await loadZonesModal();
|
await loadZones();
|
||||||
|
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||||
|
await loadZonesModal();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
suppressZoneContentDriverSideEffects = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1066,8 +1370,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;
|
||||||
|
|||||||
@@ -14,23 +14,34 @@
|
|||||||
Loading zones...
|
Loading zones...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-end">
|
||||||
|
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||||
|
<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-brightness-control">
|
<div class="header-brightness-control">
|
||||||
<label for="header-brightness-slider">Brightness</label>
|
<label for="header-brightness-slider">Brightness</label>
|
||||||
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
|
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="groups-btn">Groups</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="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>
|
||||||
<div class="header-menu-mobile">
|
<div class="header-menu-mobile">
|
||||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
@@ -40,14 +51,18 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -68,6 +83,11 @@
|
|||||||
<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>
|
||||||
|
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;">
|
||||||
|
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend>
|
||||||
|
<label style="margin-right:1rem;"><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>
|
||||||
|
</fieldset>
|
||||||
<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>
|
||||||
@@ -87,12 +107,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<label>Zone Name:</label>
|
<label>Zone Name:</label>
|
||||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||||
<label class="zone-devices-label">Devices in this zone</label>
|
<div id="edit-zone-block-groups">
|
||||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
<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>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,6 +159,78 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Device groups: members + Wi‑Fi driver defaults (zones reference groups for presets) -->
|
||||||
|
<div id="groups-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Device groups</h2>
|
||||||
|
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
|
||||||
|
<div class="profiles-actions zone-modal-create-row">
|
||||||
|
<input type="text" id="new-group-name" placeholder="Group name">
|
||||||
|
<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">
|
||||||
|
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<label for="edit-group-name">Group name</label>
|
||||||
|
<input type="text" id="edit-group-name" required autocomplete="off">
|
||||||
|
<label class="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 (0–255)</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;">Wi‑Fi driver defaults (apply to all members via <strong>Apply defaults to drivers</strong> on the list)</p>
|
||||||
|
<label for="edit-group-wifi-driver-name">Display name</label>
|
||||||
|
<input type="text" id="edit-group-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
|
||||||
|
<label for="edit-group-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
|
||||||
|
<input type="number" id="edit-group-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
|
||||||
|
<label for="edit-group-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
|
||||||
|
<select id="edit-group-wifi-color-order">
|
||||||
|
<option value="rgb">RGB</option>
|
||||||
|
<option value="rbg">RBG</option>
|
||||||
|
<option value="grb">GRB</option>
|
||||||
|
<option value="gbr">GBR</option>
|
||||||
|
<option value="brg">BRG</option>
|
||||||
|
<option value="bgr">BGR</option>
|
||||||
|
</select>
|
||||||
|
<label for="edit-group-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
|
||||||
|
<select id="edit-group-wifi-startup-mode">
|
||||||
|
<option value="default">Default preset</option>
|
||||||
|
<option value="last">Last preset</option>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
</select>
|
||||||
|
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
|
||||||
|
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
|
||||||
|
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
|
||||||
|
</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 +256,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 Wi‑Fi 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 (0–255)</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>
|
||||||
@@ -177,6 +310,51 @@
|
|||||||
</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="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" style="margin-top:0.75rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
|
<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 +380,26 @@
|
|||||||
<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>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
@@ -360,19 +558,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, Wi‑Fi driver defaults, then assign groups to zones.</li>
|
||||||
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
|
<li><strong>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 Wi‑Fi settings, or use <strong>Groups</strong> for shared defaults.</li>
|
||||||
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
<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 +588,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,6 +784,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Styles moved to /static/style.css -->
|
<!-- Styles moved to /static/style.css -->
|
||||||
|
<script src="/static/groups.js"></script>
|
||||||
<script src="/static/zones.js"></script>
|
<script src="/static/zones.js"></script>
|
||||||
<script src="/static/help.js"></script>
|
<script src="/static/help.js"></script>
|
||||||
<script src="/static/led_tool.js"></script>
|
<script src="/static/led_tool.js"></script>
|
||||||
@@ -542,6 +793,8 @@
|
|||||||
<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
301
src/util/audio_detector.py
Normal 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
|
||||||
52
src/util/audio_run_persist.py
Normal file
52
src/util/audio_run_persist.py
Normal 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}")
|
||||||
578
src/util/beat_driver_route.py
Normal file
578
src/util/beat_driver_route.py
Normal 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}")
|
||||||
86
src/util/brightness_combine.py
Normal file
86
src/util/brightness_combine.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
Combine global, group, device (and optional zone) brightness into one 0–255 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)
|
||||||
@@ -78,13 +78,49 @@ 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 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,13 +155,34 @@ 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),
|
||||||
@@ -137,13 +194,14 @@ def build_preset_dict(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 +221,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1069
src/util/sequence_playback.py
Normal file
1069
src/util/sequence_playback.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
tests/audio/beat_test_120bpm.wav
Normal file
BIN
tests/audio/beat_test_120bpm.wav
Normal file
Binary file not shown.
375
tests/beat_detect.py
Normal file
375
tests/beat_detect.py
Normal 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())
|
||||||
75
tests/make_bpm_test_audio.py
Normal file
75
tests/make_bpm_test_audio.py
Normal 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())
|
||||||
@@ -1,62 +1,83 @@
|
|||||||
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
|
||||||
|
|
||||||
print("\nTesting read sequence")
|
print("\nTesting read 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()
|
||||||
print(f"Sequence list: {sequence_list}")
|
print(f"Sequence list: {sequence_list}")
|
||||||
assert sequence_id in sequence_list
|
assert sequence_id in sequence_list
|
||||||
|
|
||||||
print("\nTesting delete sequence")
|
print("\nTesting delete sequence")
|
||||||
deleted = sequences.delete(sequence_id)
|
deleted = sequences.delete(sequence_id)
|
||||||
assert deleted is True
|
assert deleted is True
|
||||||
assert sequence_id not in sequences
|
assert sequence_id not in sequences
|
||||||
|
|
||||||
print("\nTesting read after delete")
|
print("\nTesting read after delete")
|
||||||
sequence = sequences.read(sequence_id)
|
sequence = sequences.read(sequence_id)
|
||||||
assert sequence is None
|
assert sequence is None
|
||||||
|
|
||||||
print("\nAll sequence tests passed!")
|
print("\nAll sequence tests passed!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
test_sequence()
|
test_sequence()
|
||||||
|
|||||||
171
tests/play_varying_click_track.py
Normal file
171
tests/play_varying_click_track.py
Normal 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())
|
||||||
@@ -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)))
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,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 (
|
||||||
@@ -527,21 +527,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
|
||||||
|
|||||||
221
tools/generate_beat_test_track.py
Normal file
221
tools/generate_beat_test_track.py
Normal 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()
|
||||||
Reference in New Issue
Block a user