Compare commits
36 Commits
7ccab6fbc4
...
p2p
| Author | SHA1 | Date | |
|---|---|---|---|
| d682753e42 | |||
| 53976cdd70 | |||
| 94635a8cc7 | |||
| de0547615c | |||
| 78dc8ffc77 | |||
| 2cf019079e | |||
| b87382d2be | |||
| 1a69fabd98 | |||
| 4fc3f46866 | |||
| f4ef85c182 | |||
| f02eaa6bad | |||
| 7015032f5c | |||
| d7a3fa96c5 | |||
| 7a7bedc07c | |||
| baec87068a | |||
| b140aedf00 | |||
| 15f8c8a039 | |||
| 70641c63af | |||
| ef15c54593 | |||
| 301e1c64bf | |||
| c286e504eb | |||
| 964cfc6d91 | |||
| 7ecb5c3b3e | |||
| 879db2a7df | |||
| 96d1e1b5fd | |||
| 6286297646 | |||
| ca3fef3f8a | |||
| 6c9e06f33b | |||
| c1c3e5d71b | |||
| c64dd736f2 | |||
| cad0aa7e59 | |||
| 0ae39ab94b | |||
| 822d9d8e01 | |||
| 1db905eaae | |||
| 3d6ef5c7b4 | |||
| 78a4ce009c |
@@ -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.
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -28,6 +28,17 @@ Thumbs.db
|
|||||||
scripts/.led-controller-venv
|
scripts/.led-controller-venv
|
||||||
docs/.help-print.html
|
docs/.help-print.html
|
||||||
settings.json
|
settings.json
|
||||||
|
# Track shared JSON + preset binaries; ignore other db/*.json (e.g. device, zone) locally
|
||||||
|
db/*
|
||||||
|
!db/group.json
|
||||||
|
!db/palette.json
|
||||||
|
!db/pattern.json
|
||||||
|
!db/preset.json
|
||||||
|
!db/profile.json
|
||||||
|
!db/scene.json
|
||||||
|
!db/sequence.json
|
||||||
|
!db/presets/
|
||||||
|
!db/presets/*.bin
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
14
Pipfile
14
Pipfile
@@ -6,6 +6,7 @@ name = "pypi"
|
|||||||
[packages]
|
[packages]
|
||||||
mpremote = "*"
|
mpremote = "*"
|
||||||
pyserial = "*"
|
pyserial = "*"
|
||||||
|
pyserial-asyncio = "*"
|
||||||
esptool = "*"
|
esptool = "*"
|
||||||
pyjwt = "*"
|
pyjwt = "*"
|
||||||
watchfiles = "*"
|
watchfiles = "*"
|
||||||
@@ -14,6 +15,8 @@ selenium = "*"
|
|||||||
adafruit-ampy = "*"
|
adafruit-ampy = "*"
|
||||||
microdot = "*"
|
microdot = "*"
|
||||||
websockets = "*"
|
websockets = "*"
|
||||||
|
numpy = "*"
|
||||||
|
sounddevice = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
pytest = "*"
|
pytest = "*"
|
||||||
@@ -22,9 +25,10 @@ pytest = "*"
|
|||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
|
|
||||||
[scripts]
|
[scripts]
|
||||||
web = "python /home/pi/led-controller/tests/web.py"
|
web = "python tests/web.py"
|
||||||
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
watch = "python -m watchfiles \"python tests/web.py\" src tests"
|
||||||
install = "pipenv install"
|
|
||||||
run = "sh -c 'cd src && python main.py'"
|
run = "sh -c 'cd src && python main.py'"
|
||||||
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
dev = "python -m watchfiles \"sh -c 'cd src && LED_CONTROLLER_LIVE_RELOAD=1 python main.py'\" src"
|
||||||
help-pdf = "sh scripts/build_help_pdf.sh"
|
test = "python -m pytest"
|
||||||
|
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
|
||||||
|
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"
|
||||||
|
|||||||
497
Pipfile.lock
generated
497
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "98da2012e549e7b62ed49a5e1717acaf535b71e8df61bf4108d25b9023be612e"
|
"sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -159,11 +159,11 @@
|
|||||||
},
|
},
|
||||||
"certifi": {
|
"certifi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
|
"sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897",
|
||||||
"sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"
|
"sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==2026.4.22"
|
"version": "==2026.5.20"
|
||||||
},
|
},
|
||||||
"cffi": {
|
"cffi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -252,7 +252,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": {
|
||||||
@@ -392,72 +392,73 @@
|
|||||||
},
|
},
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2",
|
"sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2",
|
||||||
"sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
|
"sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==8.3.3"
|
"version": "==8.4.1"
|
||||||
},
|
},
|
||||||
"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": {
|
||||||
@@ -470,11 +471,11 @@
|
|||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242",
|
"sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5",
|
||||||
"sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
|
"sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==3.13"
|
"version": "==3.16"
|
||||||
},
|
},
|
||||||
"intelhex": {
|
"intelhex": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -485,11 +486,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": [
|
||||||
@@ -501,11 +502,12 @@
|
|||||||
},
|
},
|
||||||
"microdot": {
|
"microdot": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3ba8bab39ae52bca08ee7024dfc71afb7cff089f0b6611d2a1f617abfcee749c",
|
"sha256:206c52870e3b1d5e6d387802e2ed0afae8c4598c80542a21e3efe377efc128c8",
|
||||||
"sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721"
|
"sha256:7bb9a69fa97a47d8fe07e61d9dd405804744132ca52d26705cf1173431ff7f4b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.6.1"
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==2.6.2"
|
||||||
},
|
},
|
||||||
"mpremote": {
|
"mpremote": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -513,8 +515,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:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1",
|
||||||
|
"sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4",
|
||||||
|
"sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f",
|
||||||
|
"sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079",
|
||||||
|
"sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096",
|
||||||
|
"sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47",
|
||||||
|
"sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66",
|
||||||
|
"sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d",
|
||||||
|
"sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1",
|
||||||
|
"sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e",
|
||||||
|
"sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147",
|
||||||
|
"sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd",
|
||||||
|
"sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75",
|
||||||
|
"sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063",
|
||||||
|
"sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73",
|
||||||
|
"sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab",
|
||||||
|
"sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4",
|
||||||
|
"sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41",
|
||||||
|
"sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402",
|
||||||
|
"sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698",
|
||||||
|
"sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7",
|
||||||
|
"sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8",
|
||||||
|
"sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b",
|
||||||
|
"sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8",
|
||||||
|
"sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0",
|
||||||
|
"sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662",
|
||||||
|
"sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91",
|
||||||
|
"sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0",
|
||||||
|
"sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f",
|
||||||
|
"sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3",
|
||||||
|
"sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f",
|
||||||
|
"sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67",
|
||||||
|
"sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6",
|
||||||
|
"sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997",
|
||||||
|
"sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b",
|
||||||
|
"sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e",
|
||||||
|
"sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538",
|
||||||
|
"sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627",
|
||||||
|
"sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93",
|
||||||
|
"sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02",
|
||||||
|
"sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853",
|
||||||
|
"sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c",
|
||||||
|
"sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43",
|
||||||
|
"sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd",
|
||||||
|
"sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8",
|
||||||
|
"sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089",
|
||||||
|
"sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778",
|
||||||
|
"sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1",
|
||||||
|
"sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb",
|
||||||
|
"sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261",
|
||||||
|
"sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb",
|
||||||
|
"sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a",
|
||||||
|
"sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8",
|
||||||
|
"sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359",
|
||||||
|
"sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5",
|
||||||
|
"sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7",
|
||||||
|
"sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751",
|
||||||
|
"sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8",
|
||||||
|
"sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605",
|
||||||
|
"sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e",
|
||||||
|
"sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45",
|
||||||
|
"sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2",
|
||||||
|
"sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895",
|
||||||
|
"sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe",
|
||||||
|
"sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb",
|
||||||
|
"sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a",
|
||||||
|
"sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577",
|
||||||
|
"sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d",
|
||||||
|
"sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a",
|
||||||
|
"sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda",
|
||||||
|
"sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6",
|
||||||
|
"sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"markers": "python_version >= '3.11'",
|
||||||
|
"version": "==2.4.6"
|
||||||
|
},
|
||||||
"outcome": {
|
"outcome": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||||
@@ -549,11 +631,12 @@
|
|||||||
},
|
},
|
||||||
"pyjwt": {
|
"pyjwt": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
"sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423",
|
||||||
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
"sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.12.1"
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==2.13.0"
|
||||||
},
|
},
|
||||||
"pyserial": {
|
"pyserial": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -563,6 +646,14 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.5"
|
"version": "==3.5"
|
||||||
},
|
},
|
||||||
|
"pyserial-asyncio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f",
|
||||||
|
"sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.6"
|
||||||
|
},
|
||||||
"pysocks": {
|
"pysocks": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
||||||
@@ -667,11 +758,12 @@
|
|||||||
},
|
},
|
||||||
"requests": {
|
"requests": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
|
"sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0",
|
||||||
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
|
"sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.33.1"
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==2.34.2"
|
||||||
},
|
},
|
||||||
"rich": {
|
"rich": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -691,11 +783,12 @@
|
|||||||
},
|
},
|
||||||
"selenium": {
|
"selenium": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
|
"sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e",
|
||||||
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
|
"sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.43.0"
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==4.44.0"
|
||||||
},
|
},
|
||||||
"sniffio": {
|
"sniffio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -712,6 +805,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,128 +880,129 @@
|
|||||||
"version": "==4.15.0"
|
"version": "==4.15.0"
|
||||||
},
|
},
|
||||||
"urllib3": {
|
"urllib3": {
|
||||||
"extras": [],
|
"extras": [
|
||||||
"hashes": [
|
"socks"
|
||||||
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
|
||||||
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.9'",
|
"hashes": [
|
||||||
"version": "==2.6.3"
|
"sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c",
|
||||||
|
"sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==2.7.0"
|
||||||
},
|
},
|
||||||
"watchfiles": {
|
"watchfiles": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
|
||||||
"sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43",
|
"sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98",
|
||||||
"sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510",
|
"sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551",
|
||||||
"sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0",
|
"sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d",
|
||||||
"sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2",
|
"sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7",
|
||||||
"sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b",
|
"sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db",
|
||||||
"sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18",
|
"sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69",
|
||||||
"sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219",
|
"sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242",
|
||||||
"sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3",
|
"sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925",
|
||||||
"sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4",
|
"sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f",
|
||||||
"sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803",
|
"sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5",
|
||||||
"sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94",
|
"sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5",
|
||||||
"sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6",
|
"sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427",
|
||||||
"sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce",
|
"sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19",
|
||||||
"sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099",
|
"sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4",
|
||||||
"sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae",
|
"sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e",
|
||||||
"sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4",
|
"sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa",
|
||||||
"sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43",
|
"sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba",
|
||||||
"sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd",
|
"sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df",
|
||||||
"sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10",
|
"sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c",
|
||||||
"sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374",
|
"sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906",
|
||||||
"sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051",
|
"sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65",
|
||||||
"sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d",
|
"sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c",
|
||||||
"sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34",
|
"sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c",
|
||||||
"sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49",
|
"sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30",
|
||||||
"sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7",
|
"sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077",
|
||||||
"sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844",
|
"sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374",
|
||||||
"sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77",
|
"sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01",
|
||||||
"sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b",
|
"sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33",
|
||||||
"sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741",
|
"sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831",
|
||||||
"sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e",
|
"sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9",
|
||||||
"sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33",
|
"sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2",
|
||||||
"sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42",
|
"sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b",
|
||||||
"sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab",
|
"sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f",
|
||||||
"sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc",
|
"sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658",
|
||||||
"sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5",
|
"sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579",
|
||||||
"sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da",
|
"sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5",
|
||||||
"sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e",
|
"sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0",
|
||||||
"sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05",
|
"sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7",
|
||||||
"sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a",
|
"sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666",
|
||||||
"sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d",
|
"sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5",
|
||||||
"sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701",
|
"sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201",
|
||||||
"sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863",
|
"sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103",
|
||||||
"sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2",
|
"sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6",
|
||||||
"sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101",
|
"sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8",
|
||||||
"sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02",
|
"sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1",
|
||||||
"sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b",
|
"sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631",
|
||||||
"sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6",
|
"sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898",
|
||||||
"sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb",
|
"sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d",
|
||||||
"sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620",
|
"sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44",
|
||||||
"sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957",
|
"sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2",
|
||||||
"sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6",
|
"sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5",
|
||||||
"sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d",
|
"sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a",
|
||||||
"sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956",
|
"sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1",
|
||||||
"sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef",
|
"sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b",
|
||||||
"sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261",
|
"sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc",
|
||||||
"sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02",
|
"sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5",
|
||||||
"sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af",
|
"sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377",
|
||||||
"sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9",
|
"sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8",
|
||||||
"sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21",
|
"sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add",
|
||||||
"sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336",
|
"sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281",
|
||||||
"sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d",
|
"sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9",
|
||||||
"sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c",
|
"sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994",
|
||||||
"sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31",
|
"sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0",
|
||||||
"sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81",
|
"sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e",
|
||||||
"sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9",
|
"sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0",
|
||||||
"sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff",
|
"sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28",
|
||||||
"sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2",
|
"sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7",
|
||||||
"sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e",
|
"sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55",
|
||||||
"sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc",
|
"sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb",
|
||||||
"sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404",
|
"sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07",
|
||||||
"sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01",
|
"sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb",
|
||||||
"sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18",
|
"sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4",
|
||||||
"sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3",
|
"sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0",
|
||||||
"sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606",
|
"sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e",
|
||||||
"sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04",
|
"sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4",
|
||||||
"sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3",
|
"sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9",
|
||||||
"sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14",
|
"sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06",
|
||||||
"sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c",
|
"sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26",
|
||||||
"sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82",
|
"sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7",
|
||||||
"sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610",
|
"sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4",
|
||||||
"sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0",
|
"sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3",
|
||||||
"sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150",
|
"sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3",
|
||||||
"sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5",
|
"sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838",
|
||||||
"sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c",
|
"sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71",
|
||||||
"sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a",
|
"sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488",
|
||||||
"sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b",
|
"sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717",
|
||||||
"sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d",
|
"sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d",
|
||||||
"sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70",
|
"sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44",
|
||||||
"sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70",
|
"sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2",
|
||||||
"sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f",
|
"sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b",
|
||||||
"sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24",
|
"sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2",
|
||||||
"sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e",
|
"sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22",
|
||||||
"sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be",
|
"sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6",
|
||||||
"sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5",
|
"sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e",
|
||||||
"sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e",
|
"sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310",
|
||||||
"sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f",
|
"sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165",
|
||||||
"sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88",
|
"sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5",
|
||||||
"sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb",
|
"sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799",
|
||||||
"sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849",
|
"sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8",
|
||||||
"sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d",
|
"sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7",
|
||||||
"sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c",
|
"sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379",
|
||||||
"sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44",
|
"sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925",
|
||||||
"sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac",
|
"sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72",
|
||||||
"sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428",
|
"sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4",
|
||||||
"sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b",
|
"sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08",
|
||||||
"sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5",
|
"sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"
|
||||||
"sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa",
|
|
||||||
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
|
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.1.1"
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==1.2.0"
|
||||||
},
|
},
|
||||||
"websocket-client": {
|
"websocket-client": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -970,6 +1077,7 @@
|
|||||||
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
|
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==16.0"
|
"version": "==16.0"
|
||||||
},
|
},
|
||||||
"wsproto": {
|
"wsproto": {
|
||||||
@@ -1020,6 +1128,7 @@
|
|||||||
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
|
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==9.0.3"
|
"version": "==9.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# led-controller
|
# led-controller
|
||||||
|
|
||||||
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices. Outbound paths include:
|
LED controller web app for managing profiles, **zones**, presets, and colour palettes, and sending commands to LED devices over **ESP-NOW** (binary wire format).
|
||||||
|
|
||||||
- **Serial → ESP-NOW bridge**: JSON lines over UART to an ESP32 that forwards ESP-NOW frames (configure `serial_port` and baud in `settings.json` / Settings model).
|
- **Bridge ESP32**: runs a WebSocket server; the Pi connects as client (`bridge_ws_url` in `settings.json`, e.g. `ws://192.168.4.1/ws`).
|
||||||
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
|
- **LED drivers**: announce on boot via ESP-NOW broadcast; the controller registers them and pushes group membership.
|
||||||
|
- Architecture (diagrams): [docs/espnow-architecture.md](docs/espnow-architecture.md)
|
||||||
|
- Wire format (byte layouts): [docs/espnow-binary-protocol.md](docs/espnow-binary-protocol.md) (≤250 bytes per frame, no JSON on the wire)
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
|
|||||||
19
bridge-serial/README.md
Normal file
19
bridge-serial/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# bridge-serial
|
||||||
|
|
||||||
|
ESP32 ESP-NOW bridge with **USB/serial** uplink to the Pi (GPIO UART). Sync loop only — no asyncio, no Microdot.
|
||||||
|
|
||||||
|
```
|
||||||
|
bridge-serial/
|
||||||
|
src/
|
||||||
|
main.py # entry
|
||||||
|
settings.py # /settings.json on device
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bridge-serial
|
||||||
|
python ../led-tool/cli.py -p /dev/ttyUSB0 --src -r -f
|
||||||
|
```
|
||||||
|
|
||||||
|
No `--lib` required. Match `serial_baudrate` on the ESP and Pi (e.g. `921600`).
|
||||||
166
bridge-serial/src/main.py
Normal file
166
bridge-serial/src/main.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""ESP-NOW bridge: Pi USB-serial downlink, ESP-NOW to drivers (sync loop)."""
|
||||||
|
|
||||||
|
import gc, json, struct, time
|
||||||
|
import espnow, machine, network
|
||||||
|
from machine import Pin, UART
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
|
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
WIRE = 0x4C
|
||||||
|
MAX_SERIAL = 4096
|
||||||
|
MAX_ESPNOW = 250
|
||||||
|
ESPNOW_EXIST = -12395
|
||||||
|
ESPNOW_FULL = -12392
|
||||||
|
|
||||||
|
|
||||||
|
def add_peer_if_needed(esp, dest, ch):
|
||||||
|
try:
|
||||||
|
esp.add_peer(dest, channel=ch)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
esp.add_peer(dest)
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def del_peer_if_present(esp, dest):
|
||||||
|
try:
|
||||||
|
esp.del_peer(dest)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def send_unicast_temp_peer(esp, dest, ch, pkt):
|
||||||
|
try:
|
||||||
|
add_peer_if_needed(esp, dest, ch)
|
||||||
|
except OSError as e:
|
||||||
|
if e.args and e.args[0] == ESPNOW_FULL:
|
||||||
|
del_peer_if_present(esp, dest)
|
||||||
|
add_peer_if_needed(esp, dest, ch)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
esp.send(dest, pkt, True)
|
||||||
|
finally:
|
||||||
|
del_peer_if_present(esp, dest)
|
||||||
|
|
||||||
|
|
||||||
|
def init_radio(ch, name, password):
|
||||||
|
network.WLAN(network.STA_IF).active(False)
|
||||||
|
network.WLAN(network.AP_IF).active(False)
|
||||||
|
time.sleep_ms(100)
|
||||||
|
ap = network.WLAN(network.AP_IF)
|
||||||
|
ap.active(True)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
if password:
|
||||||
|
try:
|
||||||
|
ap.config(essid=name or "bridge", password=password, channel=ch, hidden=True)
|
||||||
|
except TypeError:
|
||||||
|
ap.config(essid=name or "bridge", channel=ch)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ap.config(essid=name or "bridge", channel=ch, hidden=True)
|
||||||
|
except TypeError:
|
||||||
|
ap.config(essid=name or "bridge", channel=ch)
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
sta.config(pm=network.WLAN.PM_NONE)
|
||||||
|
try:
|
||||||
|
sta.config(channel=ch)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def mac_bytes(addr):
|
||||||
|
h = str(addr).replace(":", "").replace("-", "").strip().lower()
|
||||||
|
return bytes.fromhex(h)
|
||||||
|
|
||||||
|
|
||||||
|
def read_serial(uart, buf):
|
||||||
|
if uart.any():
|
||||||
|
buf.extend(uart.read(min(uart.any(), 256)))
|
||||||
|
out = []
|
||||||
|
while len(buf) >= 2:
|
||||||
|
n = (buf[0] << 8) | buf[1]
|
||||||
|
if n > MAX_SERIAL:
|
||||||
|
buf[:] = buf[1:]
|
||||||
|
continue
|
||||||
|
need = 2 + n
|
||||||
|
if len(buf) < need:
|
||||||
|
break
|
||||||
|
out.append(bytes(buf[2:need]))
|
||||||
|
buf[:] = buf[need:]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def downlink(esp, ch, raw):
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
if raw[0] == WIRE:
|
||||||
|
if len(raw) < 2:
|
||||||
|
return
|
||||||
|
esp.send(BROADCAST, raw, True)
|
||||||
|
return
|
||||||
|
if len(raw) < 8 or raw[0] != ord("{"):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
devs = data.get("dv") or data.get("devices")
|
||||||
|
if data.get("v") != "1" or not isinstance(devs, dict):
|
||||||
|
return
|
||||||
|
for mac_s, body in devs.items():
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
msg = {"v": "1"}
|
||||||
|
msg.update(body)
|
||||||
|
pkt = json.dumps(msg, separators=(",", ":")).encode()
|
||||||
|
if len(pkt) > MAX_ESPNOW:
|
||||||
|
continue
|
||||||
|
dest = mac_bytes(mac_s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if dest == BROADCAST:
|
||||||
|
esp.send(BROADCAST, pkt, True)
|
||||||
|
else:
|
||||||
|
send_unicast_temp_peer(esp, dest, ch, pkt)
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
s = Settings()
|
||||||
|
ch = max(1, min(11, int(s.get("wifi_channel", 5))))
|
||||||
|
init_radio(ch, s.get("name"), s.get("ap_password") or "")
|
||||||
|
baud = int(s.get("serial_baudrate", 921600))
|
||||||
|
uart = UART(
|
||||||
|
int(s.get("serial_uart_id", 1)),
|
||||||
|
baud,
|
||||||
|
tx=Pin(int(s.get("serial_tx_pin", 2))),
|
||||||
|
rx=Pin(int(s.get("serial_rx_pin", 3))),
|
||||||
|
)
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
add_peer_if_needed(esp, BROADCAST, ch)
|
||||||
|
print("bridge ch", ch, "baud", baud, "heap", gc.mem_free())
|
||||||
|
|
||||||
|
wdt = machine.WDT(timeout=10000)
|
||||||
|
rx_buf = bytearray()
|
||||||
|
while True:
|
||||||
|
wdt.feed()
|
||||||
|
for frame in read_serial(uart, rx_buf):
|
||||||
|
try:
|
||||||
|
downlink(esp, ch, frame)
|
||||||
|
except OSError as e:
|
||||||
|
print("dl", e)
|
||||||
|
host, msg = esp.recv(0)
|
||||||
|
if host:
|
||||||
|
up = bytes([0]) + host + msg
|
||||||
|
uart.write(struct.pack(">H", len(up)) + up)
|
||||||
|
else:
|
||||||
|
time.sleep_ms(1)
|
||||||
62
bridge-serial/src/settings.py
Normal file
62
bridge-serial/src/settings.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import ubinascii
|
||||||
|
import network
|
||||||
|
|
||||||
|
WIFI_CHANNEL_DEFAULT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _sta_mac_hex():
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
was_on = sta.active()
|
||||||
|
if not was_on:
|
||||||
|
sta.active(True)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
try:
|
||||||
|
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||||
|
except Exception:
|
||||||
|
mac = "000000000000"
|
||||||
|
if not was_on:
|
||||||
|
sta.active(False)
|
||||||
|
return mac
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(dict):
|
||||||
|
SETTINGS_FILE = "/settings.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def set_defaults(self):
|
||||||
|
self["name"] = "bridge-" + _sta_mac_hex()
|
||||||
|
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
|
||||||
|
self["ap_password"] = ""
|
||||||
|
self["serial_baudrate"] = 921600
|
||||||
|
self["serial_uart_id"] = 1
|
||||||
|
self["serial_tx_pin"] = 2
|
||||||
|
self["serial_rx_pin"] = 3
|
||||||
|
self["serial_usb"] = False
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
try:
|
||||||
|
with open(self.SETTINGS_FILE, "w") as f:
|
||||||
|
f.write(json.dumps(self))
|
||||||
|
except Exception as e:
|
||||||
|
print("save settings:", e)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
with open(self.SETTINGS_FILE, "r") as f:
|
||||||
|
loaded = json.load(f)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ValueError("not object")
|
||||||
|
except Exception:
|
||||||
|
self.clear()
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
self.clear()
|
||||||
|
self.set_defaults()
|
||||||
|
for k, v in loaded.items():
|
||||||
|
self[k] = v
|
||||||
22
bridge-wifi/README.md
Normal file
22
bridge-wifi/README.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# bridge-wifi
|
||||||
|
|
||||||
|
ESP32 ESP-NOW bridge with **Wi‑Fi AP + WebSocket** (`/ws`). Same ESP-NOW downlink as bridge-serial.
|
||||||
|
|
||||||
|
```
|
||||||
|
bridge-wifi/
|
||||||
|
src/
|
||||||
|
main.py
|
||||||
|
settings.py
|
||||||
|
wifi_ap.py
|
||||||
|
espnow_wire.py # uplink frame helper only
|
||||||
|
lib/microdot/ # WebSocket server
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bridge-wifi
|
||||||
|
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi: join bridge AP, `bridge_ws_url` → `ws://192.168.4.1/ws`.
|
||||||
2
bridge-wifi/lib/microdot/__init__.py
Normal file
2
bridge-wifi/lib/microdot/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||||
|
send_file # noqa: F401
|
||||||
8
bridge-wifi/lib/microdot/helpers.py
Normal file
8
bridge-wifi/lib/microdot/helpers.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
try:
|
||||||
|
from functools import wraps
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
# MicroPython does not currently implement functools.wraps
|
||||||
|
def wraps(wrapped):
|
||||||
|
def _(wrapper):
|
||||||
|
return wrapper
|
||||||
|
return _
|
||||||
1450
bridge-wifi/lib/microdot/microdot.py
Normal file
1450
bridge-wifi/lib/microdot/microdot.py
Normal file
File diff suppressed because it is too large
Load Diff
225
bridge-wifi/lib/microdot/session.py
Normal file
225
bridge-wifi/lib/microdot/session.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
HAS_JWT = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_JWT = False
|
||||||
|
try:
|
||||||
|
import ubinascii
|
||||||
|
except ImportError:
|
||||||
|
import binascii as ubinascii
|
||||||
|
try:
|
||||||
|
import uhashlib as hashlib
|
||||||
|
except ImportError:
|
||||||
|
import hashlib
|
||||||
|
try:
|
||||||
|
import uhmac as hmac
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import hmac
|
||||||
|
except ImportError:
|
||||||
|
hmac = None
|
||||||
|
import json
|
||||||
|
|
||||||
|
from microdot.microdot import invoke_handler
|
||||||
|
from microdot.helpers import wraps
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDict(dict):
|
||||||
|
"""A session dictionary.
|
||||||
|
|
||||||
|
The session dictionary is a standard Python dictionary that has been
|
||||||
|
extended with convenience ``save()`` and ``delete()`` methods.
|
||||||
|
"""
|
||||||
|
def __init__(self, request, session_dict):
|
||||||
|
super().__init__(session_dict)
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
"""Update the session cookie."""
|
||||||
|
self.request.app._session.update(self.request, self)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Delete the session cookie."""
|
||||||
|
self.request.app._session.delete(self.request)
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""Session handling
|
||||||
|
|
||||||
|
:param app: The application instance.
|
||||||
|
:param secret_key: The secret key, as a string or bytes object.
|
||||||
|
:param cookie_options: A dictionary with cookie options to pass as
|
||||||
|
arguments to :meth:`Response.set_cookie()
|
||||||
|
<microdot.Response.set_cookie>`.
|
||||||
|
"""
|
||||||
|
secret_key = None
|
||||||
|
|
||||||
|
def __init__(self, app=None, secret_key=None, cookie_options=None):
|
||||||
|
self.secret_key = secret_key
|
||||||
|
self.cookie_options = cookie_options or {}
|
||||||
|
if app is not None:
|
||||||
|
self.initialize(app)
|
||||||
|
|
||||||
|
def initialize(self, app, secret_key=None, cookie_options=None):
|
||||||
|
if secret_key is not None:
|
||||||
|
self.secret_key = secret_key
|
||||||
|
if cookie_options is not None:
|
||||||
|
self.cookie_options = cookie_options
|
||||||
|
if 'path' not in self.cookie_options:
|
||||||
|
self.cookie_options['path'] = '/'
|
||||||
|
if 'http_only' not in self.cookie_options:
|
||||||
|
self.cookie_options['http_only'] = True
|
||||||
|
app._session = self
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
"""Retrieve the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
|
||||||
|
The return value is a session dictionary with the data stored in the
|
||||||
|
user's session, or ``{}`` if the session data is not available or
|
||||||
|
invalid.
|
||||||
|
"""
|
||||||
|
if not self.secret_key:
|
||||||
|
raise ValueError('The session secret key is not configured')
|
||||||
|
if hasattr(request.g, '_session'):
|
||||||
|
return request.g._session
|
||||||
|
session = request.cookies.get('session')
|
||||||
|
if session is None:
|
||||||
|
request.g._session = SessionDict(request, {})
|
||||||
|
return request.g._session
|
||||||
|
request.g._session = SessionDict(request, self.decode(session))
|
||||||
|
return request.g._session
|
||||||
|
|
||||||
|
def update(self, request, session):
|
||||||
|
"""Update the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
:param session: A dictionary with the update session data for the user.
|
||||||
|
|
||||||
|
Applications would normally not call this method directly, instead they
|
||||||
|
would use the :meth:`SessionDict.save` method on the session
|
||||||
|
dictionary, which calls this method. For example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
session['foo'] = 'bar'
|
||||||
|
session.save()
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Calling this method adds a cookie with the updated session to the
|
||||||
|
request currently being processed.
|
||||||
|
"""
|
||||||
|
if not self.secret_key:
|
||||||
|
raise ValueError('The session secret key is not configured')
|
||||||
|
|
||||||
|
encoded_session = self.encode(session)
|
||||||
|
|
||||||
|
@request.after_request
|
||||||
|
def _update_session(request, response):
|
||||||
|
response.set_cookie('session', encoded_session,
|
||||||
|
**self.cookie_options)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def delete(self, request):
|
||||||
|
"""Remove the user session.
|
||||||
|
|
||||||
|
:param request: The client request.
|
||||||
|
|
||||||
|
Applications would normally not call this method directly, instead they
|
||||||
|
would use the :meth:`SessionDict.delete` method on the session
|
||||||
|
dictionary, which calls this method. For example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
session.delete()
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Calling this method adds a cookie removal header to the request
|
||||||
|
currently being processed.
|
||||||
|
"""
|
||||||
|
@request.after_request
|
||||||
|
def _delete_session(request, response):
|
||||||
|
response.delete_cookie('session', **self.cookie_options)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def encode(self, payload, secret_key=None):
|
||||||
|
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
|
if HAS_JWT:
|
||||||
|
return jwt.encode(payload, secret_key or self.secret_key,
|
||||||
|
algorithm='HS256')
|
||||||
|
else:
|
||||||
|
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||||
|
|
||||||
|
# Create HMAC signature
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
return f"{payload_b64}.{signature}"
|
||||||
|
|
||||||
|
def decode(self, session, secret_key=None):
|
||||||
|
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
|
if HAS_JWT:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||||
|
algorithms=['HS256'])
|
||||||
|
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||||
|
return {}
|
||||||
|
return payload
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Simple decoding for MicroPython
|
||||||
|
if '.' not in session:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload_b64, signature = session.rsplit('.', 1)
|
||||||
|
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||||
|
|
||||||
|
# Verify HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
if signature != expected_signature:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(payload_json)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def with_session(f):
|
||||||
|
"""Decorator that passes the user session to the route handler.
|
||||||
|
|
||||||
|
The session dictionary is passed to the decorated function as an argument
|
||||||
|
after the request object. Example::
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@with_session
|
||||||
|
def index(request, session):
|
||||||
|
return 'Hello, World!'
|
||||||
|
|
||||||
|
Note that the decorator does not save the session. To update the session,
|
||||||
|
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
async def wrapper(request, *args, **kwargs):
|
||||||
|
return await invoke_handler(
|
||||||
|
f, request, request.app._session.get(request), *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
70
bridge-wifi/lib/microdot/utemplate.py
Normal file
70
bridge-wifi/lib/microdot/utemplate.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from utemplate import recompile
|
||||||
|
|
||||||
|
_loader = None
|
||||||
|
|
||||||
|
|
||||||
|
class Template:
|
||||||
|
"""A template object.
|
||||||
|
|
||||||
|
:param template: The filename of the template to render, relative to the
|
||||||
|
configured template directory.
|
||||||
|
"""
|
||||||
|
@classmethod
|
||||||
|
def initialize(cls, template_dir='templates',
|
||||||
|
loader_class=recompile.Loader):
|
||||||
|
"""Initialize the templating subsystem.
|
||||||
|
|
||||||
|
:param template_dir: the directory where templates are stored. This
|
||||||
|
argument is optional. The default is to load
|
||||||
|
templates from a *templates* subdirectory.
|
||||||
|
:param loader_class: the ``utemplate.Loader`` class to use when loading
|
||||||
|
templates. This argument is optional. The default
|
||||||
|
is the ``recompile.Loader`` class, which
|
||||||
|
automatically recompiles templates when they
|
||||||
|
change.
|
||||||
|
"""
|
||||||
|
global _loader
|
||||||
|
_loader = loader_class(None, template_dir)
|
||||||
|
|
||||||
|
def __init__(self, template):
|
||||||
|
if _loader is None: # pragma: no cover
|
||||||
|
self.initialize()
|
||||||
|
#: The name of the template
|
||||||
|
self.name = template
|
||||||
|
self.template = _loader.load(template)
|
||||||
|
|
||||||
|
def generate(self, *args, **kwargs):
|
||||||
|
"""Return a generator that renders the template in chunks, with the
|
||||||
|
given arguments."""
|
||||||
|
return self.template(*args, **kwargs)
|
||||||
|
|
||||||
|
def render(self, *args, **kwargs):
|
||||||
|
"""Render the template with the given arguments and return it as a
|
||||||
|
string."""
|
||||||
|
return ''.join(self.generate(*args, **kwargs))
|
||||||
|
|
||||||
|
def generate_async(self, *args, **kwargs):
|
||||||
|
"""Return an asynchronous generator that renders the template in
|
||||||
|
chunks, using the given arguments."""
|
||||||
|
class sync_to_async_iter():
|
||||||
|
def __init__(self, iter):
|
||||||
|
self.iter = iter
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
try:
|
||||||
|
return next(self.iter)
|
||||||
|
except StopIteration:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
|
||||||
|
return sync_to_async_iter(self.generate(*args, **kwargs))
|
||||||
|
|
||||||
|
async def render_async(self, *args, **kwargs):
|
||||||
|
"""Render the template with the given arguments asynchronously and
|
||||||
|
return it as a string."""
|
||||||
|
response = ''
|
||||||
|
async for chunk in self.generate_async(*args, **kwargs):
|
||||||
|
response += chunk
|
||||||
|
return response
|
||||||
231
bridge-wifi/lib/microdot/websocket.py
Normal file
231
bridge-wifi/lib/microdot/websocket.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
from microdot import Request, Response
|
||||||
|
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
|
||||||
|
from microdot.helpers import wraps
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketError(Exception):
|
||||||
|
"""Exception raised when an error occurs in a WebSocket connection."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocket:
|
||||||
|
"""A WebSocket connection object.
|
||||||
|
|
||||||
|
An instance of this class is sent to handler functions to manage the
|
||||||
|
WebSocket connection.
|
||||||
|
"""
|
||||||
|
CONT = 0
|
||||||
|
TEXT = 1
|
||||||
|
BINARY = 2
|
||||||
|
CLOSE = 8
|
||||||
|
PING = 9
|
||||||
|
PONG = 10
|
||||||
|
|
||||||
|
#: Specify the maximum message size that can be received when calling the
|
||||||
|
#: ``receive()`` method. Messages with payloads that are larger than this
|
||||||
|
#: size will be rejected and the connection closed. Set to 0 to disable
|
||||||
|
#: the size check (be aware of potential security issues if you do this),
|
||||||
|
#: or to -1 to use the value set in
|
||||||
|
#: ``Request.max_body_length``. The default is -1.
|
||||||
|
#:
|
||||||
|
#: Example::
|
||||||
|
#:
|
||||||
|
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
|
||||||
|
max_message_length = -1
|
||||||
|
|
||||||
|
def __init__(self, request):
|
||||||
|
self.request = request
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def handshake(self):
|
||||||
|
response = self._handshake_response()
|
||||||
|
await self.request.sock[1].awrite(
|
||||||
|
b'HTTP/1.1 101 Switching Protocols\r\n')
|
||||||
|
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
|
||||||
|
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
|
||||||
|
await self.request.sock[1].awrite(
|
||||||
|
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
|
||||||
|
|
||||||
|
async def receive(self):
|
||||||
|
"""Receive a message from the client."""
|
||||||
|
while True:
|
||||||
|
opcode, payload = await self._read_frame()
|
||||||
|
send_opcode, data = self._process_websocket_frame(opcode, payload)
|
||||||
|
if send_opcode: # pragma: no cover
|
||||||
|
await self.send(data, send_opcode)
|
||||||
|
elif data: # pragma: no branch
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def send(self, data, opcode=None):
|
||||||
|
"""Send a message to the client.
|
||||||
|
|
||||||
|
:param data: the data to send, given as a string or bytes.
|
||||||
|
:param opcode: a custom frame opcode to use. If not given, the opcode
|
||||||
|
is ``TEXT`` or ``BINARY`` depending on the type of the
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
frame = self._encode_websocket_frame(
|
||||||
|
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
|
||||||
|
data)
|
||||||
|
await self.request.sock[1].awrite(frame)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the websocket connection."""
|
||||||
|
if not self.closed: # pragma: no cover
|
||||||
|
self.closed = True
|
||||||
|
await self.send(b'', self.CLOSE)
|
||||||
|
|
||||||
|
def _handshake_response(self):
|
||||||
|
connection = False
|
||||||
|
upgrade = False
|
||||||
|
websocket_key = None
|
||||||
|
for header, value in self.request.headers.items():
|
||||||
|
h = header.lower()
|
||||||
|
if h == 'connection':
|
||||||
|
connection = True
|
||||||
|
if 'upgrade' not in value.lower():
|
||||||
|
return self.request.app.abort(400)
|
||||||
|
elif h == 'upgrade':
|
||||||
|
upgrade = True
|
||||||
|
if not value.lower() == 'websocket':
|
||||||
|
return self.request.app.abort(400)
|
||||||
|
elif h == 'sec-websocket-key':
|
||||||
|
websocket_key = value
|
||||||
|
if not connection or not upgrade or not websocket_key:
|
||||||
|
return self.request.app.abort(400)
|
||||||
|
d = hashlib.sha1(websocket_key.encode())
|
||||||
|
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
|
||||||
|
return binascii.b2a_base64(d.digest())[:-1]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_frame_header(cls, header):
|
||||||
|
fin = header[0] & 0x80
|
||||||
|
opcode = header[0] & 0x0f
|
||||||
|
if fin == 0 or opcode == cls.CONT: # pragma: no cover
|
||||||
|
raise WebSocketError('Continuation frames not supported')
|
||||||
|
has_mask = header[1] & 0x80
|
||||||
|
length = header[1] & 0x7f
|
||||||
|
if length == 126:
|
||||||
|
length = -2
|
||||||
|
elif length == 127:
|
||||||
|
length = -8
|
||||||
|
return fin, opcode, has_mask, length
|
||||||
|
|
||||||
|
def _process_websocket_frame(self, opcode, payload):
|
||||||
|
if opcode == self.TEXT:
|
||||||
|
payload = payload.decode()
|
||||||
|
elif opcode == self.BINARY:
|
||||||
|
pass
|
||||||
|
elif opcode == self.CLOSE:
|
||||||
|
raise WebSocketError('Websocket connection closed')
|
||||||
|
elif opcode == self.PING:
|
||||||
|
return self.PONG, payload
|
||||||
|
elif opcode == self.PONG: # pragma: no branch
|
||||||
|
return None, None
|
||||||
|
return None, payload
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _encode_websocket_frame(cls, opcode, payload):
|
||||||
|
frame = bytearray()
|
||||||
|
frame.append(0x80 | opcode)
|
||||||
|
if opcode == cls.TEXT:
|
||||||
|
payload = payload.encode()
|
||||||
|
if len(payload) < 126:
|
||||||
|
frame.append(len(payload))
|
||||||
|
elif len(payload) < (1 << 16):
|
||||||
|
frame.append(126)
|
||||||
|
frame.extend(len(payload).to_bytes(2, 'big'))
|
||||||
|
else:
|
||||||
|
frame.append(127)
|
||||||
|
frame.extend(len(payload).to_bytes(8, 'big'))
|
||||||
|
frame.extend(payload)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
async def _read_frame(self):
|
||||||
|
header = await self.request.sock[0].read(2)
|
||||||
|
if len(header) != 2: # pragma: no cover
|
||||||
|
raise WebSocketError('Websocket connection closed')
|
||||||
|
fin, opcode, has_mask, length = self._parse_frame_header(header)
|
||||||
|
if length == -2:
|
||||||
|
length = await self.request.sock[0].read(2)
|
||||||
|
length = int.from_bytes(length, 'big')
|
||||||
|
elif length == -8:
|
||||||
|
length = await self.request.sock[0].read(8)
|
||||||
|
length = int.from_bytes(length, 'big')
|
||||||
|
max_allowed_length = Request.max_body_length \
|
||||||
|
if self.max_message_length == -1 else self.max_message_length
|
||||||
|
if length > max_allowed_length:
|
||||||
|
raise WebSocketError('Message too large')
|
||||||
|
if has_mask: # pragma: no cover
|
||||||
|
mask = await self.request.sock[0].read(4)
|
||||||
|
payload = await self.request.sock[0].read(length)
|
||||||
|
if has_mask: # pragma: no cover
|
||||||
|
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
|
||||||
|
return opcode, payload
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_upgrade(request):
|
||||||
|
"""Upgrade a request handler to a websocket connection.
|
||||||
|
|
||||||
|
This function can be called directly inside a route function to process a
|
||||||
|
WebSocket upgrade handshake, for example after the user's credentials are
|
||||||
|
verified. The function returns the websocket object::
|
||||||
|
|
||||||
|
@app.route('/echo')
|
||||||
|
async def echo(request):
|
||||||
|
if not authenticate_user(request):
|
||||||
|
abort(401)
|
||||||
|
ws = await websocket_upgrade(request)
|
||||||
|
while True:
|
||||||
|
message = await ws.receive()
|
||||||
|
await ws.send(message)
|
||||||
|
"""
|
||||||
|
ws = WebSocket(request)
|
||||||
|
await ws.handshake()
|
||||||
|
|
||||||
|
@request.after_request
|
||||||
|
async def after_request(request, response):
|
||||||
|
return Response.already_handled
|
||||||
|
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def websocket_wrapper(f, upgrade_function):
|
||||||
|
@wraps(f)
|
||||||
|
async def wrapper(request, *args, **kwargs):
|
||||||
|
ws = await upgrade_function(request)
|
||||||
|
try:
|
||||||
|
await f(request, ws, *args, **kwargs)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
|
||||||
|
raise
|
||||||
|
except WebSocketError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print_exception(exc)
|
||||||
|
finally: # pragma: no cover
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Response.already_handled
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def with_websocket(f):
|
||||||
|
"""Decorator to make a route a WebSocket endpoint.
|
||||||
|
|
||||||
|
This decorator is used to define a route that accepts websocket
|
||||||
|
connections. The route then receives a websocket object as a second
|
||||||
|
argument that it can use to send and receive messages::
|
||||||
|
|
||||||
|
@app.route('/echo')
|
||||||
|
@with_websocket
|
||||||
|
async def echo(request, ws):
|
||||||
|
while True:
|
||||||
|
message = await ws.receive()
|
||||||
|
await ws.send(message)
|
||||||
|
"""
|
||||||
|
return websocket_wrapper(f, websocket_upgrade)
|
||||||
7
bridge-wifi/src/espnow_wire.py
Normal file
7
bridge-wifi/src/espnow_wire.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""WebSocket uplink framing (Pi ↔ bridge)."""
|
||||||
|
|
||||||
|
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
|
||||||
|
|
||||||
|
def pack_ws_uplink(peer, espnow_packet):
|
||||||
|
return bytes([0]) + peer + espnow_packet
|
||||||
218
bridge-wifi/src/main.py
Normal file
218
bridge-wifi/src/main.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""ESP-NOW bridge: Pi WebSocket downlink, ESP-NOW to drivers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
import espnow
|
||||||
|
import machine
|
||||||
|
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
|
||||||
|
from microdot import Microdot
|
||||||
|
from microdot.websocket import WebSocketError, with_websocket
|
||||||
|
from settings import Settings
|
||||||
|
from wifi_ap import init_bridge_network
|
||||||
|
|
||||||
|
BROADCAST = BROADCAST_MAC
|
||||||
|
WIRE = 0x4C
|
||||||
|
MAX_ESPNOW = 250
|
||||||
|
ESPNOW_EXIST = -12395
|
||||||
|
ESPNOW_FULL = -12392
|
||||||
|
|
||||||
|
|
||||||
|
def mac_str(mac):
|
||||||
|
return ":".join("%02x" % b for b in mac)
|
||||||
|
|
||||||
|
|
||||||
|
def dbg(msg):
|
||||||
|
if DEBUG:
|
||||||
|
print(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def add_peer_if_needed(esp, dest, ch):
|
||||||
|
try:
|
||||||
|
esp.add_peer(dest, channel=ch)
|
||||||
|
dbg("peer add " + mac_str(dest))
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
esp.add_peer(dest)
|
||||||
|
dbg("peer add " + mac_str(dest))
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
dbg("peer exists " + mac_str(dest))
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
dbg("peer exists " + mac_str(dest))
|
||||||
|
|
||||||
|
|
||||||
|
def del_peer_if_present(esp, dest):
|
||||||
|
try:
|
||||||
|
esp.del_peer(dest)
|
||||||
|
dbg("peer del " + mac_str(dest))
|
||||||
|
except Exception as e:
|
||||||
|
dbg("peer del skip " + mac_str(dest) + " " + repr(e))
|
||||||
|
|
||||||
|
|
||||||
|
def send_espnow(esp, dest, pkt):
|
||||||
|
try:
|
||||||
|
esp.send(dest, pkt, True)
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
label = "bcast" if dest == BROADCAST else mac_str(dest)
|
||||||
|
print("send err", label, len(pkt), e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_unicast_temp_peer(esp, dest, ch, pkt):
|
||||||
|
try:
|
||||||
|
add_peer_if_needed(esp, dest, ch)
|
||||||
|
except OSError as e:
|
||||||
|
# If peer table is full but this peer already exists, delete+retry once.
|
||||||
|
if e.args and e.args[0] == ESPNOW_FULL:
|
||||||
|
dbg("peer full " + mac_str(dest) + " retry")
|
||||||
|
del_peer_if_present(esp, dest)
|
||||||
|
add_peer_if_needed(esp, dest, ch)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
ok = send_espnow(esp, dest, pkt)
|
||||||
|
del_peer_if_present(esp, dest)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def downlink(esp, ch, raw):
|
||||||
|
n = len(raw)
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
if raw[0] == WIRE:
|
||||||
|
if n < 2:
|
||||||
|
dbg("dl skip wire short " + str(n))
|
||||||
|
return
|
||||||
|
dbg("dl wire bcast " + str(n))
|
||||||
|
send_espnow(esp, BROADCAST, raw)
|
||||||
|
return
|
||||||
|
if n < 8 or raw[0] != ord("{"):
|
||||||
|
dbg("dl skip json " + str(n))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except ValueError:
|
||||||
|
dbg("dl skip json")
|
||||||
|
return
|
||||||
|
devs = data.get("dv") or data.get("devices")
|
||||||
|
if data.get("v") != "1" or not isinstance(devs, dict):
|
||||||
|
dbg("dl skip envelope")
|
||||||
|
return
|
||||||
|
dbg("dl env " + str(len(devs)) + " dev")
|
||||||
|
for mac_s, body in devs.items():
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
dbg("dl skip body " + str(mac_s))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
h = str(mac_s).replace(":", "").replace("-", "").strip().lower()
|
||||||
|
dest = BROADCAST if h == "ffffffffffff" else bytes.fromhex(h)
|
||||||
|
msg = {"v": "1"}
|
||||||
|
msg.update(body)
|
||||||
|
pkt = json.dumps(msg, separators=(",", ":")).encode()
|
||||||
|
if len(pkt) > MAX_ESPNOW:
|
||||||
|
dbg("dl skip big " + str(len(pkt)))
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
dbg("dl skip mac " + str(mac_s))
|
||||||
|
continue
|
||||||
|
if dest == BROADCAST:
|
||||||
|
dbg("dl bcast " + str(len(pkt)))
|
||||||
|
send_espnow(esp, BROADCAST, pkt)
|
||||||
|
else:
|
||||||
|
dbg("dl uni " + mac_str(dest) + " " + str(len(pkt)))
|
||||||
|
send_unicast_temp_peer(esp, dest, ch, pkt)
|
||||||
|
time.sleep_ms(5)
|
||||||
|
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
settings = Settings()
|
||||||
|
DEBUG = bool(settings.get("debug", True))
|
||||||
|
ch = max(1, min(11, int(settings.get("wifi_channel", 5))))
|
||||||
|
init_bridge_network(settings)
|
||||||
|
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
add_peer_if_needed(esp, BROADCAST, ch)
|
||||||
|
print(
|
||||||
|
"bridge-wifi ch",
|
||||||
|
ch,
|
||||||
|
"debug",
|
||||||
|
DEBUG,
|
||||||
|
"heap",
|
||||||
|
gc.mem_free(),
|
||||||
|
"ws",
|
||||||
|
int(settings.get("ws_port", 80)),
|
||||||
|
)
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
clients = set()
|
||||||
|
wdt = machine.WDT(timeout=10000)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ws")
|
||||||
|
@with_websocket
|
||||||
|
async def ws_handler(request, ws):
|
||||||
|
clients.add(ws)
|
||||||
|
print("ws client +", len(clients))
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await ws.receive()
|
||||||
|
except WebSocketError:
|
||||||
|
dbg("ws closed")
|
||||||
|
break
|
||||||
|
if not raw:
|
||||||
|
dbg("ws empty")
|
||||||
|
break
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = raw.encode("utf-8")
|
||||||
|
dbg("ws rx " + str(len(raw)))
|
||||||
|
try:
|
||||||
|
downlink(esp, ch, raw)
|
||||||
|
except OSError as e:
|
||||||
|
print("dl err", e)
|
||||||
|
finally:
|
||||||
|
clients.discard(ws)
|
||||||
|
print("ws client -", len(clients))
|
||||||
|
|
||||||
|
|
||||||
|
async def espnow_rx_loop():
|
||||||
|
while True:
|
||||||
|
host, msg = esp.recv(0)
|
||||||
|
if host:
|
||||||
|
dbg("up " + mac_str(host) + " " + str(len(msg)))
|
||||||
|
frame = pack_ws_uplink(host, msg)
|
||||||
|
dead = []
|
||||||
|
sent = 0
|
||||||
|
for ws in list(clients):
|
||||||
|
try:
|
||||||
|
await ws.send(frame)
|
||||||
|
sent += 1
|
||||||
|
except Exception as e:
|
||||||
|
dbg("ws up err " + repr(e))
|
||||||
|
dead.append(ws)
|
||||||
|
for ws in dead:
|
||||||
|
clients.discard(ws)
|
||||||
|
if not clients:
|
||||||
|
dbg("up no ws clients")
|
||||||
|
else:
|
||||||
|
dbg("up ws " + str(sent) + "/" + str(len(clients)))
|
||||||
|
else:
|
||||||
|
await asyncio.sleep_ms(1)
|
||||||
|
wdt.feed()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
asyncio.create_task(espnow_rx_loop())
|
||||||
|
port = int(settings.get("ws_port", 80))
|
||||||
|
print("ws listen", port)
|
||||||
|
await app.start_server(host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
60
bridge-wifi/src/settings.py
Normal file
60
bridge-wifi/src/settings.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import ubinascii
|
||||||
|
import network
|
||||||
|
|
||||||
|
WIFI_CHANNEL_DEFAULT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _sta_mac_hex():
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
was_on = sta.active()
|
||||||
|
if not was_on:
|
||||||
|
sta.active(True)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
try:
|
||||||
|
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||||
|
except Exception:
|
||||||
|
mac = "000000000000"
|
||||||
|
if not was_on:
|
||||||
|
sta.active(False)
|
||||||
|
return mac
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(dict):
|
||||||
|
SETTINGS_FILE = "/settings.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
def set_defaults(self):
|
||||||
|
self["name"] = "bridge-" + _sta_mac_hex()
|
||||||
|
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
|
||||||
|
self["ap_password"] = ""
|
||||||
|
self["ap_ip"] = "192.168.4.1"
|
||||||
|
self["ws_port"] = 80
|
||||||
|
self["debug"] = True
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
try:
|
||||||
|
with open(self.SETTINGS_FILE, "w") as f:
|
||||||
|
f.write(json.dumps(self))
|
||||||
|
except Exception as e:
|
||||||
|
print("save settings:", e)
|
||||||
|
|
||||||
|
def load(self):
|
||||||
|
try:
|
||||||
|
with open(self.SETTINGS_FILE, "r") as f:
|
||||||
|
loaded = json.load(f)
|
||||||
|
if not isinstance(loaded, dict):
|
||||||
|
raise ValueError("not object")
|
||||||
|
except Exception:
|
||||||
|
self.clear()
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
return
|
||||||
|
self.clear()
|
||||||
|
self.set_defaults()
|
||||||
|
for k, v in loaded.items():
|
||||||
|
self[k] = v
|
||||||
52
bridge-wifi/src/wifi_ap.py
Normal file
52
bridge-wifi/src/wifi_ap.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""AP + STA for ESP-NOW; Pi joins the AP for WebSocket."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import network
|
||||||
|
|
||||||
|
from settings import WIFI_CHANNEL_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def _channel(settings):
|
||||||
|
try:
|
||||||
|
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return WIFI_CHANNEL_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def init_bridge_network(settings):
|
||||||
|
ch = _channel(settings)
|
||||||
|
essid = settings.get("name") or "bridge"
|
||||||
|
password = settings.get("ap_password") or ""
|
||||||
|
ap_ip = settings.get("ap_ip") or "192.168.4.1"
|
||||||
|
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
ap = network.WLAN(network.AP_IF)
|
||||||
|
sta.active(False)
|
||||||
|
ap.active(False)
|
||||||
|
time.sleep_ms(100)
|
||||||
|
|
||||||
|
ap.active(True)
|
||||||
|
time.sleep_ms(50)
|
||||||
|
if password:
|
||||||
|
try:
|
||||||
|
ap.config(essid=essid, password=password, channel=ch)
|
||||||
|
except TypeError:
|
||||||
|
ap.config(essid=essid, channel=ch)
|
||||||
|
else:
|
||||||
|
ap.config(essid=essid, channel=ch)
|
||||||
|
try:
|
||||||
|
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sta.active(True)
|
||||||
|
sta.config(pm=network.WLAN.PM_NONE)
|
||||||
|
try:
|
||||||
|
sta.config(channel=ch)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
port = int(settings.get("ws_port", 80))
|
||||||
|
print("bridge AP", essid, "ch", ch, "ip", ap.ifconfig()[0])
|
||||||
|
print("bridge_ws_url: ws://%s:%s/ws" % (ap_ip, port))
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "dcb4d99988c8": {"id": "dcb4d99988c8", "name": "outside", "type": "led", "transport": "wifi", "address": "10.1.1.227", "default_pattern": null, "zones": []}}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]}
|
||||||
281
db/pattern.json
281
db/pattern.json
@@ -1 +1,280 @@
|
|||||||
{"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}
|
{
|
||||||
|
"on": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 1,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"off": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 0,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"colour_cycle": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Step rate",
|
||||||
|
"mode": {
|
||||||
|
"0": "Scroll palette gradient",
|
||||||
|
"1": "Rainbow wheel (preset colours ignored)"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"transition": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"chase": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Colour 1 Length",
|
||||||
|
"n2": "Colour 2 Length",
|
||||||
|
"n3": "Step 1",
|
||||||
|
"n4": "Step 2",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true,
|
||||||
|
"mode": {
|
||||||
|
"0": "Two-colour chase",
|
||||||
|
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pulse": {
|
||||||
|
"n1": "Attack",
|
||||||
|
"n2": "Hold",
|
||||||
|
"n3": "Decay",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"circle": {
|
||||||
|
"n1": "Head Rate",
|
||||||
|
"n2": "Max Length",
|
||||||
|
"n3": "Tail Rate",
|
||||||
|
"n4": "Min Length",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 2,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"blink": {
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"flicker": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"flame": {
|
||||||
|
"n1": "Min brightness",
|
||||||
|
"n2": "Breath period (ms)",
|
||||||
|
"n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
|
||||||
|
"n4": "Spark gap max (ms)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"twinkle": {
|
||||||
|
"n1": "Twinkle activity (1\u2013255, higher = more changes)",
|
||||||
|
"n2": "Density (0\u2013255, higher = more of the strip lit)",
|
||||||
|
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
|
||||||
|
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"radiate": {
|
||||||
|
"n1": "Node spacing (LEDs)",
|
||||||
|
"n2": "Out time (ms)",
|
||||||
|
"n3": "In time (ms)",
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"plasma": {
|
||||||
|
"n1": "Scale",
|
||||||
|
"n2": "Speed",
|
||||||
|
"n3": "Contrast",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"bar_graph": {
|
||||||
|
"n1": "Level percent",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"strobe_burst": {
|
||||||
|
"n1": "Burst count",
|
||||||
|
"n2": "Burst gap",
|
||||||
|
"n3": "Cooldown",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"rain_drops": {
|
||||||
|
"n1": "Drop rate",
|
||||||
|
"n2": "Ripple width",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"clock_sweep": {
|
||||||
|
"n1": "Hand width",
|
||||||
|
"n2": "Marker interval",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"aurora": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Band count (0) or spatial period LEDs (1)",
|
||||||
|
"n2": "Shimmer (0) or blend strength (1)",
|
||||||
|
"n3": "Unused (0) or drift speed (1)",
|
||||||
|
"mode": {
|
||||||
|
"0": "Colour bands + shimmer",
|
||||||
|
"1": "Sine northern wave"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"icicles": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Anchor spacing (LEDs)",
|
||||||
|
"n2": "Max icicle length (LEDs)",
|
||||||
|
"n3": "Phase step per refresh",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"blizzard": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Flake density",
|
||||||
|
"n2": "Fall speed",
|
||||||
|
"n3": "Wind (128 = centred; lower/raise for drift bias)",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"rime": {
|
||||||
|
"n1": "Crystallisation rate",
|
||||||
|
"n2": "Melt (decay) per refresh",
|
||||||
|
"n3": "Spark cap (LEDs refreshed per cycle)",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"candle_glow": {
|
||||||
|
"n1": "Candle count",
|
||||||
|
"n2": "Glow width (LEDs)",
|
||||||
|
"n3": "Flicker strength",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"orbit": {
|
||||||
|
"n1": "Orbit count",
|
||||||
|
"n2": "Base speed",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"palette_morph": {
|
||||||
|
"n1": "Morph ms",
|
||||||
|
"n2": "Warp rate",
|
||||||
|
"n3": "Turbulence",
|
||||||
|
"max_colors": 10,
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"supports_manual": false
|
||||||
|
},
|
||||||
|
"meteor": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Tail length (0–1) or eye width (2)",
|
||||||
|
"n2": "Speed (LEDs per frame)",
|
||||||
|
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
|
||||||
|
"mode": {
|
||||||
|
"0": "Fading meteor",
|
||||||
|
"1": "Dual comets",
|
||||||
|
"2": "Bouncing scanner"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"particles": {
|
||||||
|
"supports_reverse": true,
|
||||||
|
"n1": "Flake density (0) or spawn rate (1)",
|
||||||
|
"n2": "Fall speed (LEDs per frame)",
|
||||||
|
"n3": "Unused (0) or streak length (1)",
|
||||||
|
"mode": {
|
||||||
|
"0": "Snowfall flakes",
|
||||||
|
"1": "Starfall streaks"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
},
|
||||||
|
"sparkle": {
|
||||||
|
"n1": "Spark density (0–1) or firefly count (2)",
|
||||||
|
"n2": "Trail decay (0) or twinkle speed (2)",
|
||||||
|
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
|
||||||
|
"mode": {
|
||||||
|
"0": "Sparkle trail",
|
||||||
|
"1": "Ice burst + halo",
|
||||||
|
"2": "Fireflies"
|
||||||
|
},
|
||||||
|
"min_delay": 10,
|
||||||
|
"max_delay": 10000,
|
||||||
|
"max_colors": 10,
|
||||||
|
"has_background": true,
|
||||||
|
"supports_manual": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41", "brightness": 23}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null, "presets_flat": []}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"], "brightness": 167}}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
This document covers:
|
This document covers:
|
||||||
|
|
||||||
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
|
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, zones, presets, transport send, pattern OTA helpers, and related resources.
|
||||||
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **serial → ESP-NOW bridge** to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
|
2. **LED driver JSON** — the compact **v1** message format. It is sent over the **ESP-NOW bridge** (WebSocket) to ESP32 peers and as **single JSON text messages** over the **outbound WebSocket** to **Wi-Fi** drivers (same logical fields).
|
||||||
|
|
||||||
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
|
Default HTTP listen address: `0.0.0.0`. Port defaults to **80**; override with the **`PORT`** environment variable (see `pipenv run run`).
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
|
|||||||
|
|
||||||
Connect to **`ws://<host>:<port>/ws`**.
|
Connect to **`ws://<host>:<port>/ws`**.
|
||||||
|
|
||||||
- Send **JSON**: the object is forwarded through the **serial sender** (6-byte MAC prefix + payload to the ESP-NOW bridge). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
|
- Send **JSON**: the object is forwarded through the **ESP-NOW bridge** (devices envelope or legacy MAC-prefixed payload). Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination from settings is used.
|
||||||
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||||
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||||
|
|
||||||
|
|||||||
184
docs/espnow-architecture.md
Normal file
184
docs/espnow-architecture.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# ESP-NOW transport architecture
|
||||||
|
|
||||||
|
This document describes how **led-controller**, the **bridge ESP32**, and **led-driver** devices work together. Wire-level byte layouts are in [espnow-binary-protocol.md](espnow-binary-protocol.md).
|
||||||
|
|
||||||
|
**Pi ↔ bridge WebSocket:** v1 **devices envelope** (JSON) — see [espnow-sender/msg.json](../espnow-sender/msg.json). **ESP-NOW over the air:** JSON driver payloads (≤250 bytes) or legacy binary (`0x4C` wire). The Pi web UI and `db/*.json` use JSON internally.
|
||||||
|
|
||||||
|
## System overview
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Component | Firmware / path | Role |
|
||||||
|
|-----------|-----------------|------|
|
||||||
|
| **led-controller** | Raspberry Pi, `src/` | Web app; WebSocket **client** to bridge (auto-reconnect); device registry; builds devices envelope |
|
||||||
|
| **Bridge** | [`espnow-sender/`](../espnow-sender/) | WebSocket **server** `/ws`; routes envelope per MAC; max **20** peers (LRU) |
|
||||||
|
| **led-driver** | [`led-driver/`](../led-driver/) submodule | Boot **ANNOUNCE** broadcast; applies **GROUPS**, **CMD**, **GROUP_CMD** |
|
||||||
|
|
||||||
|
Configure the Pi in `settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bridge_ws_url": "ws://192.168.4.1/ws",
|
||||||
|
"wifi_channel": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect the Pi to the **bridge access point** (SSID = bridge `name` in `/settings.json`, default IP **192.168.4.1**). All nodes must use the same 2.4 GHz **channel**: set `wifi_channel` in the bridge and each led-driver `/settings.json` (applied at boot on those devices). The Pi stores `wifi_channel` in its own `settings.json` for alignment (restart led-controller after changing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Boot and registration
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
1. Driver powers on and sends **ANNOUNCE** to broadcast MAC `ff:ff:ff:ff:ff:ff`.
|
||||||
|
2. Bridge receives it and forwards a **WebSocket uplink** frame to the Pi (peer MAC + packet).
|
||||||
|
3. Pi **upserts** the device in `db/device.json` (key = 12-char hex MAC).
|
||||||
|
4. Pi scans `db/group.json` and sends a **groups** envelope (`set_groups: true`) unicast to that MAC.
|
||||||
|
5. Driver stores group ids in RAM (`device_groups`) for filtering.
|
||||||
|
6. Pi bridge client **reconnects** automatically if the WebSocket drops (2 s backoff).
|
||||||
|
|
||||||
|
If the Pi or bridge is not up yet, the driver re-sends **ANNOUNCE** periodically until **GROUPS** arrives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Devices envelope (Pi → bridge)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"dv": {
|
||||||
|
"ff:ff:ff:ff:ff:ff": {
|
||||||
|
"p": { "2": { "p": "on", "c": ["#FFFFFF"], "a": true } },
|
||||||
|
"s": ["2", 0],
|
||||||
|
"g": ["5", "18"],
|
||||||
|
"sg": false,
|
||||||
|
"sv": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Short wire names (long names still accepted on receive): `dv`=devices, `p`=presets, `s`=select (`["preset_id", step?]` — no device name), `g`=groups, `sg`=set_groups, `sv`=save, `df`=default; preset fields `p/c/d/b/a/bg/n1…`.
|
||||||
|
|
||||||
|
| `set_groups` | Destination | Bridge | Driver |
|
||||||
|
|--------------|-------------|--------|--------|
|
||||||
|
| `true` | any | Unicast only (expand `ff:ff:…` to all known peers) | `groups_replace`, then apply body |
|
||||||
|
| `false` | `ff:ff:ff:ff:ff:ff` | ESP-NOW air broadcast | Apply only if device is in `groups` |
|
||||||
|
| `false` | specific MAC | Unicast | Same group filter |
|
||||||
|
|
||||||
|
Legacy raw payloads (binary wire or plain v1 JSON without `devices`) are still **broadcast** by the bridge.
|
||||||
|
|
||||||
|
## Sending presets and commands
|
||||||
|
|
||||||
|
1. UI or API triggers a send (e.g. `POST /presets/push`).
|
||||||
|
2. Pi builds a **devices envelope** (or legacy binary) and sends it on the bridge WebSocket.
|
||||||
|
3. Bridge routes each MAC entry to unicast or ESP-NOW broadcast per `set_groups`.
|
||||||
|
4. Driver `process_data` applies presets, select (`[preset_id, step?]`; legacy name map still accepted), brightness, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Packet layers
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Layer A — WebSocket frame (Pi ↔ bridge)
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 0 | 1 | `flags` — bit0 = broadcast (`ff:ff:…`); peer ignored if set |
|
||||||
|
| 1 | 6 | `peer` — destination MAC (raw bytes) |
|
||||||
|
| 7 | … | Full ESP-NOW packet (layer B) |
|
||||||
|
|
||||||
|
**Uplink** (bridge → Pi): same layout; `flags = 0`, `peer` = sender.
|
||||||
|
|
||||||
|
**Ack** (bridge → Pi after downlink): 1 byte — `0x01` ok, `0x00` error.
|
||||||
|
|
||||||
|
### Layer B — ESP-NOW packet (on air)
|
||||||
|
|
||||||
|
| Offset | Size | Field |
|
||||||
|
|--------|------|--------|
|
||||||
|
| 0 | 1 | Magic `0x4C` (`'L'`) |
|
||||||
|
| 1 | 1 | Message type |
|
||||||
|
| 2 | … | Body (≤248 bytes so total ≤250) |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Type | Value | Direction | Purpose |
|
||||||
|
|------|-------|-------------|---------|
|
||||||
|
| ANNOUNCE | `0x01` | Driver → broadcast | Boot settings |
|
||||||
|
| GROUPS | `0x02` | Pi → driver | Group membership |
|
||||||
|
| CMD | `0x03` | Pi → driver | Command (v2 envelope) |
|
||||||
|
| GROUP_CMD | `0x04` | Pi → broadcast | Command scoped to one group |
|
||||||
|
| BRIDGE_CH | `0x10` | Pi → bridge | Set STA channel 1–11 |
|
||||||
|
|
||||||
|
### Layer C — v2 command envelope (inside CMD / GROUP_CMD)
|
||||||
|
|
||||||
|
Used for presets, select, default, brightness. **No JSON.**
|
||||||
|
|
||||||
|
| Byte | Field |
|
||||||
|
|------|--------|
|
||||||
|
| 0 | Version `2` |
|
||||||
|
| 1 | Brightness wire 0–127 (→ 0–255); `128–255` = unchanged |
|
||||||
|
| 2 | `lp` — presets section length |
|
||||||
|
| 3 | `ls` — select section length |
|
||||||
|
| 4 | `ld` — default section length |
|
||||||
|
| 5… | Presets blob (`lp` bytes) |
|
||||||
|
| … | Select blob (`ls` bytes) |
|
||||||
|
| … | Default blob (`ld` bytes) |
|
||||||
|
|
||||||
|
Optional trailing `0x01` after the envelope in **CMD** means `save` (persist to flash).
|
||||||
|
|
||||||
|
Implementation: [`src/util/binary_envelope.py`](../src/util/binary_envelope.py), [`src/util/espnow_wire.py`](../src/util/espnow_wire.py).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Message body reference
|
||||||
|
|
||||||
|
### ANNOUNCE (`0x01`)
|
||||||
|
|
||||||
|
Sender MAC comes from ESP-NOW headers, not the body.
|
||||||
|
|
||||||
|
```
|
||||||
|
name_len (u8) | name (utf-8) | num_leds (u16 LE) | color_order (u8) | startup_mode (u8) | brightness (u8) | device_type (u8)
|
||||||
|
```
|
||||||
|
|
||||||
|
| `color_order` | `startup_mode` |
|
||||||
|
|---------------|----------------|
|
||||||
|
| 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr | 0=default, 1=last, 2=off |
|
||||||
|
|
||||||
|
### GROUPS (`0x02`)
|
||||||
|
|
||||||
|
```
|
||||||
|
count (u8) | repeat: id_len (u8) | group_id (utf-8)
|
||||||
|
```
|
||||||
|
|
||||||
|
Group ids match keys in `db/group.json` (e.g. `"5"`, `"18"`).
|
||||||
|
|
||||||
|
### GROUP_CMD (`0x04`)
|
||||||
|
|
||||||
|
```
|
||||||
|
group_id_len (u8) | group_id (utf-8) | v2 envelope | [optional 0x01 save]
|
||||||
|
```
|
||||||
|
|
||||||
|
Driver applies only if `group_id` is in its stored list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Size limits and chunking
|
||||||
|
|
||||||
|
- **250 bytes** max per ESP-NOW datagram.
|
||||||
|
- Large preset libraries → multiple **CMD** packets from the Pi.
|
||||||
|
- Bridge stores at most **20** peer MACs; oldest peer evicted (LRU) when full.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related files
|
||||||
|
|
||||||
|
| Topic | Location |
|
||||||
|
|-------|----------|
|
||||||
|
| Byte-level spec | [espnow-binary-protocol.md](espnow-binary-protocol.md) |
|
||||||
|
| Pi wire codec | [`src/util/espnow_wire.py`](../src/util/espnow_wire.py) |
|
||||||
|
| Pi bridge client | [`src/models/bridge_ws_client.py`](../src/models/bridge_ws_client.py) |
|
||||||
|
| Bridge firmware | [`espnow-sender/main.py`](../espnow-sender/main.py) |
|
||||||
|
| Driver ESP-NOW | [`led-driver/src/espnow_transport.py`](../led-driver/src/espnow_transport.py) |
|
||||||
114
docs/espnow-binary-protocol.md
Normal file
114
docs/espnow-binary-protocol.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# ESP-NOW binary protocol
|
||||||
|
|
||||||
|
**See also:** [espnow-architecture.md](espnow-architecture.md) (diagrams, flows, configuration).
|
||||||
|
|
||||||
|
All ESP-NOW datagrams and Pi↔bridge WebSocket frames use **binary only** (no JSON on the wire). Maximum ESP-NOW payload length: **250 bytes**.
|
||||||
|
|
||||||
|
## ESP-NOW packet
|
||||||
|
|
||||||
|
| Offset | Field |
|
||||||
|
|--------|--------|
|
||||||
|
| 0 | Magic `0x4C` (`'L'`) |
|
||||||
|
| 1 | Message type |
|
||||||
|
| 2… | Type-specific body |
|
||||||
|
|
||||||
|
### Message types
|
||||||
|
|
||||||
|
| Value | Name | Direction |
|
||||||
|
|-------|------|-----------|
|
||||||
|
| `0x01` | `ANNOUNCE` | Driver → broadcast |
|
||||||
|
| `0x02` | `GROUPS` | Controller → driver |
|
||||||
|
| `0x03` | `CMD` | Controller → driver |
|
||||||
|
| `0x04` | `GROUP_CMD` | Controller → broadcast |
|
||||||
|
| `0x05` | `PING_REQ` | Controller → broadcast |
|
||||||
|
| `0x06` | `PING_RSP` | Driver → controller (unicast) |
|
||||||
|
| `0x10` | `BRIDGE_CH` | Controller → broadcast |
|
||||||
|
|
||||||
|
### ANNOUNCE (`0x01`)
|
||||||
|
|
||||||
|
Driver settings at boot. Sender MAC is taken from the ESP-NOW peer address (not repeated in the body).
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| name_len | u8 |
|
||||||
|
| name | UTF-8 |
|
||||||
|
| num_leds | u16 LE |
|
||||||
|
| color_order | u8 enum: 0=rgb, 1=rbg, 2=grb, 3=gbr, 4=brg, 5=bgr |
|
||||||
|
| startup_mode | u8: 0=default, 1=last, 2=off |
|
||||||
|
| brightness | u8 0–255 |
|
||||||
|
| device_type | u8: 0=led |
|
||||||
|
|
||||||
|
### GROUPS (`0x02`)
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| count | u8 |
|
||||||
|
| × count | u8 id_len + UTF-8 group id |
|
||||||
|
|
||||||
|
### CMD (`0x03`)
|
||||||
|
|
||||||
|
Bytes 2… are a **v2 binary envelope** (see `src/util/binary_envelope.py`): 5-byte header + presets/select/default blobs. Total packet ≤ 250 bytes.
|
||||||
|
|
||||||
|
### GROUP_CMD (`0x04`)
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| group_id_len | u8 |
|
||||||
|
| group_id | UTF-8 |
|
||||||
|
| cmd_envelope | v2 binary envelope |
|
||||||
|
|
||||||
|
Drivers apply the nested envelope only if `group_id` is in their stored group list.
|
||||||
|
|
||||||
|
### PING_REQ (`0x05`)
|
||||||
|
|
||||||
|
Controller discovery ping (broadcast). Drivers reply with **PING_RSP** after a random delay (50–500 ms) to reduce ESP-NOW collisions.
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| ping_id | u32 LE |
|
||||||
|
|
||||||
|
### PING_RSP (`0x06`)
|
||||||
|
|
||||||
|
Unicast to the bridge/controller peer that sent the request (ESP-NOW source MAC of the received **PING_REQ**).
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| ping_id | u32 LE |
|
||||||
|
| name_len | u8 |
|
||||||
|
| name | UTF-8 |
|
||||||
|
|
||||||
|
### BRIDGE_CH (`0x10`)
|
||||||
|
|
||||||
|
| Field | Type |
|
||||||
|
|-------|------|
|
||||||
|
| channel | u8 (1–11) |
|
||||||
|
|
||||||
|
Sets the bridge ESP32 STA channel (not forwarded to LED drivers as a command).
|
||||||
|
|
||||||
|
## Pi ↔ bridge WebSocket frame
|
||||||
|
|
||||||
|
Binary WebSocket messages only.
|
||||||
|
|
||||||
|
| Offset | Field |
|
||||||
|
|--------|--------|
|
||||||
|
| 0 | flags: bit0 = broadcast destination; bit1 reserved |
|
||||||
|
| 1–6 | peer MAC (6 bytes); ignored if broadcast |
|
||||||
|
| 7… | ESP-NOW packet (magic + type + body) |
|
||||||
|
|
||||||
|
Broadcast destination uses peer `ff:ff:ff:ff:ff:ff`.
|
||||||
|
|
||||||
|
The bridge maintains at most **20** ESP-NOW peers (LRU eviction).
|
||||||
|
|
||||||
|
## v2 command envelope
|
||||||
|
|
||||||
|
Native binary sections (no JSON). Header:
|
||||||
|
|
||||||
|
| Byte | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 0 | Version `2` |
|
||||||
|
| 1 | Brightness wire 0–127 (maps to 0–255); 128–255 = unchanged |
|
||||||
|
| 2 | Presets section length |
|
||||||
|
| 3 | Select section length |
|
||||||
|
| 4 | Default section length |
|
||||||
|
|
||||||
|
See `binary_envelope.py` for blob layouts.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# LED controller — user guide
|
# LED controller — user guide
|
||||||
|
|
||||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **serial → ESP-NOW bridge** or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **ESP-NOW bridge** (WebSocket) or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
||||||
|
|
||||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||||
|
|
||||||
|
|||||||
57
docs/images/espnow/boot-sequence.svg
Normal file
57
docs/images/espnow/boot-sequence.svg
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 520" font-family="system-ui, Segoe UI, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arr" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
|
||||||
|
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
|
||||||
|
.msg { stroke: #2980b9; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
|
||||||
|
.msgret { stroke: #27ae60; stroke-width: 1.5; fill: none; marker-end: url(#arr); }
|
||||||
|
.note { fill: #fef9e7; stroke: #d4ac0d; stroke-width: 1; }
|
||||||
|
.t { font-size: 13px; fill: #222; }
|
||||||
|
.h { font-size: 14px; font-weight: 700; fill: #111; }
|
||||||
|
.s { font-size: 11px; fill: #555; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Boot and registration sequence</text>
|
||||||
|
|
||||||
|
<!-- Actors -->
|
||||||
|
<rect class="actor" x="40" y="40" width="120" height="40" rx="6"/>
|
||||||
|
<text x="100" y="66" text-anchor="middle" class="h">Driver</text>
|
||||||
|
<line class="lifeline" x1="100" y1="80" x2="100" y2="480"/>
|
||||||
|
|
||||||
|
<rect class="actor" x="310" y="40" width="120" height="40" rx="6"/>
|
||||||
|
<text x="370" y="66" text-anchor="middle" class="h">Bridge</text>
|
||||||
|
<line class="lifeline" x1="370" y1="80" x2="370" y2="480"/>
|
||||||
|
|
||||||
|
<rect class="actor" x="580" y="40" width="140" height="40" rx="6"/>
|
||||||
|
<text x="650" y="66" text-anchor="middle" class="h">led-controller</text>
|
||||||
|
<line class="lifeline" x1="650" y1="80" x2="650" y2="480"/>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<path class="msg" d="M 100 110 L 368 110"/>
|
||||||
|
<text x="234" y="102" text-anchor="middle" class="t">ESP-NOW broadcast ANNOUNCE</text>
|
||||||
|
<text x="234" y="128" text-anchor="middle" class="s">dest ff:ff:ff:ff:ff:ff</text>
|
||||||
|
|
||||||
|
<path class="msg" d="M 372 150 L 648 150"/>
|
||||||
|
<text x="510" y="142" text-anchor="middle" class="t">WS uplink: peer MAC + packet</text>
|
||||||
|
|
||||||
|
<rect class="note" x="520" y="168" width="200" height="44" rx="4"/>
|
||||||
|
<text x="620" y="188" text-anchor="middle" class="s">upsert device in</text>
|
||||||
|
<text x="620" y="204" text-anchor="middle" class="s">db/device.json</text>
|
||||||
|
|
||||||
|
<path class="msgret" d="M 648 230 L 372 230"/>
|
||||||
|
<text x="510" y="222" text-anchor="middle" class="t">WS downlink: GROUPS unicast</text>
|
||||||
|
|
||||||
|
<path class="msgret" d="M 368 270 L 102 270"/>
|
||||||
|
<text x="234" y="262" text-anchor="middle" class="t">ESP-NOW unicast GROUPS</text>
|
||||||
|
|
||||||
|
<rect class="note" x="30" y="300" width="140" height="40" rx="4"/>
|
||||||
|
<text x="100" y="318" text-anchor="middle" class="s">store group ids</text>
|
||||||
|
<text x="100" y="332" text-anchor="middle" class="s">in RAM</text>
|
||||||
|
|
||||||
|
<text x="390" y="380" text-anchor="middle" class="s">Driver re-sends ANNOUNCE until GROUPS received if Pi/bridge late</text>
|
||||||
|
<text x="390" y="460" text-anchor="middle" class="s">ANNOUNCE body: name, num_leds, color_order, startup_mode, brightness</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
53
docs/images/espnow/command-flow.svg
Normal file
53
docs/images/espnow/command-flow.svg
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 480" font-family="system-ui, Segoe UI, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="a" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.actor { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; }
|
||||||
|
.lifeline { stroke: #aaa; stroke-width: 1; stroke-dasharray: 4 4; }
|
||||||
|
.msg { stroke: #8e44ad; stroke-width: 1.5; fill: none; marker-end: url(#a); }
|
||||||
|
.t { font-size: 13px; fill: #222; }
|
||||||
|
.h { font-size: 14px; font-weight: 700; }
|
||||||
|
.s { font-size: 11px; fill: #555; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<text x="390" y="24" text-anchor="middle" class="h" font-size="16">Preset / command delivery</text>
|
||||||
|
|
||||||
|
<rect class="actor" x="30" y="44" width="90" height="36" rx="6"/>
|
||||||
|
<text x="75" y="68" text-anchor="middle" class="h">UI</text>
|
||||||
|
<line class="lifeline" x1="75" y1="80" x2="75" y2="440"/>
|
||||||
|
|
||||||
|
<rect class="actor" x="200" y="44" width="120" height="36" rx="6"/>
|
||||||
|
<text x="260" y="68" text-anchor="middle" class="h">Pi</text>
|
||||||
|
<line class="lifeline" x1="260" y1="80" x2="260" y2="440"/>
|
||||||
|
|
||||||
|
<rect class="actor" x="400" y="44" width="100" height="36" rx="6"/>
|
||||||
|
<text x="450" y="68" text-anchor="middle" class="h">Bridge</text>
|
||||||
|
<line class="lifeline" x1="450" y1="80" x2="450" y2="440"/>
|
||||||
|
|
||||||
|
<rect class="actor" x="580" y="44" width="100" height="36" rx="6"/>
|
||||||
|
<text x="630" y="68" text-anchor="middle" class="h">Driver</text>
|
||||||
|
<line class="lifeline" x1="630" y1="80" x2="630" y2="440"/>
|
||||||
|
|
||||||
|
<path class="msg" d="M 77 110 L 258 110"/>
|
||||||
|
<text x="168" y="102" text-anchor="middle" class="t">POST /presets/send (JSON)</text>
|
||||||
|
|
||||||
|
<text x="260" y="145" text-anchor="middle" class="s">build v2 envelope</text>
|
||||||
|
<text x="260" y="162" text-anchor="middle" class="s">pack CMD (d250 B)</text>
|
||||||
|
|
||||||
|
<path class="msg" d="M 262 190 L 448 190"/>
|
||||||
|
<text x="355" y="182" text-anchor="middle" class="t">WS downlink + CMD</text>
|
||||||
|
|
||||||
|
<path class="msg" d="M 452 230 L 628 230"/>
|
||||||
|
<text x="540" y="222" text-anchor="middle" class="t">ESP-NOW unicast / broadcast</text>
|
||||||
|
|
||||||
|
<text x="630" y="275" text-anchor="middle" class="s">parse CMD</text>
|
||||||
|
<text x="630" y="292" text-anchor="middle" class="s">apply presets / select</text>
|
||||||
|
|
||||||
|
<rect x="140" y="320" width="500" height="90" fill="#f0f0f0" stroke="#999" rx="6"/>
|
||||||
|
<text x="390" y="345" text-anchor="middle" class="t">GROUP_CMD: one broadcast per group id only members apply</text>
|
||||||
|
<text x="390" y="368" text-anchor="middle" class="s">Large libraries ’ multiple CMD chunks from Pi</text>
|
||||||
|
<text x="390" y="390" text-anchor="middle" class="s">Optional trailing 0x01 on CMD = save to flash</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
42
docs/images/espnow/message-types.svg
Normal file
42
docs/images/espnow/message-types.svg
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 320" font-family="system-ui, Segoe UI, sans-serif">
|
||||||
|
<text x="320" y="28" text-anchor="middle" font-size="16" font-weight="700" fill="#111">ESP-NOW message types (byte 1 after 0x4C)</text>
|
||||||
|
|
||||||
|
<rect fill="#2c3e50" x="40" y="48" width="560" height="28" rx="4"/>
|
||||||
|
<text x="70" y="67" fill="#fff" font-size="12" font-weight="600">Value</text>
|
||||||
|
<text x="150" y="67" fill="#fff" font-size="12" font-weight="600">Name</text>
|
||||||
|
<text x="280" y="67" fill="#fff" font-size="12" font-weight="600">Direction</text>
|
||||||
|
<text x="460" y="67" fill="#fff" font-size="12" font-weight="600">Purpose</text>
|
||||||
|
|
||||||
|
<rect fill="#fff" stroke="#ddd" x="40" y="76" width="560" height="32"/>
|
||||||
|
<text x="70" y="97" font-size="12">0x01</text>
|
||||||
|
<text x="150" y="97" font-size="12" font-weight="600">ANNOUNCE</text>
|
||||||
|
<text x="280" y="97" font-size="12">Driver ? broadcast</text>
|
||||||
|
<text x="460" y="97" font-size="12">Boot settings</text>
|
||||||
|
|
||||||
|
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="108" width="560" height="32"/>
|
||||||
|
<text x="70" y="129" font-size="12">0x02</text>
|
||||||
|
<text x="150" y="129" font-size="12" font-weight="600">GROUPS</text>
|
||||||
|
<text x="280" y="129" font-size="12">Pi ? driver</text>
|
||||||
|
<text x="460" y="129" font-size="12">Group membership</text>
|
||||||
|
|
||||||
|
<rect fill="#fff" stroke="#ddd" x="40" y="140" width="560" height="32"/>
|
||||||
|
<text x="70" y="161" font-size="12">0x03</text>
|
||||||
|
<text x="150" y="161" font-size="12" font-weight="600">CMD</text>
|
||||||
|
<text x="280" y="161" font-size="12">Pi ? driver</text>
|
||||||
|
<text x="460" y="161" font-size="12">v2 command envelope</text>
|
||||||
|
|
||||||
|
<rect fill="#f8f9fa" stroke="#ddd" x="40" y="172" width="560" height="32"/>
|
||||||
|
<text x="70" y="193" font-size="12">0x04</text>
|
||||||
|
<text x="150" y="193" font-size="12" font-weight="600">GROUP_CMD</text>
|
||||||
|
<text x="280" y="193" font-size="12">Pi ? broadcast</text>
|
||||||
|
<text x="460" y="193" font-size="12">Filtered by group id</text>
|
||||||
|
|
||||||
|
<rect fill="#fff" stroke="#ddd" x="40" y="204" width="560" height="32"/>
|
||||||
|
<text x="70" y="225" font-size="12">0x10</text>
|
||||||
|
<text x="150" y="225" font-size="12" font-weight="600">BRIDGE_CH</text>
|
||||||
|
<text x="280" y="225" font-size="12">Pi ? bridge</text>
|
||||||
|
<text x="460" y="225" font-size="12">Wi-Fi channel 1–11</text>
|
||||||
|
|
||||||
|
<text x="320" y="270" text-anchor="middle" font-size="12" fill="#555">Every packet: [0x4C magic][type][body…] total ? 250 bytes</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
62
docs/images/espnow/packet-layers.svg
Normal file
62
docs/images/espnow/packet-layers.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 480" font-family="ui-monospace, monospace">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.layer { stroke: #2c3e50; stroke-width: 2; }
|
||||||
|
.ws { fill: #e8f4fc; }
|
||||||
|
.esp { fill: #fef9e7; }
|
||||||
|
.env { fill: #eafaf1; }
|
||||||
|
.lbl { font-family: system-ui, sans-serif; font-size: 14px; font-weight: 700; fill: #111; }
|
||||||
|
.byte { font-size: 12px; fill: #333; }
|
||||||
|
.title { font-family: system-ui, sans-serif; font-size: 17px; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<text x="360" y="28" text-anchor="middle" class="title">Packet layers (outside ’ inside)</text>
|
||||||
|
|
||||||
|
<!-- WS layer -->
|
||||||
|
<rect class="layer ws" x="60" y="50" width="600" height="70" rx="6"/>
|
||||||
|
<text x="80" y="78" class="lbl">WebSocket frame (Pi ” bridge)</text>
|
||||||
|
<rect x="80" y="88" width="50" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="105" y="104" text-anchor="middle" class="byte">flags</text>
|
||||||
|
<rect x="138" y="88" width="120" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="198" y="104" text-anchor="middle" class="byte">peer MAC ×6</text>
|
||||||
|
<rect x="268" y="88" width="380" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="458" y="104" text-anchor="middle" class="byte">ESP-NOW packet (below)</text>
|
||||||
|
|
||||||
|
<!-- ESP layer -->
|
||||||
|
<rect class="layer esp" x="100" y="140" width="520" height="70" rx="6"/>
|
||||||
|
<text x="120" y="168" class="lbl">ESP-NOW datagram (d250 bytes)</text>
|
||||||
|
<rect x="120" y="178" width="40" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="140" y="194" text-anchor="middle" class="byte">4C</text>
|
||||||
|
<rect x="168" y="178" width="50" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="193" y="194" text-anchor="middle" class="byte">type</text>
|
||||||
|
<rect x="230" y="178" width="370" height="24" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="415" y="194" text-anchor="middle" class="byte">body (ANNOUNCE / GROUPS / CMD / &)</text>
|
||||||
|
|
||||||
|
<!-- CMD + envelope -->
|
||||||
|
<rect class="layer env" x="140" y="230" width="440" height="120" rx="6"/>
|
||||||
|
<text x="160" y="258" class="lbl">Inside CMD (0x03) v2 command envelope</text>
|
||||||
|
<rect x="160" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="174" y="283" text-anchor="middle" class="byte">02</text>
|
||||||
|
<rect x="194" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="208" y="283" text-anchor="middle" class="byte">br</text>
|
||||||
|
<rect x="228" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="242" y="283" text-anchor="middle" class="byte">lp</text>
|
||||||
|
<rect x="262" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="276" y="283" text-anchor="middle" class="byte">ls</text>
|
||||||
|
<rect x="296" y="268" width="28" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="310" y="283" text-anchor="middle" class="byte">ld</text>
|
||||||
|
<rect x="334" y="268" width="110" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="389" y="283" text-anchor="middle" class="byte">presets</text>
|
||||||
|
<rect x="450" y="268" width="60" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="480" y="283" text-anchor="middle" class="byte">select</text>
|
||||||
|
<rect x="516" y="268" width="54" height="22" fill="#fff" stroke="#666"/>
|
||||||
|
<text x="543" y="283" text-anchor="middle" class="byte">def</text>
|
||||||
|
<rect x="160" y="300" width="60" height="22" fill="#ffeaa7" stroke="#666"/>
|
||||||
|
<text x="190" y="315" text-anchor="middle" class="byte">save?</text>
|
||||||
|
<text x="360" y="335" text-anchor="middle" class="byte" font-family="system-ui">optional 0x01 after envelope</text>
|
||||||
|
|
||||||
|
<text x="360" y="400" text-anchor="middle" font-family="system-ui" font-size="12" fill="#555">
|
||||||
|
Pi REST/UI uses JSON · conversion to binary happens at bridge boundary
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.6 KiB |
65
docs/images/espnow/system-overview.svg
Normal file
65
docs/images/espnow/system-overview.svg
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 420" font-family="system-ui, Segoe UI, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L6,3 L0,6 Z" fill="#333"/>
|
||||||
|
</marker>
|
||||||
|
<style>
|
||||||
|
.box { fill: #f4f6f8; stroke: #2c3e50; stroke-width: 2; rx: 8; }
|
||||||
|
.title { font-size: 16px; font-weight: 700; fill: #1a1a1a; }
|
||||||
|
.label { font-size: 13px; fill: #333; }
|
||||||
|
.small { font-size: 11px; fill: #555; }
|
||||||
|
.line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
|
||||||
|
.dashed { stroke-dasharray: 6 4; }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<text x="410" y="28" text-anchor="middle" class="title" font-size="18">ESP-NOW LED system three nodes</text>
|
||||||
|
|
||||||
|
<!-- Pi -->
|
||||||
|
<rect class="box" x="40" y="60" width="220" height="300"/>
|
||||||
|
<text x="150" y="88" text-anchor="middle" class="title">led-controller</text>
|
||||||
|
<text x="150" y="108" text-anchor="middle" class="small">Raspberry Pi</text>
|
||||||
|
<rect x="60" y="125" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||||
|
<text x="150" y="148" text-anchor="middle" class="label">Web UI / REST (JSON)</text>
|
||||||
|
<rect x="60" y="170" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||||
|
<text x="150" y="193" text-anchor="middle" class="label">db/device.json, groups</text>
|
||||||
|
<rect x="60" y="215" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
|
||||||
|
<text x="150" y="238" text-anchor="middle" class="label">espnow_wire + binary</text>
|
||||||
|
<rect x="60" y="260" width="180" height="36" fill="#e8f4fc" stroke="#2980b9" rx="4"/>
|
||||||
|
<text x="150" y="283" text-anchor="middle" class="label">bridge_ws_client</text>
|
||||||
|
<text x="150" y="330" text-anchor="middle" class="small">WS client ’ bridge</text>
|
||||||
|
|
||||||
|
<!-- Bridge -->
|
||||||
|
<rect class="box" x="300" y="100" width="220" height="220"/>
|
||||||
|
<text x="410" y="128" text-anchor="middle" class="title">Bridge ESP32</text>
|
||||||
|
<text x="410" y="148" text-anchor="middle" class="small">espnow-sender</text>
|
||||||
|
<rect x="320" y="165" width="180" height="36" fill="#fff" stroke="#888" rx="4"/>
|
||||||
|
<text x="410" y="188" text-anchor="middle" class="label">WebSocket server /ws</text>
|
||||||
|
<rect x="320" y="210" width="180" height="36" fill="#fef9e7" stroke="#d4ac0d" rx="4"/>
|
||||||
|
<text x="410" y="233" text-anchor="middle" class="label">ESP-NOW relay</text>
|
||||||
|
<text x="410" y="275" text-anchor="middle" class="small">max 20 peers (LRU)</text>
|
||||||
|
|
||||||
|
<!-- Drivers -->
|
||||||
|
<rect class="box" x="560" y="60" width="220" height="300"/>
|
||||||
|
<text x="670" y="88" text-anchor="middle" class="title">led-driver × N</text>
|
||||||
|
<text x="670" y="108" text-anchor="middle" class="small">ESP32 LED strips</text>
|
||||||
|
<rect x="580" y="140" width="180" height="32" fill="#eafaf1" stroke="#27ae60" rx="4"/>
|
||||||
|
<text x="670" y="161" text-anchor="middle" class="label">boot ANNOUNCE</text>
|
||||||
|
<rect x="580" y="182" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
|
||||||
|
<text x="670" y="203" text-anchor="middle" class="label">store GROUPS</text>
|
||||||
|
<rect x="580" y="224" width="180" height="32" fill="#fff" stroke="#888" rx="4"/>
|
||||||
|
<text x="670" y="245" text-anchor="middle" class="label">apply CMD / GROUP_CMD</text>
|
||||||
|
<text x="670" y="320" text-anchor="middle" class="small">binary only on air</text>
|
||||||
|
|
||||||
|
<!-- Arrows -->
|
||||||
|
<path class="line" d="M 260 278 L 298 200"/>
|
||||||
|
<text x="268" y="235" class="small">binary WS</text>
|
||||||
|
<path class="line" d="M 520 230 L 558 200"/>
|
||||||
|
<text x="528" y="218" class="small">ESP-NOW</text>
|
||||||
|
<path class="line dashed" d="M 520 260 L 558 280"/>
|
||||||
|
<text x="528" y="278" class="small">broadcast</text>
|
||||||
|
<path class="line dashed" d="M 558 160 L 520 175"/>
|
||||||
|
<text x="530" y="158" class="small">ANNOUNCE</text>
|
||||||
|
|
||||||
|
<text x="410" y="400" text-anchor="middle" class="small">d250 bytes per ESP-NOW frame · no JSON on wire</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,7 +0,0 @@
|
|||||||
# espnow-sender
|
|
||||||
|
|
||||||
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
|
|
||||||
|
|
||||||
- WebSocket endpoint: `/ws`
|
|
||||||
- Entry point: `main.py`
|
|
||||||
- Message template: `msg.json`
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
|
|
||||||
from microdot import Microdot
|
|
||||||
from microdot.websocket import WebSocketError, with_websocket
|
|
||||||
|
|
||||||
import espnow
|
|
||||||
import network
|
|
||||||
from util import format_mac, parse_mac
|
|
||||||
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
_esp = None
|
|
||||||
_known_peers = set()
|
|
||||||
_ws_clients = set()
|
|
||||||
|
|
||||||
|
|
||||||
def _init_espnow():
|
|
||||||
global _esp
|
|
||||||
sta = network.WLAN(network.STA_IF)
|
|
||||||
sta.active(True)
|
|
||||||
_esp = espnow.ESPNow()
|
|
||||||
_esp.active(True)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_envelope(obj):
|
|
||||||
if obj.get("v") != "1":
|
|
||||||
raise ValueError("message.v must be '1'")
|
|
||||||
devices = obj["devices"]
|
|
||||||
for address in devices.keys():
|
|
||||||
parse_mac(address)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _send_espnow(address, payload):
|
|
||||||
if _esp is None:
|
|
||||||
raise ValueError("espnow is not initialized")
|
|
||||||
mac = parse_mac(address)
|
|
||||||
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
||||||
if mac not in _known_peers:
|
|
||||||
_esp.add_peer(mac)
|
|
||||||
_known_peers.add(mac)
|
|
||||||
_esp.send(mac, msg)
|
|
||||||
return mac, len(msg)
|
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast_ws(obj):
|
|
||||||
text = json.dumps(obj)
|
|
||||||
dead = []
|
|
||||||
for client in list(_ws_clients):
|
|
||||||
try:
|
|
||||||
await client.send(text)
|
|
||||||
except Exception:
|
|
||||||
dead.append(client)
|
|
||||||
for client in dead:
|
|
||||||
_ws_clients.discard(client)
|
|
||||||
|
|
||||||
|
|
||||||
async def _espnow_receive_loop():
|
|
||||||
while True:
|
|
||||||
host, msg = _esp.recv(0)
|
|
||||||
if not host:
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
continue
|
|
||||||
await _broadcast_ws(
|
|
||||||
{
|
|
||||||
"from": format_mac(host),
|
|
||||||
"payload": msg.decode("utf-8"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/ws")
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
_ws_clients.add(ws)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
raw = await ws.receive()
|
|
||||||
except WebSocketError:
|
|
||||||
break
|
|
||||||
|
|
||||||
if not raw:
|
|
||||||
break
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(raw)
|
|
||||||
env = _validate_envelope(parsed)
|
|
||||||
sent = []
|
|
||||||
for address, payload in env["devices"].items():
|
|
||||||
mac, payload_size = _send_espnow(address, payload)
|
|
||||||
sent.append(
|
|
||||||
{
|
|
||||||
"address": format_mac(mac),
|
|
||||||
"bytes": payload_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
await ws.send(json.dumps({"ok": False, "error": str(e)}))
|
|
||||||
continue
|
|
||||||
|
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"sent": sent,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
_ws_clients.discard(ws)
|
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
|
||||||
_init_espnow()
|
|
||||||
asyncio.create_task(_espnow_receive_loop())
|
|
||||||
await app.start_server(host="0.0.0.0", port=port)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main(port=80))
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"v": "1",
|
|
||||||
"devices": {
|
|
||||||
"ff:ff:ff:ff:ff:ff": {
|
|
||||||
"presets": {
|
|
||||||
"preset_id": {
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FF0000"],
|
|
||||||
"delay": 100,
|
|
||||||
"brightness": 255,
|
|
||||||
"auto": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"select": {
|
|
||||||
"preset": "preset_id",
|
|
||||||
"step": 0
|
|
||||||
},
|
|
||||||
"save": true,
|
|
||||||
"default": "preset_id",
|
|
||||||
"b": 255
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
91
espnow-sender/src/bridge_http.py
Normal file
91
espnow-sender/src/bridge_http.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""HTTP settings API for the ESP-NOW bridge (AP IP, password, channel)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from settings import WIFI_CHANNEL_DEFAULT
|
||||||
|
|
||||||
|
_SETTINGS_KEYS = frozenset(
|
||||||
|
{"name", "ap_ip", "ap_password", "wifi_channel", "ws_port", "max_peers"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ipv4(value):
|
||||||
|
parts = str(value).strip().split(".")
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError("ap_ip must be dotted IPv4")
|
||||||
|
out = []
|
||||||
|
for p in parts:
|
||||||
|
n = int(p)
|
||||||
|
if n < 0 or n > 255:
|
||||||
|
raise ValueError("ap_ip octet out of range")
|
||||||
|
out.append(n)
|
||||||
|
return ".".join(str(x) for x in out)
|
||||||
|
|
||||||
|
|
||||||
|
def public_settings(settings):
|
||||||
|
return {
|
||||||
|
"name": settings.get("name", ""),
|
||||||
|
"ap_ip": settings.get("ap_ip", "192.168.4.1"),
|
||||||
|
"ap_password_set": bool(str(settings.get("ap_password") or "").strip()),
|
||||||
|
"wifi_channel": settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT),
|
||||||
|
"ws_port": settings.get("ws_port", 80),
|
||||||
|
"max_peers": settings.get("max_peers", 20),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_settings_update(settings, data):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("body must be a JSON object")
|
||||||
|
reboot_required = False
|
||||||
|
if "name" in data:
|
||||||
|
name = str(data["name"] or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("name is required")
|
||||||
|
if len(name) > 32:
|
||||||
|
raise ValueError("name too long")
|
||||||
|
settings["name"] = name
|
||||||
|
reboot_required = True
|
||||||
|
if "ap_ip" in data:
|
||||||
|
settings["ap_ip"] = _parse_ipv4(data["ap_ip"])
|
||||||
|
reboot_required = True
|
||||||
|
if "ap_password" in data:
|
||||||
|
pw = str(data["ap_password"] or "")
|
||||||
|
if pw and len(pw) < 8:
|
||||||
|
raise ValueError("ap_password must be at least 8 characters or empty")
|
||||||
|
settings["ap_password"] = pw
|
||||||
|
reboot_required = True
|
||||||
|
if "wifi_channel" in data:
|
||||||
|
ch = int(data["wifi_channel"])
|
||||||
|
if ch < 1 or ch > 11:
|
||||||
|
raise ValueError("wifi_channel must be 1–11")
|
||||||
|
settings["wifi_channel"] = ch
|
||||||
|
reboot_required = True
|
||||||
|
if "ws_port" in data:
|
||||||
|
port = int(data["ws_port"])
|
||||||
|
if port < 1 or port > 65535:
|
||||||
|
raise ValueError("ws_port out of range")
|
||||||
|
settings["ws_port"] = port
|
||||||
|
if "max_peers" in data:
|
||||||
|
settings["max_peers"] = max(1, min(20, int(data["max_peers"])))
|
||||||
|
return reboot_required
|
||||||
|
|
||||||
|
|
||||||
|
def register_bridge_routes(app, settings):
|
||||||
|
@app.get("/settings")
|
||||||
|
async def get_bridge_settings(request):
|
||||||
|
return json.dumps(public_settings(settings)), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@app.put("/settings")
|
||||||
|
async def put_bridge_settings(request):
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
reboot_required = apply_settings_update(settings, data)
|
||||||
|
settings.save()
|
||||||
|
body = public_settings(settings)
|
||||||
|
body["message"] = "Settings saved"
|
||||||
|
body["reboot_required"] = reboot_required
|
||||||
|
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as err:
|
||||||
|
return json.dumps({"error": str(err)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except Exception as err:
|
||||||
|
return json.dumps({"error": str(err)}), 500, {"Content-Type": "application/json"}
|
||||||
133
espnow-sender/src/main.py
Normal file
133
espnow-sender/src/main.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from microdot import Microdot
|
||||||
|
from microdot.websocket import WebSocketError, with_websocket
|
||||||
|
|
||||||
|
import aioespnow
|
||||||
|
import machine
|
||||||
|
from settings import Settings
|
||||||
|
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
|
||||||
|
from peer_table import PeerTable, load_max_peers
|
||||||
|
from downlink_router import is_devices_envelope, route_envelope
|
||||||
|
from wifi_ap import init_bridge_network
|
||||||
|
from util import print_bridge_ip
|
||||||
|
from bridge_http import register_bridge_routes
|
||||||
|
from machine import UART, Pin
|
||||||
|
|
||||||
|
wdt = machine.WDT(timeout=10000)
|
||||||
|
wdt.feed()
|
||||||
|
machine.freq(160000000)
|
||||||
|
settings = Settings()
|
||||||
|
print(settings)
|
||||||
|
|
||||||
|
uart = UART(1, baudrate=921600, tx=Pin(2), rx=Pin(3))
|
||||||
|
|
||||||
|
app = Microdot()
|
||||||
|
register_bridge_routes(app, settings)
|
||||||
|
|
||||||
|
init_bridge_network(settings)
|
||||||
|
print_bridge_ip(settings.get("ws_port", 80))
|
||||||
|
|
||||||
|
esp = aioespnow.AIOESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
esp.add_peer(BROADCAST_MAC)
|
||||||
|
|
||||||
|
peer_table = PeerTable(load_max_peers())
|
||||||
|
clients = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _note_uplink_peer(host, msg):
|
||||||
|
if host and len(host) == 6:
|
||||||
|
name = None
|
||||||
|
if msg and msg[0:1] == b"{":
|
||||||
|
try:
|
||||||
|
data = json.loads(msg)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
name = data.get("name")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
peer_table.touch(host, name, esp)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/ws")
|
||||||
|
@with_websocket
|
||||||
|
async def ws(request, ws):
|
||||||
|
clients.add(ws)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = await ws.receive()
|
||||||
|
except WebSocketError as err:
|
||||||
|
print(err)
|
||||||
|
break
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = raw.encode("utf-8")
|
||||||
|
try:
|
||||||
|
if is_devices_envelope(raw):
|
||||||
|
await route_envelope(esp, peer_table, raw)
|
||||||
|
else:
|
||||||
|
await esp.asend(BROADCAST_MAC, raw)
|
||||||
|
print(raw)
|
||||||
|
print("ws tx", len(raw), "B")
|
||||||
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
clients.discard(ws)
|
||||||
|
|
||||||
|
|
||||||
|
async def _espnow_receive_loop():
|
||||||
|
async for host, msg in esp:
|
||||||
|
if not host or not msg:
|
||||||
|
continue
|
||||||
|
_note_uplink_peer(host, msg)
|
||||||
|
print("espnow rx", len(msg), "B")
|
||||||
|
frame = pack_ws_uplink(host, msg)
|
||||||
|
dead = []
|
||||||
|
for client in list(clients):
|
||||||
|
try:
|
||||||
|
await client.send(frame)
|
||||||
|
except Exception:
|
||||||
|
dead.append(client)
|
||||||
|
for client in dead:
|
||||||
|
clients.discard(client)
|
||||||
|
uart.write(msg)
|
||||||
|
|
||||||
|
|
||||||
|
async def _serial_receive_loop():
|
||||||
|
while True:
|
||||||
|
if uart.any():
|
||||||
|
raw = uart.read()
|
||||||
|
print(raw)
|
||||||
|
try:
|
||||||
|
if is_devices_envelope(raw):
|
||||||
|
await route_envelope(esp, peer_table, raw)
|
||||||
|
else:
|
||||||
|
await esp.asend(BROADCAST_MAC, raw)
|
||||||
|
print(raw)
|
||||||
|
print("ws tx", len(raw), "B")
|
||||||
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wdt_feed_loop():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
wdt.feed()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
asyncio.create_task(_wdt_feed_loop())
|
||||||
|
asyncio.create_task(_espnow_receive_loop())
|
||||||
|
asyncio.create_task(_serial_receive_loop())
|
||||||
|
await app.start_server(host="0.0.0.0", port=80)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
90
espnow-sender/src/peer_table.py
Normal file
90
espnow-sender/src/peer_table.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""LRU table of ESP-NOW peer MACs seen on uplink."""
|
||||||
|
|
||||||
|
from espnow_wire import BROADCAST_MAC
|
||||||
|
|
||||||
|
try:
|
||||||
|
from settings import Settings
|
||||||
|
except ImportError:
|
||||||
|
Settings = None
|
||||||
|
|
||||||
|
# ESP32 counts the broadcast peer toward the ~20 peer limit.
|
||||||
|
_RESERVED_FOR_BROADCAST = 1
|
||||||
|
|
||||||
|
|
||||||
|
class PeerTable:
|
||||||
|
def __init__(self, max_peers=20):
|
||||||
|
limit = max(1, int(max_peers) - _RESERVED_FOR_BROADCAST)
|
||||||
|
self._max = limit
|
||||||
|
self._order = []
|
||||||
|
self._names = {}
|
||||||
|
|
||||||
|
def _evict_lru(self, esp):
|
||||||
|
if not self._order:
|
||||||
|
return
|
||||||
|
old = self._order.pop(0)
|
||||||
|
self._names.pop(old, None)
|
||||||
|
if esp is not None:
|
||||||
|
try:
|
||||||
|
esp.del_peer(old)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def touch(self, mac_bytes, name=None, esp=None):
|
||||||
|
"""Note a peer from uplink (LRU). Pass ``esp`` so evictions free ESP-NOW slots."""
|
||||||
|
if not mac_bytes or len(mac_bytes) != 6:
|
||||||
|
return
|
||||||
|
if mac_bytes == BROADCAST_MAC:
|
||||||
|
return
|
||||||
|
if mac_bytes in self._order:
|
||||||
|
self._order.remove(mac_bytes)
|
||||||
|
elif len(self._order) >= self._max:
|
||||||
|
self._evict_lru(esp)
|
||||||
|
self._order.append(mac_bytes)
|
||||||
|
if name:
|
||||||
|
self._names[mac_bytes] = str(name)
|
||||||
|
if esp is not None:
|
||||||
|
try:
|
||||||
|
esp.add_peer(mac_bytes)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ensure_peer(self, esp, mac_bytes):
|
||||||
|
"""Register ``mac_bytes`` on ESP-NOW, evicting LRU peers when the table is full."""
|
||||||
|
if not mac_bytes or len(mac_bytes) != 6:
|
||||||
|
return False
|
||||||
|
if mac_bytes == BROADCAST_MAC:
|
||||||
|
try:
|
||||||
|
esp.add_peer(mac_bytes)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
if mac_bytes in self._order:
|
||||||
|
self._order.remove(mac_bytes)
|
||||||
|
self._order.append(mac_bytes)
|
||||||
|
else:
|
||||||
|
while len(self._order) >= self._max:
|
||||||
|
self._evict_lru(esp)
|
||||||
|
self._order.append(mac_bytes)
|
||||||
|
# Uplink touch() only updates LRU; always add_peer before unicast send.
|
||||||
|
try:
|
||||||
|
esp.add_peer(mac_bytes)
|
||||||
|
except OSError as err:
|
||||||
|
print("add_peer failed", err)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def peers(self):
|
||||||
|
return list(self._order)
|
||||||
|
|
||||||
|
def is_broadcast_mac(self, mac_bytes):
|
||||||
|
return mac_bytes == BROADCAST_MAC
|
||||||
|
|
||||||
|
|
||||||
|
def load_max_peers():
|
||||||
|
if Settings is None:
|
||||||
|
return 20
|
||||||
|
try:
|
||||||
|
s = Settings()
|
||||||
|
return int(s.get("max_peers", 20))
|
||||||
|
except Exception:
|
||||||
|
return 20
|
||||||
48
espnow-sender/src/util.py
Normal file
48
espnow-sender/src/util.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
def parse_mac(value):
|
||||||
|
raw = value.strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(raw) != 12:
|
||||||
|
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
|
||||||
|
try:
|
||||||
|
return bytes.fromhex(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("address contains non-hex characters")
|
||||||
|
|
||||||
|
|
||||||
|
def format_mac(mac_bytes):
|
||||||
|
return ":".join("{:02x}".format(b) for b in mac_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def print_bridge_ip(ws_port=80):
|
||||||
|
import network
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = int(ws_port)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = 80
|
||||||
|
|
||||||
|
ips = []
|
||||||
|
try:
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
if sta.active():
|
||||||
|
ip = sta.ifconfig()[0]
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
ips.append(("STA", ip))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ap = network.WLAN(network.AP_IF)
|
||||||
|
if ap.active():
|
||||||
|
ip = ap.ifconfig()[0]
|
||||||
|
if ip and ip != "0.0.0.0":
|
||||||
|
ips.append(("AP", ip))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
print("bridge IP: (AP not up)")
|
||||||
|
return
|
||||||
|
|
||||||
|
ips.sort(key=lambda x: 0 if x[0] == "AP" else 1)
|
||||||
|
_label, ip = ips[0]
|
||||||
|
print("bridge IP (AP):", ip)
|
||||||
|
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
def parse_mac(value):
|
|
||||||
raw = value.strip().lower().replace(":", "").replace("-", "")
|
|
||||||
if len(raw) != 12:
|
|
||||||
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
|
|
||||||
try:
|
|
||||||
return bytes.fromhex(raw)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError("address contains non-hex characters")
|
|
||||||
|
|
||||||
|
|
||||||
def format_mac(mac_bytes):
|
|
||||||
return ":".join("{:02x}".format(b) for b in mac_bytes)
|
|
||||||
Submodule led-driver updated: a79c6f4dd3...3286c4002d
Submodule led-simulator updated: 42c14361e8...4fc3345fc9
2
led-tool
2
led-tool
Submodule led-tool updated: 580fd11aca...2961ad2a29
@@ -1,3 +1,5 @@
|
|||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
python_files = ["test_endpoints_pytest.py"]
|
python_files = ["test_*.py"]
|
||||||
|
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
|
||||||
|
norecursedirs = ["models"]
|
||||||
|
|||||||
419
scripts/create_winter_profile.py
Normal file
419
scripts/create_winter_profile.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add Winter profile: 6-light 2x3 grid, presets, and sequences."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DB = ROOT / "db"
|
||||||
|
|
||||||
|
PROFILE_ID = "3"
|
||||||
|
PALETTE_ID = "14"
|
||||||
|
ZONE_PRESETS_ID = "11"
|
||||||
|
ZONE_SEQUENCES_ID = "12"
|
||||||
|
|
||||||
|
# 2x3 grid device MACs (placeholders — assign real devices in the UI)
|
||||||
|
DEVICE_MACS = [
|
||||||
|
"a0b100000001", # r0c0 top-left
|
||||||
|
"a0b100000002", # r0c1
|
||||||
|
"a0b100000003", # r0c2
|
||||||
|
"a0b100000004", # r1c0 bottom-left
|
||||||
|
"a0b100000005", # r1c1
|
||||||
|
"a0b100000006", # r1c2
|
||||||
|
]
|
||||||
|
|
||||||
|
GROUP_CELL = {
|
||||||
|
"a0b100000001": "6",
|
||||||
|
"a0b100000002": "7",
|
||||||
|
"a0b100000003": "8",
|
||||||
|
"a0b100000004": "9",
|
||||||
|
"a0b100000005": "10",
|
||||||
|
"a0b100000006": "11",
|
||||||
|
}
|
||||||
|
GROUP_TOP_ROW = "12"
|
||||||
|
GROUP_BOTTOM_ROW = "13"
|
||||||
|
GROUP_COL_LEFT = "14"
|
||||||
|
GROUP_COL_MID = "15"
|
||||||
|
GROUP_COL_RIGHT = "16"
|
||||||
|
GROUP_ALL = "17"
|
||||||
|
|
||||||
|
PRESET_OFF = "78"
|
||||||
|
PRESET_TWINKLE = "79"
|
||||||
|
PRESET_ICICLES = "80"
|
||||||
|
PRESET_BLIZZARD = "81"
|
||||||
|
PRESET_RIME = "82"
|
||||||
|
PRESET_AURORA = "83"
|
||||||
|
PRESET_STARFALL = "84"
|
||||||
|
PRESET_SPARKLE = "85"
|
||||||
|
PRESET_COOL_WHITE = "86"
|
||||||
|
PRESET_CHASE_ICE = "87"
|
||||||
|
|
||||||
|
SEQ_CASCADE = "12"
|
||||||
|
SEQ_ROWS = "13"
|
||||||
|
SEQ_COLUMNS = "14"
|
||||||
|
SEQ_BLIZZARD_ALL = "15"
|
||||||
|
SEQ_ROTATION = "16"
|
||||||
|
|
||||||
|
|
||||||
|
def load_json(name: str) -> dict:
|
||||||
|
path = DB / f"{name}.json"
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def save_json(name: str, data: dict) -> None:
|
||||||
|
path = DB / f"{name}.json"
|
||||||
|
path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def preset_skeleton(name: str, pattern: str, colors: list, **extra) -> dict:
|
||||||
|
doc = {
|
||||||
|
"name": name,
|
||||||
|
"pattern": pattern,
|
||||||
|
"colors": colors,
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 80,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 0,
|
||||||
|
"n2": 0,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
"n5": 0,
|
||||||
|
"n6": 0,
|
||||||
|
"n7": 0,
|
||||||
|
"n8": 0,
|
||||||
|
"profile_id": PROFILE_ID,
|
||||||
|
"background": "#0A1520",
|
||||||
|
"manual_beat_n": 1,
|
||||||
|
}
|
||||||
|
doc.update(extra)
|
||||||
|
if "palette_refs" not in doc and pattern not in ("on", "off"):
|
||||||
|
doc["palette_refs"] = [None] * len(colors)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def seq_doc(
|
||||||
|
name: str,
|
||||||
|
lanes: list,
|
||||||
|
lanes_group_ids: list,
|
||||||
|
*,
|
||||||
|
loop: bool = True,
|
||||||
|
simulated_bpm: int = 90,
|
||||||
|
) -> dict:
|
||||||
|
steps = [step for lane in lanes for step in lane]
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"profile_id": PROFILE_ID,
|
||||||
|
"group_ids": [GROUP_ALL],
|
||||||
|
"lanes": lanes,
|
||||||
|
"lanes_group_ids": lanes_group_ids,
|
||||||
|
"advance_mode": "beats",
|
||||||
|
"steps": steps,
|
||||||
|
"step_duration_ms": 3000,
|
||||||
|
"simulated_bpm": simulated_bpm,
|
||||||
|
"sequence_transition": 500,
|
||||||
|
"loop": loop,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
profiles = load_json("profile")
|
||||||
|
palettes = load_json("palette")
|
||||||
|
groups = load_json("group")
|
||||||
|
devices = load_json("device")
|
||||||
|
zones = load_json("zone")
|
||||||
|
sequences = load_json("sequence")
|
||||||
|
presets = load_json("preset")
|
||||||
|
|
||||||
|
labels = [
|
||||||
|
("winter top-left", 0),
|
||||||
|
("winter top-centre", 1),
|
||||||
|
("winter top-right", 2),
|
||||||
|
("winter bottom-left", 3),
|
||||||
|
("winter bottom-centre", 4),
|
||||||
|
("winter bottom-right", 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
profiles[PROFILE_ID] = {
|
||||||
|
"name": "Winter",
|
||||||
|
"type": "zones",
|
||||||
|
"zones": [ZONE_PRESETS_ID, ZONE_SEQUENCES_ID],
|
||||||
|
"scenes": [],
|
||||||
|
"palette_id": PALETTE_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
palettes[PALETTE_ID] = [
|
||||||
|
"#E8F4FF",
|
||||||
|
"#9ECFFF",
|
||||||
|
"#5080C8",
|
||||||
|
"#FFFFFF",
|
||||||
|
"#B0DCFF",
|
||||||
|
"#0A1520",
|
||||||
|
"#FF8020",
|
||||||
|
"#071018",
|
||||||
|
]
|
||||||
|
|
||||||
|
for mac, (label, _idx) in zip(DEVICE_MACS, labels):
|
||||||
|
devices[mac] = {
|
||||||
|
"id": mac,
|
||||||
|
"name": label,
|
||||||
|
"type": "led",
|
||||||
|
"transport": "wifi",
|
||||||
|
"address": "",
|
||||||
|
"default_pattern": None,
|
||||||
|
"zones": [],
|
||||||
|
"output_brightness": 255,
|
||||||
|
"wifi_color_order": "rgb",
|
||||||
|
"wifi_startup_mode": "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
def group_row(gid: str, name: str, macs: list) -> None:
|
||||||
|
groups[gid] = {
|
||||||
|
"name": name,
|
||||||
|
"devices": macs,
|
||||||
|
"profile_id": PROFILE_ID,
|
||||||
|
"wifi_color_order": "rgb",
|
||||||
|
"wifi_startup_mode": "default",
|
||||||
|
"output_brightness": 255,
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["000000", "E8F4FF"],
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
for mac, gid in zip(DEVICE_MACS, GROUP_CELL.values()):
|
||||||
|
group_row(gid, labels[DEVICE_MACS.index(mac)][0], [mac])
|
||||||
|
|
||||||
|
group_row(GROUP_TOP_ROW, "winter top row", DEVICE_MACS[:3])
|
||||||
|
group_row(GROUP_BOTTOM_ROW, "winter bottom row", DEVICE_MACS[3:])
|
||||||
|
group_row(GROUP_COL_LEFT, "winter left column", [DEVICE_MACS[0], DEVICE_MACS[3]])
|
||||||
|
group_row(GROUP_COL_MID, "winter centre column", [DEVICE_MACS[1], DEVICE_MACS[4]])
|
||||||
|
group_row(GROUP_COL_RIGHT, "winter right column", [DEVICE_MACS[2], DEVICE_MACS[5]])
|
||||||
|
group_row(GROUP_ALL, "winter grid (all)", list(DEVICE_MACS))
|
||||||
|
|
||||||
|
presets[PRESET_OFF] = preset_skeleton("winter off", "off", [], brightness=0, delay=100)
|
||||||
|
presets[PRESET_TWINKLE] = preset_skeleton(
|
||||||
|
"winter twinkle",
|
||||||
|
"twinkle",
|
||||||
|
["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||||
|
n1=150,
|
||||||
|
n2=20,
|
||||||
|
n4=10,
|
||||||
|
delay=100,
|
||||||
|
)
|
||||||
|
presets[PRESET_ICICLES] = preset_skeleton(
|
||||||
|
"winter icicles",
|
||||||
|
"icicles",
|
||||||
|
["#F0F8FF", "#9ECFFF", "#FFFFFF"],
|
||||||
|
n1=14,
|
||||||
|
n2=11,
|
||||||
|
n3=1,
|
||||||
|
delay=80,
|
||||||
|
)
|
||||||
|
presets[PRESET_BLIZZARD] = preset_skeleton(
|
||||||
|
"winter blizzard",
|
||||||
|
"blizzard",
|
||||||
|
["#FFFFFF", "#CDE8FF", "#AACCF5"],
|
||||||
|
n1=110,
|
||||||
|
n2=2,
|
||||||
|
n3=140,
|
||||||
|
delay=45,
|
||||||
|
)
|
||||||
|
presets[PRESET_RIME] = preset_skeleton(
|
||||||
|
"winter rime",
|
||||||
|
"rime",
|
||||||
|
["#E8F4FF", "#FFFFFF", "#B8DCF8"],
|
||||||
|
n1=40,
|
||||||
|
n2=18,
|
||||||
|
n3=4,
|
||||||
|
delay=120,
|
||||||
|
)
|
||||||
|
presets[PRESET_AURORA] = preset_skeleton(
|
||||||
|
"winter aurora",
|
||||||
|
"aurora",
|
||||||
|
["#183050", "#5090C8", "#C8E8FF"],
|
||||||
|
n1=22,
|
||||||
|
n2=210,
|
||||||
|
n6=1,
|
||||||
|
delay=90,
|
||||||
|
)
|
||||||
|
presets[PRESET_STARFALL] = preset_skeleton(
|
||||||
|
"winter starfall",
|
||||||
|
"particles",
|
||||||
|
["#FFFFFF", "#C8E8FF", "#FFF8E0"],
|
||||||
|
n1=16,
|
||||||
|
n2=2,
|
||||||
|
n3=12,
|
||||||
|
n6=1,
|
||||||
|
delay=55,
|
||||||
|
)
|
||||||
|
presets[PRESET_SPARKLE] = preset_skeleton(
|
||||||
|
"winter ice sparkle",
|
||||||
|
"sparkle",
|
||||||
|
["#E8F4FF", "#B0DCFF", "#FFFFFF"],
|
||||||
|
n1=70,
|
||||||
|
n2=165,
|
||||||
|
n3=1,
|
||||||
|
n6=1,
|
||||||
|
delay=50,
|
||||||
|
)
|
||||||
|
presets[PRESET_COOL_WHITE] = preset_skeleton(
|
||||||
|
"winter cool white",
|
||||||
|
"on",
|
||||||
|
["#E6F2FF"],
|
||||||
|
brightness=200,
|
||||||
|
delay=100,
|
||||||
|
)
|
||||||
|
presets[PRESET_CHASE_ICE] = preset_skeleton(
|
||||||
|
"winter ice chase",
|
||||||
|
"chase",
|
||||||
|
["#E8F4FF", "#5080C8"],
|
||||||
|
auto=False,
|
||||||
|
n1=20,
|
||||||
|
n2=20,
|
||||||
|
n3=15,
|
||||||
|
n4=15,
|
||||||
|
delay=120,
|
||||||
|
background="#071018",
|
||||||
|
)
|
||||||
|
|
||||||
|
grid_presets = [
|
||||||
|
[PRESET_ICICLES, PRESET_TWINKLE, PRESET_BLIZZARD],
|
||||||
|
[PRESET_RIME, PRESET_AURORA, PRESET_STARFALL],
|
||||||
|
]
|
||||||
|
flat = [p for row in grid_presets for p in row]
|
||||||
|
|
||||||
|
zones[ZONE_PRESETS_ID] = {
|
||||||
|
"name": "Winter grid",
|
||||||
|
"names": [],
|
||||||
|
"group_ids": [GROUP_ALL],
|
||||||
|
"preset_group_ids": {},
|
||||||
|
"presets": grid_presets,
|
||||||
|
"presets_flat": flat,
|
||||||
|
"default_preset": PRESET_TWINKLE,
|
||||||
|
"brightness": 200,
|
||||||
|
"sequence_ids": [],
|
||||||
|
"content_kind": "presets",
|
||||||
|
}
|
||||||
|
|
||||||
|
sequences[SEQ_CASCADE] = seq_doc(
|
||||||
|
"Winter cell cascade",
|
||||||
|
[
|
||||||
|
[{"preset_id": PRESET_ICICLES, "beats": 6}],
|
||||||
|
[{"preset_id": PRESET_SPARKLE, "beats": 6}],
|
||||||
|
[{"preset_id": PRESET_BLIZZARD, "beats": 6}],
|
||||||
|
[{"preset_id": PRESET_RIME, "beats": 6}],
|
||||||
|
[{"preset_id": PRESET_AURORA, "beats": 6}],
|
||||||
|
[{"preset_id": PRESET_STARFALL, "beats": 6}],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[GROUP_CELL[DEVICE_MACS[0]]],
|
||||||
|
[GROUP_CELL[DEVICE_MACS[1]]],
|
||||||
|
[GROUP_CELL[DEVICE_MACS[2]]],
|
||||||
|
[GROUP_CELL[DEVICE_MACS[3]]],
|
||||||
|
[GROUP_CELL[DEVICE_MACS[4]]],
|
||||||
|
[GROUP_CELL[DEVICE_MACS[5]]],
|
||||||
|
],
|
||||||
|
simulated_bpm=85,
|
||||||
|
)
|
||||||
|
|
||||||
|
sequences[SEQ_ROWS] = seq_doc(
|
||||||
|
"Winter row waves",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{"preset_id": PRESET_BLIZZARD, "beats": 8},
|
||||||
|
{"preset_id": PRESET_ICICLES, "beats": 8},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{"preset_id": PRESET_AURORA, "beats": 8},
|
||||||
|
{"preset_id": PRESET_RIME, "beats": 8},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[[GROUP_TOP_ROW], [GROUP_BOTTOM_ROW]],
|
||||||
|
simulated_bpm=80,
|
||||||
|
)
|
||||||
|
|
||||||
|
sequences[SEQ_COLUMNS] = seq_doc(
|
||||||
|
"Winter column chase",
|
||||||
|
[
|
||||||
|
[{"preset_id": PRESET_CHASE_ICE, "beats": 12}],
|
||||||
|
[{"preset_id": PRESET_TWINKLE, "beats": 12}],
|
||||||
|
[{"preset_id": PRESET_STARFALL, "beats": 12}],
|
||||||
|
],
|
||||||
|
[[GROUP_COL_LEFT], [GROUP_COL_MID], [GROUP_COL_RIGHT]],
|
||||||
|
simulated_bpm=95,
|
||||||
|
)
|
||||||
|
|
||||||
|
sequences[SEQ_BLIZZARD_ALL] = seq_doc(
|
||||||
|
"Winter full blizzard",
|
||||||
|
[[{"preset_id": PRESET_BLIZZARD, "beats": 16}]],
|
||||||
|
[[GROUP_ALL]],
|
||||||
|
simulated_bpm=75,
|
||||||
|
)
|
||||||
|
|
||||||
|
sequences[SEQ_ROTATION] = seq_doc(
|
||||||
|
"Winter showcase",
|
||||||
|
[
|
||||||
|
[
|
||||||
|
{"preset_id": PRESET_ICICLES, "beats": 8},
|
||||||
|
{"preset_id": PRESET_BLIZZARD, "beats": 8},
|
||||||
|
{"preset_id": PRESET_RIME, "beats": 8},
|
||||||
|
{"preset_id": PRESET_AURORA, "beats": 8},
|
||||||
|
{"preset_id": PRESET_STARFALL, "beats": 8},
|
||||||
|
{"preset_id": PRESET_TWINKLE, "beats": 8},
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[[GROUP_ALL]],
|
||||||
|
simulated_bpm=72,
|
||||||
|
)
|
||||||
|
|
||||||
|
zones[ZONE_SEQUENCES_ID] = {
|
||||||
|
"name": "Winter sequences",
|
||||||
|
"names": [],
|
||||||
|
"group_ids": [GROUP_ALL],
|
||||||
|
"preset_group_ids": {},
|
||||||
|
"presets": [],
|
||||||
|
"presets_flat": [],
|
||||||
|
"default_preset": None,
|
||||||
|
"brightness": 200,
|
||||||
|
"sequence_ids": [
|
||||||
|
SEQ_CASCADE,
|
||||||
|
SEQ_ROWS,
|
||||||
|
SEQ_COLUMNS,
|
||||||
|
SEQ_BLIZZARD_ALL,
|
||||||
|
SEQ_ROTATION,
|
||||||
|
],
|
||||||
|
"content_kind": "sequences",
|
||||||
|
}
|
||||||
|
|
||||||
|
save_json("profile", profiles)
|
||||||
|
save_json("palette", palettes)
|
||||||
|
save_json("group", groups)
|
||||||
|
save_json("device", devices)
|
||||||
|
save_json("zone", zones)
|
||||||
|
save_json("sequence", sequences)
|
||||||
|
save_json("preset", presets)
|
||||||
|
|
||||||
|
print("Winter profile created:")
|
||||||
|
print(f" profile {PROFILE_ID}, palette {PALETTE_ID}")
|
||||||
|
print(f" zones {ZONE_PRESETS_ID} (presets 2x3), {ZONE_SEQUENCES_ID} (sequences)")
|
||||||
|
print(f" devices {', '.join(DEVICE_MACS)}")
|
||||||
|
print(f" groups {GROUP_CELL} + rows/cols/all")
|
||||||
|
print(f" presets {PRESET_OFF}-{PRESET_CHASE_ICE}")
|
||||||
|
print(f" sequences {SEQ_CASCADE}-{SEQ_ROTATION}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
24
scripts/mpremote_send_ch5.sh
Executable file
24
scripts/mpremote_send_ch5.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Upload and run a device-side ESP-NOW sender script.
|
||||||
|
# Default channel is 5 and default destination is broadcast.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/mpremote_send_ch5.sh [port] [dest_mac_hex] [payload_hex]
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# scripts/mpremote_send_ch5.sh /dev/ttyACM0
|
||||||
|
# scripts/mpremote_send_ch5.sh /dev/ttyACM0 ffffffffffff 4c0501000000
|
||||||
|
|
||||||
|
PORT="${1:-/dev/ttyACM0}"
|
||||||
|
DEST_HEX="${2:-ffffffffffff}"
|
||||||
|
PAYLOAD_HEX="${3:-4c0501000000}"
|
||||||
|
CHANNEL=5
|
||||||
|
DEVICE_SCRIPT="send_ch5.py"
|
||||||
|
|
||||||
|
mpremote connect "${PORT}" fs cp "scripts/mpremote_send_ch5_device.py" ":${DEVICE_SCRIPT}"
|
||||||
|
mpremote connect "${PORT}" exec "
|
||||||
|
import ${DEVICE_SCRIPT%.*}
|
||||||
|
${DEVICE_SCRIPT%.*}.send_once('${DEST_HEX}', '${PAYLOAD_HEX}', ${CHANNEL})
|
||||||
|
"
|
||||||
42
scripts/mpremote_send_ch5_device.py
Normal file
42
scripts/mpremote_send_ch5_device.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Device-side ESP-NOW sender (MicroPython, channel 5)."""
|
||||||
|
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
import ubinascii
|
||||||
|
|
||||||
|
|
||||||
|
CHANNEL = 5
|
||||||
|
DEST_HEX = "ffffffffffff"
|
||||||
|
PAYLOAD_HEX = "4c0501000000"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_channel(channel):
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
sta.config(pm=network.WLAN.PM_NONE)
|
||||||
|
sta.config(channel=channel)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_peer(esp, dest, channel):
|
||||||
|
try:
|
||||||
|
esp.add_peer(dest, channel=channel)
|
||||||
|
except TypeError:
|
||||||
|
esp.add_peer(dest)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
|
||||||
|
dest = ubinascii.unhexlify(dest_hex)
|
||||||
|
pkt = ubinascii.unhexlify(payload_hex)
|
||||||
|
_set_channel(channel)
|
||||||
|
e = espnow.ESPNow()
|
||||||
|
e.active(True)
|
||||||
|
_add_peer(e, dest, channel)
|
||||||
|
ok = e.send(dest, pkt, True)
|
||||||
|
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
send_once()
|
||||||
@@ -2,15 +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.transport import get_current_sender
|
from models.group import Group
|
||||||
from models.wifi_ws_clients import (
|
from models.transport import get_current_bridge
|
||||||
normalize_tcp_peer_ip,
|
from settings import get_settings
|
||||||
send_json_line_to_ip,
|
from util.brightness_combine import effective_brightness_for_mac
|
||||||
tcp_client_connected,
|
|
||||||
)
|
|
||||||
from util.driver_patterns import driver_patterns_dir
|
from util.driver_patterns import driver_patterns_dir
|
||||||
from util.espnow_message import build_message
|
from util.espnow_message import build_message
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -52,22 +51,33 @@ 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 = get_settings()
|
||||||
|
|
||||||
|
|
||||||
def _device_live_connected(dev_dict):
|
def _device_live_connected(dev_dict):
|
||||||
"""
|
"""ESP-NOW has no live session flag on the Pi."""
|
||||||
Wi-Fi: whether the controller has an outbound WebSocket to this device's IP.
|
|
||||||
ESP-NOW: None (no Wi-Fi session on the Pi for that transport).
|
|
||||||
"""
|
|
||||||
tr = (dev_dict.get("transport") or "espnow").strip().lower()
|
|
||||||
if tr != "wifi":
|
|
||||||
return None
|
return None
|
||||||
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
|
|
||||||
if not ip:
|
|
||||||
return False
|
|
||||||
return tcp_client_connected(ip)
|
|
||||||
|
|
||||||
|
|
||||||
def _device_json_with_live_status(dev_dict):
|
def _device_json_with_live_status(dev_dict):
|
||||||
@@ -131,18 +141,111 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
|
|||||||
return b" 2" in first_line
|
return b" 2" in first_line
|
||||||
|
|
||||||
|
|
||||||
async def _identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name):
|
async def _identify_send_off_after_delay(bridge, dev_id):
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||||
off_msg = build_message(select={name: ["off"]})
|
await bridge.send(
|
||||||
if transport == "wifi":
|
{"v": "1", "select": ["off"]},
|
||||||
await send_json_line_to_ip(wifi_ip, off_msg)
|
addr=dev_id,
|
||||||
else:
|
)
|
||||||
await sender.send(off_msg, addr=dev_id)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _identify_send_off_after_delay_broadcast(bridge, group_ids=None):
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||||
|
body = {"v": "1", "select": ["off"]}
|
||||||
|
if group_ids:
|
||||||
|
body["groups"] = [str(g) for g in group_ids if str(g).strip()]
|
||||||
|
await bridge.send(body)
|
||||||
|
except Exception:
|
||||||
|
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"
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
|
return 503, "Transport not configured"
|
||||||
|
try:
|
||||||
|
ok = await bridge.send(
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
"select": [_IDENTIFY_PRESET_KEY],
|
||||||
|
},
|
||||||
|
addr=dev_id,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return 503, "Send failed"
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
_identify_send_off_after_delay(bridge, dev_id)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return 503, str(e)
|
||||||
|
return 200, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def send_identify_to_group_devices(
|
||||||
|
macs: list[str],
|
||||||
|
*,
|
||||||
|
group_ids: list[str] | None = None,
|
||||||
|
) -> tuple[int, list[dict]]:
|
||||||
|
"""
|
||||||
|
Identify all drivers in ``group_ids`` via broadcast; members filter on ``groups``.
|
||||||
|
|
||||||
|
``macs`` is only used for the API ``sent`` count (group member list), not for addressing.
|
||||||
|
"""
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
errors: list[dict] = []
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
|
return 0, [{"mac": "*", "error": "Transport not configured"}]
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"v": "1",
|
||||||
|
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||||
|
"select": [_IDENTIFY_PRESET_KEY],
|
||||||
|
}
|
||||||
|
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||||
|
if gids:
|
||||||
|
body["groups"] = gids
|
||||||
|
|
||||||
|
try:
|
||||||
|
deliveries, _chunks = await deliver_json_messages(
|
||||||
|
bridge,
|
||||||
|
[json.dumps(body, separators=(",", ":"))],
|
||||||
|
None,
|
||||||
|
devices,
|
||||||
|
delay_s=0,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return 0, errors + [{"mac": "*", "error": str(e)}]
|
||||||
|
|
||||||
|
if deliveries < 1:
|
||||||
|
return 0, errors + [{"mac": "*", "error": "Send failed"}]
|
||||||
|
|
||||||
|
asyncio.create_task(_identify_send_off_after_delay_broadcast(bridge, gids))
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in macs:
|
||||||
|
m = normalize_mac(str(raw))
|
||||||
|
if m and m not in seen:
|
||||||
|
seen.add(m)
|
||||||
|
return len(seen), 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 +257,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 +378,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",
|
||||||
@@ -264,6 +413,46 @@ async def delete_device(request, id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/groups")
|
||||||
|
async def update_device_groups(request):
|
||||||
|
"""Push current group membership to all ESP-NOW drivers in the registry."""
|
||||||
|
_ = request
|
||||||
|
from util.espnow_registry import push_groups_all_espnow_devices
|
||||||
|
|
||||||
|
result = await push_groups_all_espnow_devices()
|
||||||
|
status = 200 if result.get("ok") else 503
|
||||||
|
if not result.get("total"):
|
||||||
|
return (
|
||||||
|
json.dumps({"ok": False, "error": "No ESP-NOW devices in registry"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return json.dumps(result), status, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/ping")
|
||||||
|
async def ping_devices(request):
|
||||||
|
"""
|
||||||
|
Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s).
|
||||||
|
JSON body: ``{"timeout_s": 3.0}`` (optional).
|
||||||
|
"""
|
||||||
|
from util.espnow_ping import run_ping
|
||||||
|
|
||||||
|
timeout_s = 3.0
|
||||||
|
try:
|
||||||
|
body = request.json or {}
|
||||||
|
if isinstance(body, dict) and body.get("timeout_s") is not None:
|
||||||
|
timeout_s = float(body["timeout_s"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return json.dumps({"error": "Invalid timeout_s"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
timeout_s = max(0.5, min(30.0, timeout_s))
|
||||||
|
result = await run_ping(timeout_s=timeout_s)
|
||||||
|
status = 200 if result.get("ok") else 503
|
||||||
|
return json.dumps(result), status, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
@controller.post("/<id>/identify")
|
@controller.post("/<id>/identify")
|
||||||
async def identify_device(request, id):
|
async def identify_device(request, id):
|
||||||
"""
|
"""
|
||||||
@@ -271,51 +460,106 @@ 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
|
||||||
|
if isinstance(body, dict) and body.get("zone_brightness") is not None:
|
||||||
|
try:
|
||||||
|
zb = _validate_output_brightness(body.get("zone_brightness"))
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
b_val = effective_brightness_for_mac(
|
||||||
|
_pi_settings,
|
||||||
|
_group_registry,
|
||||||
|
devices,
|
||||||
|
id,
|
||||||
|
zone_brightness=zb,
|
||||||
|
)
|
||||||
|
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
name = str(dev.get("name") or "").strip()
|
|
||||||
if not name:
|
|
||||||
return json.dumps({"error": "Device must have a name to identify"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
transport = dev.get("transport") or "espnow"
|
|
||||||
wifi_ip = None
|
|
||||||
if transport == "wifi":
|
|
||||||
wifi_ip = dev.get("address")
|
|
||||||
if not wifi_ip:
|
|
||||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = _compact_v1_json(
|
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
|
||||||
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
|
||||||
select={name: [_IDENTIFY_PRESET_KEY]},
|
|
||||||
)
|
|
||||||
if transport == "wifi":
|
|
||||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
return json.dumps({"error": "Send failed"}), 503, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
await sender.send(msg, addr=id)
|
|
||||||
|
|
||||||
asyncio.create_task(
|
|
||||||
_identify_send_off_after_delay(sender, transport, wifi_ip, id, name)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"message": "Identify sent"}), 200, {
|
|
||||||
|
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/<id>/driver-config")
|
||||||
|
async def push_driver_config(request, id):
|
||||||
|
"""
|
||||||
|
Push ``device_config`` to an ESP-NOW LED driver.
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
|
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||||
|
"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"}
|
||||||
|
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"error": "Send failed"}), 503, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
return json.dumps({"message": "driver-config sent"}), 200, {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,71 +567,13 @@ async def identify_device(request, id):
|
|||||||
@controller.post("/<id>/patterns/push")
|
@controller.post("/<id>/patterns/push")
|
||||||
async def push_patterns_ota(request, id):
|
async def push_patterns_ota(request, id):
|
||||||
"""
|
"""
|
||||||
Push all local pattern files directly to a Wi-Fi LED driver over HTTP upload.
|
Pattern OTA over HTTP is not available for ESP-NOW drivers.
|
||||||
"""
|
"""
|
||||||
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",
|
||||||
}
|
}
|
||||||
if (dev.get("transport") or "").lower() != "wifi":
|
return json.dumps(
|
||||||
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
|
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
|
||||||
"Content-Type": "application/json",
|
), 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",
|
|
||||||
}
|
|
||||||
|
|
||||||
base_dir = driver_patterns_dir()
|
|
||||||
try:
|
|
||||||
names = sorted(os.listdir(base_dir))
|
|
||||||
except OSError as e:
|
|
||||||
return json.dumps({"error": str(e)}), 500, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
files = [n for n in names if _safe_pattern_filename(n) and n != "__init__.py"]
|
|
||||||
if not files:
|
|
||||||
return json.dumps({"error": "No pattern files found"}), 404, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
sent = []
|
|
||||||
failed = []
|
|
||||||
total = len(files)
|
|
||||||
for idx, filename in enumerate(files):
|
|
||||||
path = os.path.join(base_dir, filename)
|
|
||||||
try:
|
|
||||||
with open(path, "r") as f:
|
|
||||||
code = f.read()
|
|
||||||
except OSError:
|
|
||||||
failed.append(filename)
|
|
||||||
continue
|
|
||||||
reload_patterns = idx == (total - 1)
|
|
||||||
ok = _http_post_pattern_source(
|
|
||||||
wifi_ip,
|
|
||||||
filename,
|
|
||||||
code,
|
|
||||||
reload_patterns=reload_patterns,
|
|
||||||
timeout_s=10.0,
|
|
||||||
)
|
|
||||||
if ok:
|
|
||||||
sent.append(filename)
|
|
||||||
else:
|
|
||||||
failed.append(filename)
|
|
||||||
|
|
||||||
if not sent:
|
|
||||||
return json.dumps({"error": "Wi-Fi driver did not accept pattern uploads", "failed": failed}), 503, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.dumps({
|
|
||||||
"message": "Pattern files uploaded",
|
|
||||||
"sent_count": len(sent),
|
|
||||||
"sent": sent,
|
|
||||||
"failed": failed,
|
|
||||||
}), 200, {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,50 +1,356 @@
|
|||||||
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_bridge
|
||||||
|
from util.espnow_registry import push_groups_for_group_devices
|
||||||
|
from settings import get_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 = get_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_bridge_id_write(data):
|
||||||
|
"""Per-group bridge assignment is disabled; ignore writes."""
|
||||||
|
if isinstance(data, dict) and "bridge_id" in data:
|
||||||
|
data["bridge_id"] = None
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
_sanitize_group_bridge_id_write(data)
|
||||||
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)})
|
||||||
|
g = groups.read(group_id)
|
||||||
|
if g:
|
||||||
|
await push_groups_for_group_devices(g)
|
||||||
|
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)
|
||||||
|
_sanitize_group_bridge_id_write(data)
|
||||||
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:
|
||||||
|
await push_groups_for_group_devices(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
|
||||||
|
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
|
||||||
if groups.delete(id):
|
if groups.delete(id):
|
||||||
|
await push_groups_for_group_devices({"devices": macs})
|
||||||
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 driver defaults to every ESP-NOW device listed in the group.
|
||||||
|
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 = []
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
|
return json.dumps({"error": "Transport not configured"}), 503
|
||||||
|
payload = {"v": "1", "device_config": dc, "save": True}
|
||||||
|
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
|
||||||
|
try:
|
||||||
|
if await bridge.send(payload, addr=m):
|
||||||
|
sent += 1
|
||||||
|
else:
|
||||||
|
errors.append({"mac": m, "error": "send failed"})
|
||||||
|
except Exception as e:
|
||||||
|
errors.append({"mac": m, "error": str(e)})
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if not bridge:
|
||||||
|
return m, False, "transport not configured"
|
||||||
|
try:
|
||||||
|
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=m)
|
||||||
|
return m, bool(ok), None if ok else "send failed"
|
||||||
|
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, group_ids=[str(id)]
|
||||||
|
)
|
||||||
|
errors.extend(batch_errors)
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{"message": "identify group done", "sent": sent, "errors": errors}
|
||||||
|
), 200, {"Content-Type": "application/json"}
|
||||||
|
|||||||
@@ -3,20 +3,40 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from microdot import Microdot
|
from microdot import Microdot, send_file
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
|
|
||||||
|
_STATIC_ALLOWED = frozenset(
|
||||||
|
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> str:
|
def _repo_root() -> str:
|
||||||
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
|
|
||||||
|
def _led_tool_static_dir() -> str:
|
||||||
|
return os.path.join(_repo_root(), "led-tool", "static")
|
||||||
|
|
||||||
|
|
||||||
def _led_cli_path() -> str:
|
def _led_cli_path() -> str:
|
||||||
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
return os.path.join(_repo_root(), "led-tool", "cli.py")
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_host_serial_ports(ports: list) -> list:
|
||||||
|
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
|
||||||
|
if not os.path.isfile(mod_path):
|
||||||
|
return ports
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod.filter_port_dicts(ports)
|
||||||
|
|
||||||
|
|
||||||
def _build_led_cli_command(port: str, payload: dict):
|
def _build_led_cli_command(port: str, payload: dict):
|
||||||
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
cmd = [sys.executable, _led_cli_path(), "--port", port]
|
||||||
|
|
||||||
@@ -92,16 +112,40 @@ def _extract_settings_from_stdout(stdout: str):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/editor")
|
||||||
|
async def settings_editor_page(request):
|
||||||
|
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
|
||||||
|
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/static/<path:filename>")
|
||||||
|
async def led_tool_static(request, filename):
|
||||||
|
if filename not in _STATIC_ALLOWED:
|
||||||
|
return "Not found", 404
|
||||||
|
path = os.path.join(_led_tool_static_dir(), filename)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return "Not found", 404
|
||||||
|
return send_file(path)
|
||||||
|
|
||||||
|
|
||||||
@controller.get("/ports")
|
@controller.get("/ports")
|
||||||
async def list_serial_ports(request):
|
async def list_serial_ports(request):
|
||||||
ports = []
|
ports = _filter_host_serial_ports(
|
||||||
for info in list_ports.comports():
|
[
|
||||||
ports.append(
|
|
||||||
{
|
{
|
||||||
"device": info.device,
|
"device": info.device,
|
||||||
"description": info.description,
|
"description": info.description,
|
||||||
"hwid": info.hwid,
|
"hwid": info.hwid,
|
||||||
}
|
}
|
||||||
|
for info in list_ports.comports()
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
json.dumps(
|
json.dumps(
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ async def create_driver_pattern(request):
|
|||||||
Body JSON:
|
Body JSON:
|
||||||
name, code (required),
|
name, code (required),
|
||||||
min_delay, max_delay, max_colors (optional numbers),
|
min_delay, max_delay, max_colors (optional numbers),
|
||||||
|
has_background (optional bool),
|
||||||
|
supports_manual (optional bool, default true if omitted in db),
|
||||||
n1..n8 (optional string labels),
|
n1..n8 (optional string labels),
|
||||||
overwrite (optional, default true).
|
overwrite (optional, default true).
|
||||||
"""
|
"""
|
||||||
@@ -409,6 +411,12 @@ async def create_driver_pattern(request):
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if "has_background" in data:
|
||||||
|
meta["has_background"] = bool(data.get("has_background"))
|
||||||
|
|
||||||
|
if "supports_manual" in data:
|
||||||
|
meta["supports_manual"] = bool(data.get("supports_manual"))
|
||||||
|
|
||||||
for i in range(1, 9):
|
for i in range(1, 9):
|
||||||
nk = "n%d" % i
|
nk = "n%d" % i
|
||||||
if nk not in data:
|
if nk not in data:
|
||||||
|
|||||||
@@ -2,16 +2,33 @@ 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_bridge
|
||||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
from util.driver_delivery import (
|
||||||
|
build_preset_json_chunks,
|
||||||
|
deliver_json_messages,
|
||||||
|
)
|
||||||
from util.espnow_message import build_message, build_preset_dict
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
|
from util.profile_bundle import export_preset_bundle, import_preset_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_colors_for_profile(profile_id):
|
||||||
|
prof = profiles.read(str(profile_id))
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
return None
|
||||||
|
pid = prof.get("palette_id") or prof.get("paletteId")
|
||||||
|
if not pid:
|
||||||
|
return None
|
||||||
|
cols = Palette().read(str(pid))
|
||||||
|
return cols if isinstance(cols, list) else None
|
||||||
|
|
||||||
|
|
||||||
def get_current_profile_id(session=None):
|
def get_current_profile_id(session=None):
|
||||||
"""Get the current active profile ID from session or fallback to first."""
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
profile_list = profiles.list()
|
profile_list = profiles.list()
|
||||||
@@ -37,6 +54,41 @@ async def list_presets(request, session):
|
|||||||
}
|
}
|
||||||
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/<preset_id>/export')
|
||||||
|
@with_session
|
||||||
|
async def export_preset(request, session, preset_id):
|
||||||
|
"""Export one preset as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
preset = presets.read(preset_id)
|
||||||
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
try:
|
||||||
|
bundle = export_preset_bundle(preset_id, presets)
|
||||||
|
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/import')
|
||||||
|
@with_session
|
||||||
|
async def import_preset(request, session):
|
||||||
|
"""Import a preset bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
|
||||||
|
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.get('/<preset_id>')
|
@controller.get('/<preset_id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def get_preset(request, session, preset_id):
|
async def get_preset(request, session, preset_id):
|
||||||
@@ -153,6 +205,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 +214,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
|
||||||
|
|
||||||
@@ -171,42 +224,16 @@ async def send_presets(request, session):
|
|||||||
if default_id is not None and str(default_id) not in presets_by_name:
|
if default_id is not None and str(default_id) not in presets_by_name:
|
||||||
default_id = None
|
default_id = None
|
||||||
|
|
||||||
sender = get_current_sender()
|
bridge = get_current_bridge()
|
||||||
if not sender:
|
if not bridge:
|
||||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
MAX_BYTES = 240
|
|
||||||
send_delay_s = 0.1
|
send_delay_s = 0.1
|
||||||
entries = list(presets_by_name.items())
|
total_presets = len(presets_by_name)
|
||||||
total_presets = len(entries)
|
chunk_messages = build_preset_json_chunks(
|
||||||
|
presets_by_name,
|
||||||
batch = {}
|
|
||||||
chunk_messages = []
|
|
||||||
for name, preset_obj in entries:
|
|
||||||
test_batch = dict(batch)
|
|
||||||
test_batch[name] = preset_obj
|
|
||||||
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
|
|
||||||
size = len(test_msg)
|
|
||||||
|
|
||||||
if size <= MAX_BYTES or not batch:
|
|
||||||
batch = test_batch
|
|
||||||
else:
|
|
||||||
chunk_messages.append(
|
|
||||||
build_message(
|
|
||||||
presets=dict(batch),
|
|
||||||
save=False,
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
batch = {name: preset_obj}
|
|
||||||
|
|
||||||
if batch:
|
|
||||||
chunk_messages.append(
|
|
||||||
build_message(
|
|
||||||
presets=dict(batch),
|
|
||||||
save=save_flag,
|
save=save_flag,
|
||||||
default=default_id,
|
default=str(default_id) if default_id is not None else None,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_list = None
|
target_list = None
|
||||||
@@ -224,20 +251,50 @@ async def send_presets(request, session):
|
|||||||
dm = normalize_mac(str(destination_mac))
|
dm = normalize_mac(str(destination_mac))
|
||||||
target_list = [dm] if dm else None
|
target_list = [dm] if dm else None
|
||||||
|
|
||||||
|
group_ids = data.get("group_ids") or data.get("groups")
|
||||||
|
if isinstance(group_ids, list):
|
||||||
|
group_ids = [str(g).strip() for g in group_ids if str(g).strip()]
|
||||||
|
else:
|
||||||
|
group_ids = None
|
||||||
|
|
||||||
|
unicast = bool(data.get("unicast")) or bool(destination_mac)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if target_list:
|
if unicast and target_list:
|
||||||
deliveries = await deliver_preset_broadcast_then_per_device(
|
deliveries = 0
|
||||||
sender,
|
for msg in chunk_messages:
|
||||||
chunk_messages,
|
d, _chunks = await deliver_json_messages(
|
||||||
|
bridge, [msg],
|
||||||
target_list,
|
target_list,
|
||||||
Device(),
|
Device(),
|
||||||
str(default_id) if default_id is not None else None,
|
|
||||||
delay_s=send_delay_s,
|
delay_s=send_delay_s,
|
||||||
|
unicast=True,
|
||||||
)
|
)
|
||||||
|
deliveries += d
|
||||||
|
if default_id is not None:
|
||||||
|
def_msg = json.dumps(
|
||||||
|
{"v": "1", "default": str(default_id), "save": True},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
d, _chunks = await deliver_json_messages(
|
||||||
|
bridge,
|
||||||
|
[def_msg],
|
||||||
|
target_list,
|
||||||
|
Device(),
|
||||||
|
delay_s=send_delay_s,
|
||||||
|
unicast=True,
|
||||||
|
)
|
||||||
|
deliveries += d
|
||||||
else:
|
else:
|
||||||
|
wire_messages = []
|
||||||
|
for msg in chunk_messages:
|
||||||
|
body = json.loads(msg)
|
||||||
|
if group_ids:
|
||||||
|
body["groups"] = list(group_ids)
|
||||||
|
wire_messages.append(json.dumps(body, separators=(",", ":")))
|
||||||
deliveries, _chunks = await deliver_json_messages(
|
deliveries, _chunks = await deliver_json_messages(
|
||||||
sender,
|
bridge,
|
||||||
chunk_messages,
|
wire_messages,
|
||||||
None,
|
None,
|
||||||
Device(),
|
Device(),
|
||||||
delay_s=send_delay_s,
|
delay_s=send_delay_s,
|
||||||
@@ -285,18 +342,37 @@ async def push_driver_messages(request, session):
|
|||||||
if not target_list:
|
if not target_list:
|
||||||
target_list = None
|
target_list = None
|
||||||
|
|
||||||
sender = get_current_sender()
|
bridge = get_current_bridge()
|
||||||
if not sender:
|
if not bridge:
|
||||||
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
messages = []
|
messages = []
|
||||||
for item in seq:
|
i = 0
|
||||||
if isinstance(item, dict):
|
while i < len(seq):
|
||||||
messages.append(json.dumps(item))
|
item = seq[i]
|
||||||
elif isinstance(item, str):
|
if not isinstance(item, dict):
|
||||||
|
if isinstance(item, str):
|
||||||
messages.append(item)
|
messages.append(item)
|
||||||
else:
|
i += 1
|
||||||
|
continue
|
||||||
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
|
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
nxt = seq[i + 1] if i + 1 < len(seq) else None
|
||||||
|
if (
|
||||||
|
isinstance(nxt, dict)
|
||||||
|
and "presets" in item
|
||||||
|
and "select" not in item
|
||||||
|
and "select" in nxt
|
||||||
|
and "presets" not in nxt
|
||||||
|
):
|
||||||
|
combined = dict(item)
|
||||||
|
combined["select"] = nxt["select"]
|
||||||
|
combined_str = json.dumps(combined, separators=(",", ":"))
|
||||||
|
if len(combined_str.encode("utf-8")) <= 248:
|
||||||
|
messages.append(combined_str)
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
messages.append(json.dumps(item, separators=(",", ":")))
|
||||||
|
i += 1
|
||||||
|
|
||||||
delay_s = data.get("delay_s", 0.05)
|
delay_s = data.get("delay_s", 0.05)
|
||||||
try:
|
try:
|
||||||
@@ -304,17 +380,31 @@ async def push_driver_messages(request, session):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
delay_s = 0.05
|
delay_s = 0.05
|
||||||
|
|
||||||
|
unicast = bool(data.get("unicast"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
deliveries, _chunks = await deliver_json_messages(
|
deliveries, _chunks = await deliver_json_messages(
|
||||||
sender,
|
bridge,
|
||||||
messages,
|
messages,
|
||||||
target_list,
|
target_list,
|
||||||
Device(),
|
Device(),
|
||||||
delay_s=delay_s,
|
delay_s=delay_s,
|
||||||
|
unicast=unicast,
|
||||||
)
|
)
|
||||||
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,
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ from microdot.session import with_session
|
|||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
from models.zone import Zone
|
from models.zone import Zone
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.sequence import Sequence
|
||||||
|
from util.profile_bundle import export_profile_bundle, import_profile_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
zones = Zone()
|
zones = Zone()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
|
sequences = Sequence()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
|
|||||||
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "No profile available"}), 404
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
|
||||||
@controller.get('/<id>')
|
|
||||||
@with_session
|
|
||||||
async def get_profile(request, id, session):
|
|
||||||
"""Get a specific profile by ID."""
|
|
||||||
# Handle 'current' as a special case
|
|
||||||
if id == 'current':
|
|
||||||
return await get_current_profile(request, session)
|
|
||||||
|
|
||||||
profile = profiles.read(id)
|
@controller.post('/import')
|
||||||
if profile:
|
@with_session
|
||||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
async def import_profile(request, session):
|
||||||
return json.dumps({"error": "Profile not found"}), 404
|
"""Import a profile bundle (optionally apply as current profile)."""
|
||||||
|
try:
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
name = body.get("name") if isinstance(body, dict) else None
|
||||||
|
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
|
||||||
|
if isinstance(apply_raw, str):
|
||||||
|
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
apply = bool(apply_raw)
|
||||||
|
|
||||||
|
new_profile_id, profile_data = import_profile_bundle(
|
||||||
|
bundle,
|
||||||
|
profiles,
|
||||||
|
zones,
|
||||||
|
presets,
|
||||||
|
sequences,
|
||||||
|
profiles._palette_model,
|
||||||
|
name=str(name).strip() if name else None,
|
||||||
|
)
|
||||||
|
if apply:
|
||||||
|
session['current_profile'] = str(new_profile_id)
|
||||||
|
session.save()
|
||||||
|
return (
|
||||||
|
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
|
||||||
|
201,
|
||||||
|
{'Content-Type': 'application/json'},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>/export')
|
||||||
|
async def export_profile(request, id):
|
||||||
|
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
|
||||||
|
try:
|
||||||
|
bundle = export_profile_bundle(
|
||||||
|
str(id),
|
||||||
|
profiles,
|
||||||
|
zones,
|
||||||
|
presets,
|
||||||
|
sequences,
|
||||||
|
profiles._palette_model,
|
||||||
|
)
|
||||||
|
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|
||||||
@controller.post('/<id>/apply')
|
@controller.post('/<id>/apply')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
|
|||||||
session.save()
|
session.save()
|
||||||
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_profile(request):
|
|
||||||
"""Create a new profile."""
|
|
||||||
try:
|
|
||||||
data = dict(request.json or {})
|
|
||||||
name = data.get("name", "")
|
|
||||||
seed_raw = data.get("seed_dj_zone", False)
|
|
||||||
if isinstance(seed_raw, str):
|
|
||||||
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
|
||||||
else:
|
|
||||||
seed_dj_zone = bool(seed_raw)
|
|
||||||
# Request-only flag: do not persist on profile records.
|
|
||||||
data.pop("seed_dj_zone", None)
|
|
||||||
profile_id = profiles.create(name)
|
|
||||||
# Avoid persisting request-only fields.
|
|
||||||
data.pop("name", None)
|
|
||||||
if data:
|
|
||||||
profiles.update(profile_id, data)
|
|
||||||
|
|
||||||
# New profiles always start with a default zone pre-populated with starter presets.
|
|
||||||
default_preset_ids = []
|
|
||||||
default_preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "on",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#FFFFFF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "off",
|
|
||||||
"pattern": "off",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 0,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Colour Cycle",
|
|
||||||
"pattern": "colour_cycle",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 100,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 500,
|
|
||||||
"auto": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flicker",
|
|
||||||
"pattern": "flicker",
|
|
||||||
"colors": ["#FFB84D"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 80,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "flame",
|
|
||||||
"pattern": "flame",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 50,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 35,
|
|
||||||
"n2": 2600,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "twinkle",
|
|
||||||
"pattern": "twinkle",
|
|
||||||
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 55,
|
|
||||||
"auto": True,
|
|
||||||
"n1": 72,
|
|
||||||
"n2": 140,
|
|
||||||
"n3": 2,
|
|
||||||
"n4": 6,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in default_preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
default_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
|
||||||
zones.update(default_tab_id, {
|
|
||||||
"presets_flat": default_preset_ids,
|
|
||||||
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile = profiles.read(profile_id) or {}
|
|
||||||
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
|
||||||
profile_tabs.append(str(default_tab_id))
|
|
||||||
|
|
||||||
if seed_dj_zone:
|
|
||||||
# Seed a DJ-focused zone with three starter presets.
|
|
||||||
seeded_preset_ids = []
|
|
||||||
preset_defs = [
|
|
||||||
{
|
|
||||||
"name": "DJ Rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 60,
|
|
||||||
"n1": 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Single Color",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": ["#ff00ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DJ Transition",
|
|
||||||
"pattern": "transition",
|
|
||||||
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
|
||||||
"brightness": 220,
|
|
||||||
"delay": 250,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
for preset_data in preset_defs:
|
|
||||||
pid = presets.create(profile_id)
|
|
||||||
presets.update(pid, preset_data)
|
|
||||||
seeded_preset_ids.append(str(pid))
|
|
||||||
|
|
||||||
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
|
||||||
zones.update(dj_tab_id, {
|
|
||||||
"presets_flat": seeded_preset_ids,
|
|
||||||
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile_tabs.append(str(dj_tab_id))
|
|
||||||
|
|
||||||
profiles.update(profile_id, {"zones": profile_tabs})
|
|
||||||
|
|
||||||
profile_data = profiles.read(profile_id)
|
|
||||||
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
|
||||||
return json.dumps({"error": str(e)}), 400
|
|
||||||
|
|
||||||
@controller.post('/<id>/clone')
|
@controller.post('/<id>/clone')
|
||||||
async def clone_profile(request, id):
|
async def clone_profile(request, id):
|
||||||
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get('/<id>')
|
||||||
|
@with_session
|
||||||
|
async def get_profile(request, id, session):
|
||||||
|
"""Get a specific profile by ID."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
return await get_current_profile(request, session)
|
||||||
|
|
||||||
|
profile = profiles.read(id)
|
||||||
|
if profile:
|
||||||
|
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
@controller.post('')
|
||||||
|
async def create_profile(request):
|
||||||
|
"""Create a new profile."""
|
||||||
|
try:
|
||||||
|
data = dict(request.json or {})
|
||||||
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_zone", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_zone = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_zone", None)
|
||||||
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
|
if data:
|
||||||
|
profiles.update(profile_id, data)
|
||||||
|
|
||||||
|
# New profiles always start with a default zone pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Colour Cycle",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flicker",
|
||||||
|
"pattern": "flicker",
|
||||||
|
"colors": ["#FFB84D"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 80,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "flame",
|
||||||
|
"pattern": "flame",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 50,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 35,
|
||||||
|
"n2": 2600,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "twinkle",
|
||||||
|
"pattern": "twinkle",
|
||||||
|
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 55,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 72,
|
||||||
|
"n2": 140,
|
||||||
|
"n3": 2,
|
||||||
|
"n4": 6,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
zones.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_zone:
|
||||||
|
# Seed a DJ-focused zone with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "colour_cycle",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
"mode": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
zones.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"zones": profile_tabs})
|
||||||
|
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/current')
|
@controller.put('/current')
|
||||||
@with_session
|
@with_session
|
||||||
async def update_current_profile(request, session):
|
async def update_current_profile(request, session):
|
||||||
|
|||||||
@@ -1,51 +1,298 @@
|
|||||||
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_bridge
|
||||||
|
from models.preset import Preset
|
||||||
|
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
sequences = Sequence()
|
sequences = Sequence()
|
||||||
|
profiles = Profile()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
@controller.get('')
|
|
||||||
async def list_sequences(request):
|
|
||||||
"""List all sequences."""
|
|
||||||
return json.dumps(sequences), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
@controller.get('/<id>')
|
def get_current_profile_id(session=None):
|
||||||
async def get_sequence(request, id):
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
"""Get a specific sequence by ID."""
|
profile_list = profiles.list()
|
||||||
sequence = sequences.read(id)
|
session_profile = None
|
||||||
if sequence:
|
if session is not None:
|
||||||
return json.dumps(sequence), 200, {'Content-Type': 'application/json'}
|
session_profile = session.get("current_profile")
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
@with_session
|
||||||
|
async def list_sequences(request, session):
|
||||||
|
"""List sequences for the current profile."""
|
||||||
|
sequences.load()
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||||
|
scoped = {
|
||||||
|
sid: sdata
|
||||||
|
for sid, sdata in sequences.items()
|
||||||
|
if isinstance(sdata, dict)
|
||||||
|
and str(sdata.get("profile_id")) == str(current_profile_id)
|
||||||
|
}
|
||||||
|
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>/export")
|
||||||
|
@with_session
|
||||||
|
async def export_sequence(request, session, id):
|
||||||
|
"""Export a sequence and referenced presets as a JSON bundle."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
bundle = export_sequence_bundle(
|
||||||
|
id,
|
||||||
|
sequences,
|
||||||
|
presets,
|
||||||
|
profile_id=current_profile_id,
|
||||||
|
)
|
||||||
|
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/import")
|
||||||
|
@with_session
|
||||||
|
async def import_sequence(request, session):
|
||||||
|
"""Import a sequence bundle into the current profile."""
|
||||||
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
body = request.json or {}
|
||||||
|
bundle = body.get("bundle") if isinstance(body, dict) else body
|
||||||
|
if not isinstance(bundle, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Expected JSON bundle"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
|
||||||
|
return (
|
||||||
|
json.dumps({new_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
@with_session
|
||||||
|
async def get_sequence(request, session, id):
|
||||||
|
"""Get a specific sequence by ID (current profile only)."""
|
||||||
|
sequences.load()
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if (
|
||||||
|
seq
|
||||||
|
and current_profile_id
|
||||||
|
and str(seq.get("profile_id")) == str(current_profile_id)
|
||||||
|
):
|
||||||
|
return json.dumps(seq), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
|
||||||
async def create_sequence(request):
|
@controller.post("")
|
||||||
"""Create a new sequence."""
|
@with_session
|
||||||
|
async def create_sequence(request, session):
|
||||||
|
"""Create a new sequence for the current profile."""
|
||||||
|
try:
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
group_name = data.get("group_name", "")
|
except Exception:
|
||||||
preset_names = data.get("presets", None)
|
return (
|
||||||
sequence_id = sequences.create(group_name, preset_names)
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
if data:
|
400,
|
||||||
sequences.update(sequence_id, data)
|
{"Content-Type": "application/json"},
|
||||||
return json.dumps(sequences.read(sequence_id)), 201, {'Content-Type': 'application/json'}
|
)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No profile available"}),
|
||||||
|
404,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
sequence_id = sequences.create(current_profile_id)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
|
if sequences.update(sequence_id, data):
|
||||||
|
seq_data = sequences.read(sequence_id)
|
||||||
|
return (
|
||||||
|
json.dumps({sequence_id: seq_data}),
|
||||||
|
201,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Failed to create sequence"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
@controller.put('/<id>')
|
|
||||||
async def update_sequence(request, id):
|
@controller.put("/<id>")
|
||||||
"""Update an existing sequence."""
|
@with_session
|
||||||
|
async def update_sequence(request, session, id):
|
||||||
|
"""Update an existing sequence (current profile only)."""
|
||||||
try:
|
try:
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
data = request.json
|
data = request.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "Invalid JSON"}),
|
||||||
|
400,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
if sequences.update(id, data):
|
if sequences.update(id, data):
|
||||||
return json.dumps(sequences.read(id)), 200, {'Content-Type': 'application/json'}
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
|
||||||
async def delete_sequence(request, id):
|
@controller.delete("/<id>")
|
||||||
"""Delete a sequence."""
|
@with_session
|
||||||
if sequences.delete(id):
|
async def delete_sequence(request, session, id):
|
||||||
return json.dumps({"message": "Sequence deleted successfully"}), 200
|
"""Delete a sequence (current profile only)."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
seq = sequences.read(id)
|
||||||
|
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
|
||||||
return json.dumps({"error": "Sequence not found"}), 404
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import stop_if_playing_sequence
|
||||||
|
|
||||||
|
stop_if_playing_sequence(str(id))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if sequences.delete(id):
|
||||||
|
return (
|
||||||
|
json.dumps({"message": "Sequence deleted successfully"}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return json.dumps({"error": "Sequence not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/sync-phase")
|
||||||
|
@with_session
|
||||||
|
async def sync_sequence_beat_phase(request, session):
|
||||||
|
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
|
||||||
|
_ = session
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
mode = data.get("mode") or data.get("align") or "step"
|
||||||
|
try:
|
||||||
|
from util.sequence_playback import sync_beat_phase
|
||||||
|
|
||||||
|
if not await sync_beat_phase(str(mode)):
|
||||||
|
return (
|
||||||
|
json.dumps({"error": "No sequence is playing"}),
|
||||||
|
409,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
from util.audio_detector import anchor_shared_bar_phase
|
||||||
|
|
||||||
|
anchor_shared_bar_phase()
|
||||||
|
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@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_playback
|
||||||
|
|
||||||
|
await stop_playback(clear_devices=True)
|
||||||
|
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_bridge():
|
||||||
|
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
|
||||||
|
|
||||||
|
play_opts = data if isinstance(data, dict) else None
|
||||||
|
await start(zone_id, str(id), str(current_profile_id), play_opts)
|
||||||
|
from util.sequence_playback import pending_play_status
|
||||||
|
|
||||||
|
body = {"ok": True, **pending_play_status()}
|
||||||
|
return json.dumps(body), 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"}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import json
|
|||||||
|
|
||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
|
|
||||||
from models import wifi_ws_clients
|
from settings import get_settings
|
||||||
from settings import Settings
|
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def get_settings(request):
|
async def get_settings(request):
|
||||||
@@ -75,7 +74,28 @@ def _validate_global_brightness(value):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
@controller.put('/settings')
|
def _validate_sequence_switch_wait(value):
|
||||||
|
s = str(value).strip().lower()
|
||||||
|
if s not in ("beat", "downbeat"):
|
||||||
|
raise ValueError("sequence_switch_wait must be beat or downbeat")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_audio_beat_phase_ms(value):
|
||||||
|
v = int(value)
|
||||||
|
if v < 0 or v > 500:
|
||||||
|
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_audio_input_volume(value):
|
||||||
|
v = int(value)
|
||||||
|
if v < 0 or v > 200:
|
||||||
|
raise ValueError("audio_input_volume must be between 0 and 200")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put('')
|
||||||
async def update_settings(request):
|
async def update_settings(request):
|
||||||
"""Update general settings."""
|
"""Update general settings."""
|
||||||
try:
|
try:
|
||||||
@@ -87,16 +107,15 @@ async def update_settings(request):
|
|||||||
elif key == 'global_brightness' and value is not None:
|
elif key == 'global_brightness' and value is not None:
|
||||||
settings[key] = _validate_global_brightness(value)
|
settings[key] = _validate_global_brightness(value)
|
||||||
global_brightness_changed = True
|
global_brightness_changed = True
|
||||||
|
elif key == 'sequence_switch_wait' and value is not None:
|
||||||
|
settings[key] = _validate_sequence_switch_wait(value)
|
||||||
|
elif key == 'audio_beat_phase_ms' and value is not None:
|
||||||
|
settings[key] = _validate_audio_beat_phase_ms(value)
|
||||||
|
elif key == 'audio_input_volume' and value is not None:
|
||||||
|
settings[key] = _validate_audio_input_volume(value)
|
||||||
else:
|
else:
|
||||||
settings[key] = value
|
settings[key] = value
|
||||||
settings.save()
|
settings.save()
|
||||||
if global_brightness_changed:
|
|
||||||
try:
|
|
||||||
asyncio.get_running_loop().create_task(
|
|
||||||
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
|
|
||||||
)
|
|
||||||
except RuntimeError:
|
|
||||||
pass
|
|
||||||
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
282
src/controllers/wifi_bridge.py
Normal file
282
src/controllers/wifi_bridge.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Pi Wi‑Fi and saved ESP-NOW bridge profiles."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from microdot import Microdot
|
||||||
|
|
||||||
|
from settings import get_settings
|
||||||
|
from util.bridge_profiles import find_bridge_profile, normalise_bridges
|
||||||
|
from util.bridge_runtime import (
|
||||||
|
active_bridge_profile_id,
|
||||||
|
bridge_connected,
|
||||||
|
bridge_serial_connected,
|
||||||
|
bridge_ws_connected,
|
||||||
|
connect_bridge_profile,
|
||||||
|
connect_bridge_serial,
|
||||||
|
connect_bridge_wifi,
|
||||||
|
)
|
||||||
|
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_transport(settings) -> str:
|
||||||
|
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||||
|
return mode if mode in ("wifi", "serial") else "wifi"
|
||||||
|
|
||||||
|
|
||||||
|
def _bridges_payload(settings) -> dict:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"wifi_interface": settings.get("wifi_interface") or "",
|
||||||
|
"bridge_ws_url": settings.get("bridge_ws_url") or "",
|
||||||
|
"bridge_connected": bridge_connected(),
|
||||||
|
"bridge_wifi_connected": bridge_ws_connected(),
|
||||||
|
"bridge_serial_connected": bridge_serial_connected(),
|
||||||
|
"bridge_transport": _bridge_transport(settings),
|
||||||
|
"active_bridge_id": active_bridge_profile_id(settings) or "",
|
||||||
|
"bridge_serial_port": settings.get("bridge_serial_port") or "",
|
||||||
|
"bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600),
|
||||||
|
"bridges": normalise_bridges(settings.get("bridges")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/interfaces")
|
||||||
|
async def wifi_interfaces(request):
|
||||||
|
_ = request
|
||||||
|
if not nmcli_available():
|
||||||
|
return (
|
||||||
|
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
|
||||||
|
503,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
|
||||||
|
200,
|
||||||
|
{"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/scan")
|
||||||
|
async def wifi_scan(request):
|
||||||
|
device = (request.args.get("device") or "").strip()
|
||||||
|
if not device:
|
||||||
|
return json.dumps({"error": "device query param required"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if not nmcli_available():
|
||||||
|
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
networks = await scan_wifi(device)
|
||||||
|
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/bridges")
|
||||||
|
async def get_bridges(request):
|
||||||
|
_ = request
|
||||||
|
settings = get_settings()
|
||||||
|
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/bridges")
|
||||||
|
async def put_bridges(request):
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
settings = get_settings()
|
||||||
|
if "wifi_interface" in data:
|
||||||
|
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
|
||||||
|
if "bridge_transport" in data:
|
||||||
|
mode = str(data.get("bridge_transport") or "").strip().lower()
|
||||||
|
if mode in ("wifi", "serial"):
|
||||||
|
settings["bridge_transport"] = mode
|
||||||
|
if "bridge_ws_url" in data:
|
||||||
|
settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip()
|
||||||
|
if "bridge_serial_port" in data:
|
||||||
|
settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip()
|
||||||
|
if "bridge_serial_baudrate" in data:
|
||||||
|
settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600)
|
||||||
|
if "bridges" in data:
|
||||||
|
settings["bridges"] = normalise_bridges(data.get("bridges"))
|
||||||
|
settings.save()
|
||||||
|
return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.delete("/bridges/<bridge_id>")
|
||||||
|
async def delete_bridge_profile(request, bridge_id):
|
||||||
|
_ = request
|
||||||
|
settings = get_settings()
|
||||||
|
bid = str(bridge_id or "").strip()
|
||||||
|
bridges = normalise_bridges(settings.get("bridges"))
|
||||||
|
kept = [b for b in bridges if str(b.get("id") or "") != bid]
|
||||||
|
if len(kept) == len(bridges):
|
||||||
|
return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
settings["bridges"] = kept
|
||||||
|
settings.save()
|
||||||
|
payload = _bridges_payload(settings)
|
||||||
|
payload["message"] = "Bridge profile deleted"
|
||||||
|
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/bridges/<bridge_id>/connect")
|
||||||
|
async def connect_saved_bridge(request, bridge_id):
|
||||||
|
_ = request
|
||||||
|
settings = get_settings()
|
||||||
|
profile = find_bridge_profile(settings, bridge_id)
|
||||||
|
if not profile:
|
||||||
|
return json.dumps({"error": "Bridge profile not found"}), 404, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
ok, err = await connect_bridge_profile(profile, settings)
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = _bridges_payload(settings)
|
||||||
|
payload["message"] = f"Connected to {profile.get('label')}"
|
||||||
|
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/connect")
|
||||||
|
async def wifi_connect_bridge(request):
|
||||||
|
"""Join a bridge AP and open its WebSocket."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
settings = get_settings()
|
||||||
|
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
|
||||||
|
ssid = str(data.get("ssid") or "").strip()
|
||||||
|
password = str(data.get("password") or "")
|
||||||
|
ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip()
|
||||||
|
try:
|
||||||
|
ws_port = int(data.get("ws_port") or 80)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ws_port = 80
|
||||||
|
label = str(data.get("label") or ssid).strip() or ssid
|
||||||
|
save_profile = bool(data.get("save_profile", True))
|
||||||
|
if not device:
|
||||||
|
return json.dumps({"error": "Wi‑Fi interface (device) is required"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if not ssid:
|
||||||
|
return json.dumps({"error": "ssid is required"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
settings["wifi_interface"] = device
|
||||||
|
bridges = normalise_bridges(settings.get("bridges"))
|
||||||
|
profile_id = None
|
||||||
|
if save_profile:
|
||||||
|
profile_id = secrets.token_hex(6)
|
||||||
|
bridges = [
|
||||||
|
b
|
||||||
|
for b in bridges
|
||||||
|
if not (b.get("transport") == "wifi" and b.get("ssid") == ssid)
|
||||||
|
]
|
||||||
|
bridges.append(
|
||||||
|
{
|
||||||
|
"id": profile_id,
|
||||||
|
"label": label,
|
||||||
|
"transport": "wifi",
|
||||||
|
"ssid": ssid,
|
||||||
|
"password": password,
|
||||||
|
"ap_ip": ap_ip,
|
||||||
|
"ws_port": ws_port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings["bridges"] = bridges
|
||||||
|
settings.save()
|
||||||
|
profile = {
|
||||||
|
"transport": "wifi",
|
||||||
|
"ssid": ssid,
|
||||||
|
"password": password,
|
||||||
|
"ap_ip": ap_ip,
|
||||||
|
"ws_port": ws_port,
|
||||||
|
"wifi_interface": device,
|
||||||
|
}
|
||||||
|
ok, err = await connect_bridge_wifi(profile, settings)
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = _bridges_payload(settings)
|
||||||
|
payload["profile_id"] = profile_id
|
||||||
|
payload["message"] = f"Connected to {ssid}"
|
||||||
|
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("/serial/connect")
|
||||||
|
async def serial_connect_bridge(request):
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
port = str(data.get("port") or data.get("serial_port") or "").strip()
|
||||||
|
save_profile = bool(data.get("save_profile", True))
|
||||||
|
label = str(data.get("label") or port).strip() or port
|
||||||
|
try:
|
||||||
|
baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
baud = 921600
|
||||||
|
if not port:
|
||||||
|
return json.dumps({"error": "port is required"}), 400, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
settings = get_settings()
|
||||||
|
bridges = normalise_bridges(settings.get("bridges"))
|
||||||
|
profile_id = None
|
||||||
|
if save_profile:
|
||||||
|
profile_id = secrets.token_hex(6)
|
||||||
|
bridges = [
|
||||||
|
b
|
||||||
|
for b in bridges
|
||||||
|
if not (b.get("transport") == "serial" and b.get("serial_port") == port)
|
||||||
|
]
|
||||||
|
bridges.append(
|
||||||
|
{
|
||||||
|
"id": profile_id,
|
||||||
|
"label": label,
|
||||||
|
"transport": "serial",
|
||||||
|
"serial_port": port,
|
||||||
|
"serial_baudrate": baud,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
settings["bridges"] = bridges
|
||||||
|
settings.save()
|
||||||
|
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
|
||||||
|
ok, err = await connect_bridge_serial(profile, settings)
|
||||||
|
if not ok:
|
||||||
|
return json.dumps({"ok": False, "error": err}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = _bridges_payload(settings)
|
||||||
|
payload["profile_id"] = profile_id
|
||||||
|
payload["message"] = f"Connected on {port}"
|
||||||
|
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
|
|||||||
@controller.get("")
|
@controller.get("")
|
||||||
@with_session
|
@with_session
|
||||||
async def list_zones(request, session):
|
async def list_zones(request, session):
|
||||||
|
zones.load()
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
current_zone_id = get_current_zone_id(request, session)
|
current_zone_id = get_current_zone_id(request, session)
|
||||||
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
||||||
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
|
|||||||
|
|
||||||
@controller.get("/<id>")
|
@controller.get("/<id>")
|
||||||
async def get_zone(request, id):
|
async def get_zone(request, id):
|
||||||
|
zones.load()
|
||||||
z = zones.read(id)
|
z = zones.read(id)
|
||||||
if z:
|
if z:
|
||||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||||
@@ -290,6 +292,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 +301,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 +346,13 @@ async def clone_zone(request, session, id):
|
|||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
source_name = source.get("name") or f"Zone {id}"
|
source_name = source.get("name") or f"Zone {id}"
|
||||||
new_name = data.get("name") or f"{source_name} Copy"
|
new_name = data.get("name") or f"{source_name} Copy"
|
||||||
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
|
clone_id = zones.create(
|
||||||
|
new_name,
|
||||||
|
source.get("names"),
|
||||||
|
source.get("presets"),
|
||||||
|
source.get("group_ids"),
|
||||||
|
source.get("content_kind"),
|
||||||
|
)
|
||||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
if extra:
|
if extra:
|
||||||
zones.update(clone_id, extra)
|
zones.update(clone_id, extra)
|
||||||
|
|||||||
585
src/main.py
585
src/main.py
@@ -2,14 +2,12 @@ import asyncio
|
|||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
from settings import Settings
|
from settings import WIFI_CHANNEL_DEFAULT, get_settings
|
||||||
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
@@ -22,230 +20,100 @@ import controllers.pattern as pattern
|
|||||||
import controllers.settings as settings_controller
|
import controllers.settings as settings_controller
|
||||||
import controllers.device as device_controller
|
import controllers.device as device_controller
|
||||||
import controllers.led_tool as led_tool_controller
|
import controllers.led_tool as led_tool_controller
|
||||||
from models.transport import get_sender, set_sender, get_current_sender
|
from models.transport import (
|
||||||
from models.device import Device, normalize_mac
|
get_bridge,
|
||||||
from models import wifi_ws_clients as tcp_client_registry
|
set_bridge,
|
||||||
from util.device_status_broadcaster import (
|
get_current_bridge,
|
||||||
broadcast_device_tcp_snapshot_to,
|
BridgeSerialTransport,
|
||||||
broadcast_device_tcp_status,
|
BridgeWsTransport,
|
||||||
register_device_status_ws,
|
|
||||||
unregister_device_status_ws,
|
|
||||||
)
|
)
|
||||||
|
from models.device import Device
|
||||||
_tcp_device_lock = threading.Lock()
|
from models.bridge_serial_client import init_bridge_serial_client
|
||||||
|
from models.bridge_ws_client import init_bridge_client
|
||||||
DISCOVERY_UDP_PORT = 8766
|
from util.espnow_registry import handle_bridge_uplink
|
||||||
|
from util.bridge_runtime import set_bridge_uplink_handler
|
||||||
|
import controllers.wifi_bridge as wifi_bridge_controller
|
||||||
|
from util.audio_detector import AudioBeatDetector
|
||||||
|
|
||||||
|
|
||||||
def _register_udp_device_sync(
|
def _live_reload_enabled() -> bool:
|
||||||
device_name: str, peer_ip: str, mac, device_type=None
|
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
|
||||||
) -> None:
|
return v not in ("", "0", "false", "no")
|
||||||
with _tcp_device_lock:
|
|
||||||
try:
|
|
||||||
d = Device()
|
|
||||||
did, persisted = d.upsert_wifi_tcp_client(
|
|
||||||
device_name, peer_ip, mac, device_type=device_type
|
|
||||||
)
|
|
||||||
if did and persisted:
|
|
||||||
print(
|
|
||||||
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"UDP device registry failed: {e}")
|
|
||||||
traceback.print_exception(type(e), e, e.__traceback__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_udp_discovery(sock, udp_holder=None) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
data, addr = await asyncio.get_running_loop().sock_recvfrom(sock, 2048)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except OSError as e:
|
|
||||||
if udp_holder and udp_holder.get("closing"):
|
|
||||||
break
|
|
||||||
print(f"[UDP] recv failed: {e!r}")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[UDP] recv failed: {e!r}")
|
|
||||||
continue
|
|
||||||
peer_ip = addr[0] if addr else ""
|
|
||||||
line = data.split(b"\n", 1)[0].strip()
|
|
||||||
if line:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(line.decode("utf-8"))
|
|
||||||
if isinstance(parsed, dict):
|
|
||||||
dns = str(parsed.get("device_name") or "").strip()
|
|
||||||
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get(
|
|
||||||
"sta_mac"
|
|
||||||
)
|
|
||||||
device_type = parsed.get("type") or parsed.get("device_type")
|
|
||||||
if dns and normalize_mac(mac):
|
|
||||||
_register_udp_device_sync(dns, peer_ip, mac, device_type)
|
|
||||||
if str(parsed.get("v") or "") == "1":
|
|
||||||
tcp_client_registry.ensure_driver_connection(peer_ip)
|
|
||||||
except (UnicodeError, ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
await asyncio.get_running_loop().sock_sendto(sock, data, addr)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[UDP] echo send failed: {e!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _prime_wifi_outbound_driver_connections() -> None:
|
|
||||||
"""
|
|
||||||
For each Wi‑Fi device in the registry with a usable IPv4, start (or keep) the
|
|
||||||
outbound WebSocket task. The client loop reconnects automatically if the link
|
|
||||||
drops. Presets are not pushed automatically; use Send Presets / profile apply.
|
|
||||||
"""
|
|
||||||
n = 0
|
|
||||||
try:
|
|
||||||
dev = Device()
|
|
||||||
for mac_key, doc in list(dev.items()):
|
|
||||||
if not isinstance(doc, dict):
|
|
||||||
continue
|
|
||||||
if doc.get("transport") != "wifi":
|
|
||||||
continue
|
|
||||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
|
||||||
if not ip:
|
|
||||||
continue
|
|
||||||
tcp_client_registry.ensure_driver_connection(ip)
|
|
||||||
n += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
|
|
||||||
traceback.print_exception(type(e), e, e.__traceback__)
|
|
||||||
return
|
|
||||||
if n:
|
|
||||||
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
|
|
||||||
|
|
||||||
|
|
||||||
def _ipv4_address(addr: str) -> str | None:
|
|
||||||
"""Return dotted IPv4 string or None (hostnames skipped for UDP nudge)."""
|
|
||||||
s = (addr or "").strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
parts = s.split(".")
|
|
||||||
if len(parts) != 4:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
nums = [int(p) for p in parts]
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if not all(0 <= n <= 255 for n in nums):
|
|
||||||
return None
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
|
||||||
"""
|
|
||||||
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
|
|
||||||
UDP discovery port so the device can announce itself and we can reconnect.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
interval = 10.0
|
|
||||||
if interval <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setblocking(False)
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
if udp_holder.get("closing"):
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
dev = Device()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[hello] device list failed: {e!r}")
|
|
||||||
continue
|
|
||||||
for _mac_key, doc in list(dev.items()):
|
|
||||||
if not isinstance(doc, dict):
|
|
||||||
continue
|
|
||||||
if doc.get("transport") != "wifi":
|
|
||||||
continue
|
|
||||||
ip = _ipv4_address(str(doc.get("address") or ""))
|
|
||||||
if not ip:
|
|
||||||
continue
|
|
||||||
if tcp_client_registry.tcp_client_connected(ip):
|
|
||||||
continue
|
|
||||||
name = (doc.get("name") or "").strip()
|
|
||||||
mac = normalize_mac(doc.get("id") or _mac_key)
|
|
||||||
if not name or not mac:
|
|
||||||
continue
|
|
||||||
line = (
|
|
||||||
json.dumps(
|
|
||||||
{"m": "hello", "device_name": name, "mac": mac},
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await loop.sock_sendto(
|
|
||||||
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
print(f"[hello] UDP to {ip!r} failed: {e!r}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_udp_discovery_server(udp_holder=None) -> None:
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
sock.setblocking(False)
|
|
||||||
try:
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
||||||
except (AttributeError, OSError):
|
|
||||||
pass
|
|
||||||
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
|
|
||||||
if udp_holder is not None:
|
|
||||||
udp_holder["sock"] = sock
|
|
||||||
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
|
|
||||||
try:
|
|
||||||
await _handle_udp_discovery(sock, udp_holder)
|
|
||||||
finally:
|
|
||||||
if udp_holder is not None:
|
|
||||||
udp_holder.pop("sock", None)
|
|
||||||
try:
|
|
||||||
sock.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_bridge_wifi_channel(settings, sender):
|
|
||||||
"""Tell the serial ESP32 bridge to set STA channel (settings wifi_channel); not forwarded as ESP-NOW."""
|
|
||||||
try:
|
|
||||||
ch = int(settings.get("wifi_channel", 6))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
ch = 6
|
|
||||||
ch = max(1, min(11, ch))
|
|
||||||
payload = json.dumps({"m": "bridge", "ch": ch}, separators=(",", ":"))
|
|
||||||
try:
|
|
||||||
await sender.send(payload, addr="ffffffffffff")
|
|
||||||
print(f"[startup] bridge Wi-Fi channel -> {ch}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[startup] bridge channel message failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
settings = Settings()
|
settings = get_settings()
|
||||||
print(settings)
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
# Initialize transport (serial to ESP32 bridge)
|
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||||
sender = get_sender(settings)
|
|
||||||
set_sender(sender)
|
bridge = get_bridge(settings)
|
||||||
|
set_bridge(bridge)
|
||||||
|
|
||||||
|
bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||||
|
if bridge_mode == "wifi":
|
||||||
|
ws_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||||
|
if ws_url:
|
||||||
|
try:
|
||||||
|
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ch = WIFI_CHANNEL_DEFAULT
|
||||||
|
ws_client = init_bridge_client(ws_url, wifi_channel=ch)
|
||||||
|
ws_client.set_uplink_handler(handle_bridge_uplink)
|
||||||
|
ws_client.start()
|
||||||
|
set_bridge(BridgeWsTransport())
|
||||||
|
elif bridge_mode == "serial":
|
||||||
|
serial_port = str(settings.get("bridge_serial_port") or "").strip()
|
||||||
|
if serial_port:
|
||||||
|
baud = 115200
|
||||||
|
for prof in settings.get("bridges") or []:
|
||||||
|
if not isinstance(prof, dict):
|
||||||
|
continue
|
||||||
|
if str(prof.get("transport") or "").strip().lower() != "serial":
|
||||||
|
continue
|
||||||
|
if str(prof.get("serial_port") or "").strip() != serial_port:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
baud = int(prof.get("serial_baudrate") or baud)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
baud = int(settings.get("bridge_serial_baudrate") or baud)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
serial_client = init_bridge_serial_client(serial_port, baudrate=baud)
|
||||||
|
serial_client.set_uplink_handler(handle_bridge_uplink)
|
||||||
|
serial_client.start()
|
||||||
|
set_bridge(BridgeSerialTransport())
|
||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
audio_detector = AudioBeatDetector()
|
||||||
|
try:
|
||||||
|
from util import audio_detector as audio_detector_module
|
||||||
|
|
||||||
|
audio_detector_module.set_shared_beat_detector(audio_detector)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio detector shared registration skipped: {e!r}")
|
||||||
|
try:
|
||||||
|
from util.audio_run_persist import coerce_audio_device, read_audio_run_state
|
||||||
|
|
||||||
|
persisted = read_audio_run_state()
|
||||||
|
if persisted.get("enabled"):
|
||||||
|
sel = persisted.get("device_select") or persisted.get("device")
|
||||||
|
dev = coerce_audio_device(sel)
|
||||||
|
audio_detector.start(device=dev)
|
||||||
|
print("[startup] audio beat detector started from saved run state")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[startup] audio auto-start skipped: {e!r}")
|
||||||
|
from util import beat_driver_route
|
||||||
|
|
||||||
|
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.ensure_beat_consumer_started()
|
||||||
|
|
||||||
# Initialize sessions with a secret key from settings
|
# 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')
|
||||||
@@ -273,23 +141,185 @@ async def main(port=80):
|
|||||||
app.mount(scene.controller, '/scenes')
|
app.mount(scene.controller, '/scenes')
|
||||||
app.mount(pattern.controller, '/patterns')
|
app.mount(pattern.controller, '/patterns')
|
||||||
app.mount(settings_controller.controller, '/settings')
|
app.mount(settings_controller.controller, '/settings')
|
||||||
|
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
|
||||||
app.mount(device_controller.controller, '/devices')
|
app.mount(device_controller.controller, '/devices')
|
||||||
app.mount(led_tool_controller.controller, '/led-tool')
|
app.mount(led_tool_controller.controller, '/led-tool')
|
||||||
|
|
||||||
tcp_client_registry.set_settings(settings)
|
live_reload = _live_reload_enabled()
|
||||||
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
|
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
|
||||||
|
device_select = str(payload.get("device_select") or "").strip()
|
||||||
|
if not device_select and device not in ("", None):
|
||||||
|
device_select = str(device).strip()
|
||||||
|
try:
|
||||||
|
from util.pulse_audio_devices import resolve_capture_device
|
||||||
|
|
||||||
|
device = resolve_capture_device(device)
|
||||||
|
audio_detector.start(device=device)
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(
|
||||||
|
enabled=True,
|
||||||
|
device=device,
|
||||||
|
device_override=str(payload.get("device_override") or ""),
|
||||||
|
device_select=device_select,
|
||||||
|
)
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": str(e)}, 500
|
||||||
|
|
||||||
|
@app.route('/api/audio/device', methods=['PUT'])
|
||||||
|
async def audio_set_device(request):
|
||||||
|
"""Save preferred input device without toggling run state."""
|
||||||
|
payload = request.json if isinstance(request.json, dict) else {}
|
||||||
|
device_select = str(payload.get("device_select") or "").strip()
|
||||||
|
device_override = str(payload.get("device_override") or "").strip()
|
||||||
|
raw = device_override if device_override else device_select
|
||||||
|
device = raw if raw else None
|
||||||
|
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
|
||||||
|
|
||||||
|
prev = read_audio_run_state()
|
||||||
|
write_audio_run_state(
|
||||||
|
enabled=bool(prev.get("enabled")),
|
||||||
|
device=device if raw else None,
|
||||||
|
device_override=device_override,
|
||||||
|
device_select=device_select,
|
||||||
|
)
|
||||||
|
return {"ok": True, "audio_run": read_audio_run_state()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/stop', methods=['POST'])
|
||||||
|
async def audio_stop(request):
|
||||||
|
_ = request
|
||||||
|
audio_detector.stop()
|
||||||
|
from util.audio_run_persist import write_audio_run_state
|
||||||
|
|
||||||
|
write_audio_run_state(enabled=False)
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/reset', methods=['POST'])
|
||||||
|
async def audio_reset(request):
|
||||||
|
"""Clear beat/BPM tracking state without stopping the detector."""
|
||||||
|
_ = request
|
||||||
|
ok = audio_detector.reset_tracking()
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
||||||
|
async def audio_anchor_bar(request):
|
||||||
|
"""Mark the current moment as bar beat 1 (downbeat)."""
|
||||||
|
_ = request
|
||||||
|
ok = audio_detector.anchor_bar_phase()
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||||
|
return {"ok": True, "status": audio_detector.status()}
|
||||||
|
|
||||||
|
@app.route('/api/audio/status')
|
||||||
|
async def audio_status(request):
|
||||||
|
_ = request
|
||||||
|
from util import beat_driver_route
|
||||||
|
from util import sequence_playback
|
||||||
|
|
||||||
|
st = audio_detector.status()
|
||||||
|
st["sequence"] = sequence_playback.playback_status()
|
||||||
|
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
|
||||||
|
seq = st.get("sequence")
|
||||||
|
beat_readout = ""
|
||||||
|
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
|
||||||
|
beat_readout = str(seq.get("beat_readout") or "").strip()
|
||||||
|
elif st.get("running"):
|
||||||
|
mb = st.get("manual_beat_stride")
|
||||||
|
if isinstance(mb, dict) and mb.get("active"):
|
||||||
|
try:
|
||||||
|
n = int(mb.get("stride_n") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = 1
|
||||||
|
n = max(1, min(64, n))
|
||||||
|
try:
|
||||||
|
bi = int(mb.get("beat_in_stride") or 1)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bi = 1
|
||||||
|
pos = min(n, max(1, bi))
|
||||||
|
beat_readout = f"{pos}/{n}"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
bs = int(st.get("beat_seq") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
bs = 0
|
||||||
|
if bs > 0:
|
||||||
|
beat_readout = str(bs)
|
||||||
|
st["beat_readout"] = beat_readout
|
||||||
|
from util.audio_run_persist import read_audio_run_state
|
||||||
|
|
||||||
|
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||||
|
try:
|
||||||
|
st["input_volume"] = int(settings.get("audio_input_volume") or 100)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
st["input_volume"] = 100
|
||||||
|
st["input_volume"] = max(0, min(200, st["input_volume"]))
|
||||||
|
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||||
|
if seq_wait not in ("beat", "downbeat"):
|
||||||
|
seq_wait = "beat"
|
||||||
|
st["sequence_switch_wait"] = seq_wait
|
||||||
|
st["audio_run"] = read_audio_run_state()
|
||||||
|
return {"status": st}
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@app.route("/static/<path:path>")
|
@app.route("/static/<path:path>")
|
||||||
def static_handler(request, path):
|
def static_handler(request, path):
|
||||||
@@ -302,61 +332,56 @@ async def main(port=80):
|
|||||||
@app.route('/ws')
|
@app.route('/ws')
|
||||||
@with_websocket
|
@with_websocket
|
||||||
async def ws(request, ws):
|
async def ws(request, ws):
|
||||||
await register_device_status_ws(ws)
|
|
||||||
await broadcast_device_tcp_snapshot_to(ws)
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await ws.receive()
|
data = await ws.receive()
|
||||||
print(data)
|
if not data:
|
||||||
if data:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(data)
|
|
||||||
print("WS received JSON:", parsed)
|
|
||||||
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
|
||||||
addr = parsed.pop("to", None)
|
|
||||||
payload = json.dumps(parsed) if parsed else data
|
|
||||||
await sender.send(payload, addr=addr)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
# Not JSON: send raw with default address
|
|
||||||
try:
|
|
||||||
await sender.send(data)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
await ws.send(json.dumps({"error": "Send failed"}))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
await ws.send(json.dumps({"error": "Send failed"}))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
break
|
break
|
||||||
finally:
|
try:
|
||||||
await unregister_device_status_ws(ws)
|
if isinstance(data, (bytes, bytearray)):
|
||||||
|
await bridge.send(bytes(data))
|
||||||
|
continue
|
||||||
|
parsed = json.loads(data)
|
||||||
|
addr = parsed.pop("to", None)
|
||||||
|
await bridge.send(parsed, addr=addr)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"error": "Send failed"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Touch Device singleton early so db/device.json exists before first UDP hello.
|
|
||||||
Device()
|
Device()
|
||||||
await _send_bridge_wifi_channel(settings, sender)
|
|
||||||
_prime_wifi_outbound_driver_connections()
|
|
||||||
|
|
||||||
udp_holder = {"closing": False}
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
server_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
def _graceful_shutdown(*_args):
|
def _graceful_shutdown(*_args):
|
||||||
print("[server] shutting down...")
|
print("[server] shutting down...")
|
||||||
udp_holder["closing"] = True
|
|
||||||
u = udp_holder.get("sock")
|
|
||||||
if u is not None:
|
|
||||||
try:
|
try:
|
||||||
u.close()
|
audio_detector.stop()
|
||||||
except OSError:
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from util import sequence_playback as seq_pb
|
||||||
|
|
||||||
|
seq_pb.stop()
|
||||||
|
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||||
|
t = getattr(seq_pb, attr, None)
|
||||||
|
if t is not None and not t.done():
|
||||||
|
t.cancel()
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
tcp_client_registry.cancel_all_driver_tasks()
|
|
||||||
if getattr(app, "server", None) is not None:
|
if getattr(app, "server", None) is not None:
|
||||||
|
try:
|
||||||
app.shutdown()
|
app.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for t in server_tasks:
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
|
||||||
shutdown_handlers_registered = False
|
shutdown_handlers_registered = False
|
||||||
try:
|
try:
|
||||||
@@ -367,13 +392,15 @@ async def main(port=80):
|
|||||||
except (NotImplementedError, RuntimeError):
|
except (NotImplementedError, RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
|
||||||
try:
|
try:
|
||||||
await asyncio.gather(
|
server_tasks[:] = [
|
||||||
app.start_server(host="0.0.0.0", port=port),
|
asyncio.create_task(
|
||||||
_run_udp_discovery_server(udp_holder),
|
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
),
|
||||||
)
|
]
|
||||||
|
await asyncio.gather(*server_tasks)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == errno.EADDRINUSE:
|
if e.errno == errno.EADDRINUSE:
|
||||||
print(
|
print(
|
||||||
@@ -383,6 +410,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:
|
||||||
@@ -394,6 +425,20 @@ async def main(port=80):
|
|||||||
app.server = None
|
app.server = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
for t in list(server_tasks):
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
if server_tasks:
|
||||||
|
await asyncio.gather(*server_tasks, return_exceptions=True)
|
||||||
|
pending = [
|
||||||
|
t
|
||||||
|
for t in asyncio.all_tasks(loop)
|
||||||
|
if t is not asyncio.current_task() and not t.done()
|
||||||
|
]
|
||||||
|
for t in pending:
|
||||||
|
t.cancel()
|
||||||
|
if pending:
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
if shutdown_handlers_registered:
|
if shutdown_handlers_registered:
|
||||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||||
try:
|
try:
|
||||||
@@ -403,5 +448,9 @@ async def main(port=80):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import os
|
import os
|
||||||
|
|
||||||
port = int(os.environ.get("PORT", 80))
|
port = int(os.environ.get("PORT", 80))
|
||||||
|
try:
|
||||||
asyncio.run(main(port=port))
|
asyncio.run(main(port=port))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("[server] interrupted")
|
||||||
|
|||||||
199
src/models/bridge_serial_client.py
Normal file
199
src/models/bridge_serial_client.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""Persistent USB/serial client to the ESP-NOW bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Awaitable, Callable, Optional, Union
|
||||||
|
|
||||||
|
import serial
|
||||||
|
import serial_asyncio
|
||||||
|
|
||||||
|
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame
|
||||||
|
from util.espnow_wire import parse_ws_frame
|
||||||
|
|
||||||
|
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeSerialClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
port: str,
|
||||||
|
*,
|
||||||
|
baudrate: int = 921600,
|
||||||
|
reconnect_delay_s: float = 2.0,
|
||||||
|
):
|
||||||
|
self._port = str(port or "").strip()
|
||||||
|
self._baudrate = int(baudrate)
|
||||||
|
self._reconnect_delay_s = reconnect_delay_s
|
||||||
|
self._reader: Optional[asyncio.StreamReader] = None
|
||||||
|
self._writer: Optional[asyncio.StreamWriter] = None
|
||||||
|
self._send_lock = asyncio.Lock()
|
||||||
|
self._uplink_handler: Optional[UplinkHandler] = None
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._read_task: Optional[asyncio.Task] = None
|
||||||
|
self._connected = asyncio.Event()
|
||||||
|
self._disconnect_event = asyncio.Event()
|
||||||
|
self._stop = False
|
||||||
|
self._read_buf = bytearray()
|
||||||
|
self._bad_frame_count = 0
|
||||||
|
|
||||||
|
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
||||||
|
self._uplink_handler = handler
|
||||||
|
|
||||||
|
def _signal_disconnect(self) -> None:
|
||||||
|
self._connected.clear()
|
||||||
|
self._disconnect_event.set()
|
||||||
|
|
||||||
|
async def _close_serial(self) -> None:
|
||||||
|
reader = self._reader
|
||||||
|
writer = self._writer
|
||||||
|
self._reader = None
|
||||||
|
self._writer = None
|
||||||
|
if writer is not None:
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _read_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
while not self._disconnect_event.is_set() and not self._stop:
|
||||||
|
reader = self._reader
|
||||||
|
if reader is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = await reader.read(4096)
|
||||||
|
except (serial.SerialException, OSError, asyncio.IncompleteReadError) as e:
|
||||||
|
print(f"[bridge-serial] read error: {e!r}")
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
continue
|
||||||
|
frames = feed_serial_buffer(self._read_buf, chunk)
|
||||||
|
handler = self._uplink_handler
|
||||||
|
if handler is None:
|
||||||
|
continue
|
||||||
|
for frame in frames:
|
||||||
|
try:
|
||||||
|
peer, pkt, _bcast = parse_ws_frame(frame)
|
||||||
|
except ValueError:
|
||||||
|
self._bad_frame_count += 1
|
||||||
|
if self._bad_frame_count <= 3:
|
||||||
|
print(
|
||||||
|
f"[bridge-serial] ignored frame ({len(frame)} B), "
|
||||||
|
f"expected ws uplink header"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
self._bad_frame_count = 0
|
||||||
|
await handler(peer, pkt)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self._signal_disconnect()
|
||||||
|
|
||||||
|
async def run_forever(self) -> None:
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
await self._connect_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[bridge-serial] connection error: {e!r}")
|
||||||
|
self._signal_disconnect()
|
||||||
|
self._disconnect_event.clear()
|
||||||
|
await self._close_serial()
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
print("[bridge-serial] disconnected, reconnecting...")
|
||||||
|
await asyncio.sleep(self._reconnect_delay_s)
|
||||||
|
|
||||||
|
async def _connect_once(self) -> None:
|
||||||
|
if not self._port:
|
||||||
|
raise serial.SerialException("serial port not configured")
|
||||||
|
print(f"[bridge-serial] opening {self._port!r} @ {self._baudrate}")
|
||||||
|
self._read_buf.clear()
|
||||||
|
self._disconnect_event.clear()
|
||||||
|
reader, writer = await serial_asyncio.open_serial_connection(
|
||||||
|
url=self._port,
|
||||||
|
baudrate=self._baudrate,
|
||||||
|
exclusive=True,
|
||||||
|
)
|
||||||
|
self._reader = reader
|
||||||
|
self._writer = writer
|
||||||
|
self._connected.set()
|
||||||
|
self._read_task = asyncio.create_task(self._read_loop())
|
||||||
|
print("[bridge-serial] connected")
|
||||||
|
try:
|
||||||
|
await self._disconnect_event.wait()
|
||||||
|
finally:
|
||||||
|
read_task = self._read_task
|
||||||
|
self._read_task = None
|
||||||
|
if read_task is not None:
|
||||||
|
read_task.cancel()
|
||||||
|
try:
|
||||||
|
await read_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await self._close_serial()
|
||||||
|
|
||||||
|
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
||||||
|
writer = self._writer
|
||||||
|
return writer is not None and not writer.is_closing()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
|
||||||
|
if isinstance(packet, dict):
|
||||||
|
packet = json.dumps(packet, separators=(",", ":"))
|
||||||
|
if isinstance(packet, str):
|
||||||
|
packet = packet.encode("utf-8")
|
||||||
|
if not await self.wait_connected(timeout=30.0):
|
||||||
|
return False
|
||||||
|
writer = self._writer
|
||||||
|
if writer is None or writer.is_closing():
|
||||||
|
return False
|
||||||
|
frame = pack_serial_frame(bytes(packet))
|
||||||
|
async with self._send_lock:
|
||||||
|
try:
|
||||||
|
writer = self._writer
|
||||||
|
if writer is None or writer.is_closing():
|
||||||
|
return False
|
||||||
|
writer.write(frame)
|
||||||
|
await writer.drain()
|
||||||
|
return True
|
||||||
|
except (serial.SerialException, OSError, ConnectionError) as e:
|
||||||
|
print(f"[bridge-serial] send failed: {e!r}")
|
||||||
|
self._signal_disconnect()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def start(self) -> asyncio.Task:
|
||||||
|
self._stop = False
|
||||||
|
if self._task is None or self._task.done():
|
||||||
|
self._task = asyncio.create_task(self.run_forever())
|
||||||
|
return self._task
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop = True
|
||||||
|
self._signal_disconnect()
|
||||||
|
task = self._task
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
_client: Optional[BridgeSerialClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_bridge_serial_client() -> Optional[BridgeSerialClient]:
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def init_bridge_serial_client(port: str, *, baudrate: int = 921600) -> BridgeSerialClient:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
_client.stop()
|
||||||
|
_client = BridgeSerialClient(port, baudrate=baudrate)
|
||||||
|
return _client
|
||||||
170
src/models/bridge_ws_client.py
Normal file
170
src/models/bridge_ws_client.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""Persistent WebSocket client to the ESP-NOW bridge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Awaitable, Callable, Optional, Union
|
||||||
|
|
||||||
|
import websockets
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
from settings import WIFI_CHANNEL_DEFAULT
|
||||||
|
from util.espnow_wire import parse_ws_frame
|
||||||
|
|
||||||
|
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeWsClient:
|
||||||
|
def __init__(
|
||||||
|
self, url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT, reconnect_delay_s: float = 2.0
|
||||||
|
):
|
||||||
|
self._url = url.strip()
|
||||||
|
self._wifi_channel = wifi_channel
|
||||||
|
self._reconnect_delay_s = reconnect_delay_s
|
||||||
|
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||||
|
self._send_lock = asyncio.Lock()
|
||||||
|
self._uplink_handler: Optional[UplinkHandler] = None
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._connected = asyncio.Event()
|
||||||
|
self._disconnect_event = asyncio.Event()
|
||||||
|
self._stop = False
|
||||||
|
|
||||||
|
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
||||||
|
self._uplink_handler = handler
|
||||||
|
|
||||||
|
def _signal_disconnect(self) -> None:
|
||||||
|
self._connected.clear()
|
||||||
|
self._disconnect_event.set()
|
||||||
|
|
||||||
|
async def _close_ws(self) -> None:
|
||||||
|
ws = self._ws
|
||||||
|
self._ws = None
|
||||||
|
if ws is not None:
|
||||||
|
try:
|
||||||
|
await ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run_forever(self) -> None:
|
||||||
|
while not self._stop:
|
||||||
|
try:
|
||||||
|
await self._connect_once()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[bridge] connection error: {e!r}")
|
||||||
|
self._signal_disconnect()
|
||||||
|
self._disconnect_event.clear()
|
||||||
|
await self._close_ws()
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
print("[bridge] disconnected, reconnecting...")
|
||||||
|
await asyncio.sleep(self._reconnect_delay_s)
|
||||||
|
|
||||||
|
async def _reader_loop(self) -> None:
|
||||||
|
ws = self._ws
|
||||||
|
if ws is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async for message in ws:
|
||||||
|
if self._uplink_handler is None:
|
||||||
|
continue
|
||||||
|
if isinstance(message, str):
|
||||||
|
message = message.encode("utf-8")
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
peer, pkt, _bcast = parse_ws_frame(message)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
await self._uplink_handler(peer, pkt)
|
||||||
|
except ConnectionClosed:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._signal_disconnect()
|
||||||
|
|
||||||
|
async def _connect_once(self) -> None:
|
||||||
|
print(f"[bridge] connecting to {self._url} (channel {self._wifi_channel} on bridge)")
|
||||||
|
async with websockets.connect(self._url, ping_interval=20, ping_timeout=20) as ws:
|
||||||
|
self._ws = ws
|
||||||
|
self._connected.set()
|
||||||
|
self._disconnect_event.clear()
|
||||||
|
print("[bridge] connected")
|
||||||
|
reader = asyncio.create_task(self._reader_loop())
|
||||||
|
try:
|
||||||
|
while not self._disconnect_event.is_set():
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
finally:
|
||||||
|
reader.cancel()
|
||||||
|
try:
|
||||||
|
await reader
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
||||||
|
return True
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
|
||||||
|
if isinstance(packet, dict):
|
||||||
|
packet = json.dumps(packet, separators=(",", ":"))
|
||||||
|
if isinstance(packet, str):
|
||||||
|
packet = packet.encode("utf-8")
|
||||||
|
if not await self.wait_connected(timeout=30.0):
|
||||||
|
return False
|
||||||
|
ws = self._ws
|
||||||
|
if ws is None:
|
||||||
|
return False
|
||||||
|
async with self._send_lock:
|
||||||
|
try:
|
||||||
|
await ws.send(packet)
|
||||||
|
return True
|
||||||
|
except (ConnectionClosed, OSError) as e:
|
||||||
|
print(f"[bridge] send failed: {e!r}")
|
||||||
|
self._signal_disconnect()
|
||||||
|
await self._close_ws()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def send_espnow(
|
||||||
|
self,
|
||||||
|
packet: bytes,
|
||||||
|
*,
|
||||||
|
peer_mac: Optional[bytes] = None,
|
||||||
|
broadcast: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
del peer_mac, broadcast
|
||||||
|
return await self.send_packet(packet)
|
||||||
|
|
||||||
|
def start(self) -> asyncio.Task:
|
||||||
|
self._stop = False
|
||||||
|
if self._task is None or self._task.done():
|
||||||
|
self._task = asyncio.create_task(self.run_forever())
|
||||||
|
return self._task
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop = True
|
||||||
|
self._signal_disconnect()
|
||||||
|
task = self._task
|
||||||
|
if task is not None and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
_client: Optional[BridgeWsClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_bridge_client() -> Optional[BridgeWsClient]:
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def init_bridge_client(url: str, *, wifi_channel: int = WIFI_CHANNEL_DEFAULT) -> BridgeWsClient:
|
||||||
|
global _client
|
||||||
|
if _client is not None:
|
||||||
|
_client.stop()
|
||||||
|
_client = BridgeWsClient(url, wifi_channel=wifi_channel)
|
||||||
|
return _client
|
||||||
@@ -233,6 +233,68 @@ class Device(Model):
|
|||||||
def list(self):
|
def list(self):
|
||||||
return list(self.keys())
|
return list(self.keys())
|
||||||
|
|
||||||
|
def upsert_espnow_announced(
|
||||||
|
self,
|
||||||
|
mac,
|
||||||
|
device_name,
|
||||||
|
*,
|
||||||
|
device_type="led",
|
||||||
|
num_leds=None,
|
||||||
|
color_order=None,
|
||||||
|
startup_mode=None,
|
||||||
|
brightness=None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Register or update an ESP-NOW device from a binary ANNOUNCE.
|
||||||
|
|
||||||
|
Returns ``(mac_hex | None, persisted)``.
|
||||||
|
"""
|
||||||
|
mac_hex = normalize_mac(mac)
|
||||||
|
if not mac_hex:
|
||||||
|
return None, False
|
||||||
|
name = (device_name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None, False
|
||||||
|
resolved_type = validate_device_type(device_type)
|
||||||
|
meta = {}
|
||||||
|
if num_leds is not None:
|
||||||
|
meta["num_leds"] = int(num_leds)
|
||||||
|
if color_order is not None:
|
||||||
|
meta["color_order"] = str(color_order)
|
||||||
|
if startup_mode is not None:
|
||||||
|
meta["startup_mode"] = str(startup_mode)
|
||||||
|
if brightness is not None:
|
||||||
|
meta["brightness"] = int(brightness)
|
||||||
|
|
||||||
|
if mac_hex in self:
|
||||||
|
prev = self[mac_hex]
|
||||||
|
merged = dict(prev)
|
||||||
|
merged["name"] = name
|
||||||
|
merged["type"] = resolved_type
|
||||||
|
merged["transport"] = "espnow"
|
||||||
|
merged["address"] = mac_hex
|
||||||
|
merged["id"] = mac_hex
|
||||||
|
merged.update({k: v for k, v in meta.items() if v is not None})
|
||||||
|
if merged == prev:
|
||||||
|
return mac_hex, False
|
||||||
|
self[mac_hex] = merged
|
||||||
|
self.save()
|
||||||
|
return mac_hex, True
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"id": mac_hex,
|
||||||
|
"name": name,
|
||||||
|
"type": resolved_type,
|
||||||
|
"transport": "espnow",
|
||||||
|
"address": mac_hex,
|
||||||
|
"default_pattern": None,
|
||||||
|
"zones": [],
|
||||||
|
}
|
||||||
|
row.update({k: v for k, v in meta.items() if v is not None})
|
||||||
|
self[mac_hex] = row
|
||||||
|
self.save()
|
||||||
|
return mac_hex, True
|
||||||
|
|
||||||
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
def upsert_wifi_tcp_client(self, device_name, peer_ip, mac, device_type=None):
|
||||||
"""
|
"""
|
||||||
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
Register or update a Wi-Fi client by **MAC** (storage id). Updates **name**,
|
||||||
|
|||||||
@@ -1,14 +1,75 @@
|
|||||||
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
|
||||||
|
if "bridge_id" not in doc:
|
||||||
|
doc["bridge_id"] = None
|
||||||
|
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,
|
||||||
|
"bridge_id": None,
|
||||||
"pattern": "on",
|
"pattern": "on",
|
||||||
"colors": ["000000", "FF0000"],
|
"colors": ["000000", "FF0000"],
|
||||||
"brightness": 100,
|
"brightness": 100,
|
||||||
@@ -22,7 +83,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())
|
|
||||||
@@ -1,90 +1,171 @@
|
|||||||
import asyncio
|
"""Transport to LED drivers via ESP-NOW bridge WebSocket or USB serial."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from models.bridge_serial_client import get_bridge_serial_client
|
||||||
|
from models.bridge_ws_client import get_bridge_client
|
||||||
|
from util.bridge_envelope import (
|
||||||
|
BROADCAST_HEX,
|
||||||
|
BROADCAST_MAC,
|
||||||
|
build_devices_envelope,
|
||||||
|
format_mac_key,
|
||||||
|
is_broadcast_mac,
|
||||||
|
normalize_mac_key,
|
||||||
|
)
|
||||||
|
from util.espnow_wire import WIRE_MAGIC
|
||||||
|
|
||||||
|
|
||||||
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
|
class NullBridge:
|
||||||
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
"""No bridge configured."""
|
||||||
|
|
||||||
|
async def send(self, data, addr=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _encode_payload(data):
|
class BridgeWsTransport:
|
||||||
if isinstance(data, str):
|
"""Send v1 JSON or devices envelope via bridge WebSocket."""
|
||||||
return data.encode()
|
|
||||||
|
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
|
||||||
|
client = get_bridge_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
return json.dumps(data).encode()
|
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||||
return data
|
from util.v1_wire import compact_envelope
|
||||||
|
|
||||||
|
return await client.send_packet(compact_envelope(data))
|
||||||
|
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
|
elif isinstance(data, str):
|
||||||
|
packet = data.encode("utf-8")
|
||||||
|
elif isinstance(data, (bytes, bytearray)):
|
||||||
|
packet = bytes(data)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def _parse_mac(addr):
|
if not packet:
|
||||||
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
|
return False
|
||||||
if addr is None or addr == b"":
|
|
||||||
return BROADCAST_MAC
|
|
||||||
if isinstance(addr, bytes) and len(addr) == 6:
|
|
||||||
return addr
|
|
||||||
if isinstance(addr, str) and len(addr) == 12:
|
|
||||||
return bytes.fromhex(addr)
|
|
||||||
return BROADCAST_MAC
|
|
||||||
|
|
||||||
|
if packet[0] == WIRE_MAGIC:
|
||||||
|
return await client.send_packet(packet)
|
||||||
|
|
||||||
async def _to_thread(func, *args):
|
if packet[0:1] != b"{":
|
||||||
to_thread = getattr(asyncio, "to_thread", None)
|
return False
|
||||||
if to_thread:
|
|
||||||
return await to_thread(func, *args)
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(None, func, *args)
|
|
||||||
|
|
||||||
|
mac_key = _addr_to_envelope_key(addr)
|
||||||
|
if mac_key is None:
|
||||||
|
return await client.send_packet(packet)
|
||||||
|
|
||||||
class NullSender:
|
|
||||||
"""Used when no ESP-NOW UART bridge is configured or the port cannot be opened."""
|
|
||||||
|
|
||||||
async def send(self, data, addr=None):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class SerialSender:
|
|
||||||
def __init__(self, port, baudrate, default_addr=None):
|
|
||||||
import serial
|
|
||||||
|
|
||||||
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
|
||||||
self._default_addr = _parse_mac(default_addr)
|
|
||||||
self._write_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def send(self, data, addr=None):
|
|
||||||
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
|
||||||
payload = _encode_payload(data)
|
|
||||||
async with self._write_lock:
|
|
||||||
await _to_thread(self._serial.write, mac + payload)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
_current_sender = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_sender(sender):
|
|
||||||
global _current_sender
|
|
||||||
_current_sender = sender
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_sender():
|
|
||||||
return _current_sender
|
|
||||||
|
|
||||||
|
|
||||||
def get_sender(settings):
|
|
||||||
# Serial ESP-NOW bridge is opt-in (serial_enabled true); default off for dev / Wi-Fi-only.
|
|
||||||
if not settings.get("serial_enabled"):
|
|
||||||
print("[startup] serial bridge disabled (set serial_enabled true in settings.json to enable)")
|
|
||||||
return NullSender()
|
|
||||||
port = settings.get("serial_port", "/dev/ttyS0")
|
|
||||||
raw_port = str(port).strip() if port is not None else ""
|
|
||||||
if not raw_port or raw_port.lower() in ("none", "off"):
|
|
||||||
print("[startup] serial bridge disabled (empty serial_port)")
|
|
||||||
return NullSender()
|
|
||||||
baudrate = settings.get("serial_baudrate", 912000)
|
|
||||||
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
|
|
||||||
try:
|
try:
|
||||||
return SerialSender(raw_port, baudrate, default_addr=default_addr)
|
body = json.loads(packet.decode("utf-8"))
|
||||||
except Exception as e:
|
except (UnicodeError, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if not isinstance(body, dict) or body.get("v") != "1":
|
||||||
|
return False
|
||||||
|
|
||||||
|
envelope = build_devices_envelope({mac_key: body})
|
||||||
|
return await client.send_packet(envelope)
|
||||||
|
|
||||||
|
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
|
||||||
|
client = get_bridge_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
return await client.send_packet(envelope)
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeSerialTransport:
|
||||||
|
"""Send v1 JSON or devices envelope via bridge USB/serial."""
|
||||||
|
|
||||||
|
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
|
||||||
|
client = get_bridge_serial_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||||
|
from util.v1_wire import compact_envelope
|
||||||
|
|
||||||
|
return await client.send_packet(compact_envelope(data))
|
||||||
|
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||||
|
elif isinstance(data, str):
|
||||||
|
packet = data.encode("utf-8")
|
||||||
|
elif isinstance(data, (bytes, bytearray)):
|
||||||
|
packet = bytes(data)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not packet:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if packet[0] == WIRE_MAGIC:
|
||||||
|
return await client.send_packet(packet)
|
||||||
|
|
||||||
|
if packet[0:1] != b"{":
|
||||||
|
return False
|
||||||
|
|
||||||
|
mac_key = _addr_to_envelope_key(addr)
|
||||||
|
if mac_key is None:
|
||||||
|
return await client.send_packet(packet)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(packet.decode("utf-8"))
|
||||||
|
except (UnicodeError, ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if not isinstance(body, dict) or body.get("v") != "1":
|
||||||
|
return False
|
||||||
|
|
||||||
|
envelope = build_devices_envelope({mac_key: body})
|
||||||
|
return await client.send_packet(envelope)
|
||||||
|
|
||||||
|
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
|
||||||
|
client = get_bridge_serial_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
return await client.send_packet(envelope)
|
||||||
|
|
||||||
|
|
||||||
|
def _addr_to_envelope_key(addr) -> Optional[str]:
|
||||||
|
if addr is None:
|
||||||
|
return BROADCAST_MAC
|
||||||
|
s = str(addr).strip().lower()
|
||||||
|
if is_broadcast_mac(s):
|
||||||
|
return BROADCAST_MAC
|
||||||
|
h = normalize_mac_key(s)
|
||||||
|
if h:
|
||||||
|
try:
|
||||||
|
return format_mac_key(h)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_current_bridge = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_bridge(bridge):
|
||||||
|
global _current_bridge
|
||||||
|
_current_bridge = bridge
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_bridge():
|
||||||
|
return _current_bridge
|
||||||
|
|
||||||
|
|
||||||
|
def get_bridge(settings):
|
||||||
|
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||||
|
if mode == "wifi":
|
||||||
|
url = str(settings.get("bridge_ws_url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
print("[startup] bridge Wi‑Fi disabled (set bridge_ws_url in settings.json)")
|
||||||
|
return NullBridge()
|
||||||
|
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
|
||||||
|
return BridgeWsTransport()
|
||||||
|
port = str(settings.get("bridge_serial_port") or "").strip()
|
||||||
|
if not port:
|
||||||
print(
|
print(
|
||||||
f"[startup] serial open failed ({raw_port!r}): {e}; "
|
"[startup] bridge serial disabled (set bridge_serial_port in settings.json)"
|
||||||
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
|
|
||||||
)
|
)
|
||||||
return NullSender()
|
return NullBridge()
|
||||||
|
print(f"[startup] ESP-NOW via bridge USB serial {port!r}")
|
||||||
|
return BridgeSerialTransport()
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
_connections: dict[str, object] = {}
|
_connections: dict[str, object] = {}
|
||||||
_send_locks: dict[str, asyncio.Lock] = {}
|
_send_locks: dict[str, asyncio.Lock] = {}
|
||||||
_tasks: dict[str, asyncio.Task] = {}
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
_unreachable_counts: dict[str, int] = {}
|
|
||||||
_settings = None
|
_settings = None
|
||||||
|
|
||||||
_tcp_status_broadcast = None
|
_tcp_status_broadcast = None
|
||||||
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
|
|||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
_connections[key] = ws
|
_connections[key] = ws
|
||||||
_unreachable_counts.pop(key, None)
|
|
||||||
if key not in _send_locks:
|
if key not in _send_locks:
|
||||||
_send_locks[key] = asyncio.Lock()
|
_send_locks[key] = asyncio.Lock()
|
||||||
_schedule_status_broadcast(key, True)
|
_schedule_status_broadcast(key, True)
|
||||||
@@ -185,9 +183,9 @@ async def send_json_line_to_ip(ip: str, json_str: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def _recv_forward_loop(ip: str, ws) -> None:
|
async def _recv_forward_loop(ip: str, ws) -> None:
|
||||||
from models.transport import get_current_sender
|
from models.transport import get_current_bridge
|
||||||
|
|
||||||
sender = get_current_sender()
|
bridge = get_current_bridge()
|
||||||
async for message in ws:
|
async for message in ws:
|
||||||
if isinstance(message, bytes):
|
if isinstance(message, bytes):
|
||||||
try:
|
try:
|
||||||
@@ -201,13 +199,13 @@ async def _recv_forward_loop(ip: str, ws) -> None:
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
print(f"[WS] recv {ip}: {text}")
|
print(f"[WS] recv {ip}: {text}")
|
||||||
if not sender:
|
if not bridge:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(text)
|
parsed = json.loads(text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
try:
|
try:
|
||||||
await sender.send(text)
|
await bridge.send(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
continue
|
continue
|
||||||
@@ -215,12 +213,12 @@ async def _recv_forward_loop(ip: str, ws) -> None:
|
|||||||
addr = parsed.pop("to", None)
|
addr = parsed.pop("to", None)
|
||||||
payload = json.dumps(parsed) if parsed else "{}"
|
payload = json.dumps(parsed) if parsed else "{}"
|
||||||
try:
|
try:
|
||||||
await sender.send(payload, addr=addr)
|
await bridge.send(payload, addr=addr)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[WS] forward to bridge failed: {e}")
|
print(f"[WS] forward to bridge failed: {e}")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
await sender.send(text)
|
await bridge.send(text)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -261,66 +259,57 @@ 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).
|
|
||||||
connected_once = False
|
|
||||||
deadline = loop.time() + retry_window_s
|
|
||||||
try:
|
try:
|
||||||
while True:
|
for attempt in range(1, max_boot_attempts + 1):
|
||||||
now = loop.time()
|
|
||||||
if not connected_once and now >= deadline:
|
|
||||||
print(
|
|
||||||
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s "
|
|
||||||
f"(initial window); stopping until next UDP hello / registry prime"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
try:
|
try:
|
||||||
print(f"[WS] connecting to {uri!r}")
|
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
uri,
|
uri,
|
||||||
ping_interval=20,
|
ping_interval=20,
|
||||||
ping_timeout=15,
|
ping_timeout=15,
|
||||||
open_timeout=open_timeout,
|
open_timeout=open_timeout,
|
||||||
) as ws:
|
) as ws:
|
||||||
connected_once = True
|
|
||||||
_register_ws(ip, ws)
|
_register_ws(ip, ws)
|
||||||
try:
|
try:
|
||||||
await _recv_forward_loop(ip, ws)
|
await _recv_forward_loop(ip, ws)
|
||||||
finally:
|
finally:
|
||||||
unregister_tcp_writer(ip, ws)
|
unregister_tcp_writer(ip, ws)
|
||||||
|
return
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except ConnectionClosed as e:
|
except ConnectionClosed as e:
|
||||||
print(f"[WS] driver {ip} closed: {e}")
|
print(f"[WS] driver {ip} closed: {e}")
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _benign_ws_connect_failure(e):
|
if _benign_ws_connect_failure(e):
|
||||||
n = _unreachable_counts.get(ip, 0) + 1
|
|
||||||
_unreachable_counts[ip] = n
|
|
||||||
if n == 1 or (n % 30) == 0:
|
|
||||||
print(
|
print(
|
||||||
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
|
f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(f"[WS] driver {ip} session error: {e!r}")
|
print(f"[WS] driver {ip} session error: {e!r}")
|
||||||
traceback.print_exception(type(e), e, e.__traceback__)
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
_unreachable_counts.pop(ip, None)
|
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
|
if attempt < max_boot_attempts:
|
||||||
await asyncio.sleep(retry_interval_s)
|
await asyncio.sleep(retry_interval_s)
|
||||||
|
print(
|
||||||
|
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
|
||||||
|
"waiting for next UDP hello"
|
||||||
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
unregister_tcp_writer(ip, None)
|
unregister_tcp_writer(ip, None)
|
||||||
raise
|
raise
|
||||||
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_driver_connection(peer_ip: str) -> None:
|
def ensure_driver_connection(peer_ip: str) -> None:
|
||||||
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
|
"""Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
|
||||||
key = normalize_tcp_peer_ip(peer_ip)
|
key = normalize_tcp_peer_ip(peer_ip)
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
|
if tcp_client_connected(key):
|
||||||
|
return
|
||||||
t = _tasks.get(key)
|
t = _tasks.get(key)
|
||||||
if t is not None and not t.done():
|
if t is not None and not t.done():
|
||||||
return
|
return
|
||||||
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
|
|||||||
_schedule_status_broadcast(ip, False)
|
_schedule_status_broadcast(ip, False)
|
||||||
_connections.clear()
|
_connections.clear()
|
||||||
_send_locks.clear()
|
_send_locks.clear()
|
||||||
_unreachable_counts.clear()
|
|
||||||
|
|||||||
@@ -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). Legacy rows without ``content_kind`` are inferred on load.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not getattr(Zone, "_migration_checked", False):
|
if not getattr(Zone, "_migration_checked", False):
|
||||||
@@ -27,15 +31,98 @@ 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 "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
changed = True
|
||||||
|
if not self._normalized_content_kind(doc):
|
||||||
|
doc["content_kind"] = self._infer_content_kind(doc)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalized_content_kind(doc):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return None
|
||||||
|
kind = doc.get("content_kind")
|
||||||
|
return kind if kind in ("presets", "sequences") else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _preset_ids_in_doc(doc):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return []
|
||||||
|
flat = doc.get("presets_flat")
|
||||||
|
if isinstance(flat, list):
|
||||||
|
return [str(x) for x in flat if x is not None and str(x).strip()]
|
||||||
|
presets = doc.get("presets")
|
||||||
|
if not isinstance(presets, list) or not presets:
|
||||||
|
return []
|
||||||
|
if isinstance(presets[0], str):
|
||||||
|
return [str(x) for x in presets if x is not None and str(x).strip()]
|
||||||
|
if isinstance(presets[0], list):
|
||||||
|
out = []
|
||||||
|
for row in presets:
|
||||||
|
if isinstance(row, list):
|
||||||
|
out.extend(str(x) for x in row if x is not None and str(x).strip())
|
||||||
|
return out
|
||||||
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _infer_content_kind(cls, doc):
|
||||||
|
kind = cls._normalized_content_kind(doc)
|
||||||
|
if kind:
|
||||||
|
return kind
|
||||||
|
seq_ids = [
|
||||||
|
str(x).strip()
|
||||||
|
for x in (doc.get("sequence_ids") or [])
|
||||||
|
if x is not None and str(x).strip()
|
||||||
|
]
|
||||||
|
preset_ids = cls._preset_ids_in_doc(doc)
|
||||||
|
if seq_ids and not preset_ids:
|
||||||
|
return "sequences"
|
||||||
|
return "presets"
|
||||||
|
|
||||||
|
def _enforce_content_kind_invariants(self, doc):
|
||||||
|
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
||||||
|
kind = self._normalized_content_kind(doc)
|
||||||
|
if kind == "presets":
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
elif kind == "sequences":
|
||||||
|
doc["presets"] = []
|
||||||
|
doc["presets_flat"] = []
|
||||||
|
|
||||||
|
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
|
||||||
next_id = self.get_next_id()
|
next_id = self.get_next_id()
|
||||||
self[next_id] = {
|
gid_list = []
|
||||||
|
if isinstance(group_ids, list):
|
||||||
|
gid_list = [str(x).strip() for x in group_ids if x is not None and str(x).strip()]
|
||||||
|
doc = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"names": names if names else [],
|
"names": names if names else [],
|
||||||
|
"group_ids": gid_list,
|
||||||
|
"preset_group_ids": {},
|
||||||
"presets": presets if presets else [],
|
"presets": presets if presets else [],
|
||||||
"default_preset": None,
|
"default_preset": None,
|
||||||
"brightness": 255,
|
"brightness": 255,
|
||||||
}
|
}
|
||||||
|
if content_kind in ("presets", "sequences"):
|
||||||
|
doc["content_kind"] = content_kind
|
||||||
|
if "sequence_ids" not in doc:
|
||||||
|
doc["sequence_ids"] = []
|
||||||
|
self._enforce_content_kind_invariants(doc)
|
||||||
|
self[next_id] = doc
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|
||||||
@@ -47,7 +134,14 @@ class Zone(Model):
|
|||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
if id_str not in self:
|
if id_str not in self:
|
||||||
return False
|
return False
|
||||||
self[id_str].update(data)
|
patch = dict(data) if isinstance(data, dict) else {}
|
||||||
|
doc = self[id_str]
|
||||||
|
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
|
||||||
|
if "content_kind" in patch:
|
||||||
|
patch["content_kind"] = locked_kind
|
||||||
|
self[id_str].update(patch)
|
||||||
|
if "content_kind" in patch:
|
||||||
|
self._enforce_content_kind_invariants(self[id_str])
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
|
|
||||||
|
WIFI_CHANNEL_DEFAULT = 5
|
||||||
|
|
||||||
|
|
||||||
def _settings_path():
|
def _settings_path():
|
||||||
"""Path to settings.json in project root (writable without root)."""
|
"""Path to settings.json in project root (writable without root)."""
|
||||||
@@ -12,11 +14,15 @@ def _settings_path():
|
|||||||
return "settings.json"
|
return "settings.json"
|
||||||
|
|
||||||
|
|
||||||
|
_settings_singleton: "Settings | None" = None
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, *, quiet: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._quiet = quiet
|
||||||
if Settings.SETTINGS_FILE is None:
|
if Settings.SETTINGS_FILE is None:
|
||||||
Settings.SETTINGS_FILE = _settings_path()
|
Settings.SETTINGS_FILE = _settings_path()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
@@ -47,41 +53,42 @@ class Settings(dict):
|
|||||||
self.save()
|
self.save()
|
||||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||||
if 'wifi_channel' not in self:
|
if 'wifi_channel' not in self:
|
||||||
self['wifi_channel'] = 6
|
self['wifi_channel'] = WIFI_CHANNEL_DEFAULT
|
||||||
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
|
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
|
||||||
if 'wifi_driver_ws_port' not in self:
|
if 'bridge_ws_url' not in self:
|
||||||
self['wifi_driver_ws_port'] = 80
|
self['bridge_ws_url'] = ''
|
||||||
if 'wifi_driver_ws_path' not in self:
|
if 'wifi_interface' not in self:
|
||||||
self['wifi_driver_ws_path'] = '/ws'
|
self['wifi_interface'] = ''
|
||||||
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
|
if 'bridges' not in self:
|
||||||
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
|
self['bridges'] = []
|
||||||
if 'wifi_driver_hello_interval_s' not in self:
|
if 'bridge_transport' not in self:
|
||||||
self['wifi_driver_hello_interval_s'] = 10.0
|
self['bridge_transport'] = 'serial'
|
||||||
# Outbound WebSocket dial: total seconds to keep trying before first success
|
if 'bridge_serial_port' not in self:
|
||||||
# (many devices booting at once need more than a short window).
|
self['bridge_serial_port'] = ''
|
||||||
if 'wifi_driver_connect_retry_window_s' not in self:
|
if 'bridge_serial_baudrate' not in self:
|
||||||
self['wifi_driver_connect_retry_window_s'] = 120.0
|
self['bridge_serial_baudrate'] = 115200
|
||||||
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
|
|
||||||
if 'wifi_driver_connect_stagger_max_s' not in self:
|
|
||||||
self['wifi_driver_connect_stagger_max_s'] = 2.5
|
|
||||||
# TCP/WebSocket open timeout per attempt (seconds).
|
|
||||||
if 'wifi_driver_ws_open_timeout' not in self:
|
|
||||||
self['wifi_driver_ws_open_timeout'] = 45.0
|
|
||||||
# Pause between outbound WebSocket dial attempts (seconds).
|
|
||||||
if 'wifi_driver_connect_retry_interval_s' not in self:
|
|
||||||
self['wifi_driver_connect_retry_interval_s'] = 2.0
|
|
||||||
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
|
||||||
if 'serial_enabled' not in self:
|
|
||||||
self['serial_enabled'] = False
|
|
||||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||||
if 'global_brightness' not in self:
|
if 'global_brightness' not in self:
|
||||||
self['global_brightness'] = 255
|
self['global_brightness'] = 255
|
||||||
|
# Sequence tile start: wait for beat or downbeat (server-owned).
|
||||||
|
if 'sequence_switch_wait' not in self:
|
||||||
|
self['sequence_switch_wait'] = 'beat'
|
||||||
|
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
|
||||||
|
self['sequence_switch_wait'] = 'beat'
|
||||||
|
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
|
||||||
|
if 'audio_beat_phase_ms' not in self:
|
||||||
|
self['audio_beat_phase_ms'] = 0
|
||||||
|
# Input gain for beat detection (percent, 0–200).
|
||||||
|
if 'audio_input_volume' not in self:
|
||||||
|
self['audio_input_volume'] = 100
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
j = json.dumps(self)
|
j = json.dumps(self, indent=2, sort_keys=True)
|
||||||
with open(self.SETTINGS_FILE, 'w') as file:
|
with open(self.SETTINGS_FILE, 'w') as file:
|
||||||
file.write(j)
|
file.write(j)
|
||||||
|
file.write("\n")
|
||||||
|
if not getattr(self, "_quiet", False):
|
||||||
print("Settings saved successfully.")
|
print("Settings saved successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving settings: {e}")
|
print(f"Error saving settings: {e}")
|
||||||
@@ -93,9 +100,11 @@ class Settings(dict):
|
|||||||
loaded_settings = json.load(file)
|
loaded_settings = json.load(file)
|
||||||
self.update(loaded_settings)
|
self.update(loaded_settings)
|
||||||
loaded_from_file = True
|
loaded_from_file = True
|
||||||
|
if not getattr(self, "_quiet", False):
|
||||||
print("Settings loaded successfully.")
|
print("Settings loaded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading settings")
|
if not getattr(self, "_quiet", False):
|
||||||
|
print(f"Error loading settings: {e}")
|
||||||
self.clear()
|
self.clear()
|
||||||
finally:
|
finally:
|
||||||
# Ensure defaults are set even if file exists but is missing keys
|
# Ensure defaults are set even if file exists but is missing keys
|
||||||
@@ -103,3 +112,18 @@ class Settings(dict):
|
|||||||
# Only save if file didn't exist or was invalid
|
# Only save if file didn't exist or was invalid
|
||||||
if not loaded_from_file:
|
if not loaded_from_file:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
|
||||||
|
global _settings_singleton
|
||||||
|
if _settings_singleton is None:
|
||||||
|
_settings_singleton = Settings()
|
||||||
|
return _settings_singleton
|
||||||
|
|
||||||
|
|
||||||
|
def reload_settings() -> Settings:
|
||||||
|
"""Re-read settings.json (e.g. after external file edit)."""
|
||||||
|
global _settings_singleton
|
||||||
|
_settings_singleton = Settings(quiet=True)
|
||||||
|
return _settings_singleton
|
||||||
|
|||||||
703
src/static/audio.js
Normal file
703
src/static/audio.js
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
(() => {
|
||||||
|
let pollTimer = null;
|
||||||
|
let audioDetectorRunning = false;
|
||||||
|
let lastBeatSeq = 0;
|
||||||
|
/** 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;
|
||||||
|
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
||||||
|
const pendingBeatPhaseTimers = new Set();
|
||||||
|
let cachedBeatPhaseMs = 0;
|
||||||
|
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
|
||||||
|
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBpmDisplay(bpm) {
|
||||||
|
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
|
||||||
|
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
|
||||||
|
const node = el(id);
|
||||||
|
if (node) node.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Record<string, unknown>} status */
|
||||||
|
function updateBarPhaseDisplay(status) {
|
||||||
|
const readout = String((status && status.bar_phase_readout) || "").trim();
|
||||||
|
const phaseConf = Number((status && status.phase_confidence) || 0);
|
||||||
|
const downbeat = !!(status && status.is_downbeat);
|
||||||
|
let text = readout || "--";
|
||||||
|
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
|
||||||
|
text = `${text} (${Math.round(phaseConf * 100)}%)`;
|
||||||
|
}
|
||||||
|
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
|
||||||
|
const node = el(id);
|
||||||
|
if (!node) continue;
|
||||||
|
node.textContent = status && status.running ? text : "";
|
||||||
|
node.classList.toggle("is-downbeat", downbeat && !!readout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTopBpmVisible(on) {
|
||||||
|
const top = el("audio-top-indicator");
|
||||||
|
if (!top) return;
|
||||||
|
top.classList.toggle("audio-running", !!on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResetDetectorEnabled(on) {
|
||||||
|
const btn = el("audio-reset-btn");
|
||||||
|
if (btn) btn.disabled = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAudioTracking() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
console.warn("audio reset failed", data.error || res.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await pollStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio reset failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beatSyncButtonTitle(zoneSeqActive) {
|
||||||
|
if (!audioDetectorRunning) return "Start beat detection";
|
||||||
|
if (zoneSeqActive) return "Sync step to music (S)";
|
||||||
|
return "Beat detection running";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSequenceSyncControls(zoneSeqActive) {
|
||||||
|
const disabled = audioDetectorRunning && !zoneSeqActive;
|
||||||
|
const title = beatSyncButtonTitle(zoneSeqActive);
|
||||||
|
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
|
||||||
|
const btn = el(id);
|
||||||
|
if (!btn) continue;
|
||||||
|
btn.disabled = disabled;
|
||||||
|
btn.title = title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTopBpmButtonClick() {
|
||||||
|
if (!audioDetectorRunning) {
|
||||||
|
try {
|
||||||
|
await startAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("audio start failed", e);
|
||||||
|
alert("Failed to start audio input. Check mic permissions.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await syncSequenceBeatPhase("step");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("sequence beat sync failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSequenceBeatPhase(mode) {
|
||||||
|
const res = await fetch("/sequences/sync-phase", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ mode: mode || "step" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||||
|
}
|
||||||
|
await pollStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target) {
|
||||||
|
if (!target || typeof target !== "object") return false;
|
||||||
|
const tag = String(target.tagName || "").toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashBeatSyncButton(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.classList.add("flash");
|
||||||
|
setTimeout(() => btn.classList.remove("flash"), 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashBeat() {
|
||||||
|
const top = el("audio-top-indicator");
|
||||||
|
const topSync = el("audio-top-beat-sync");
|
||||||
|
if (topSync && top && top.classList.contains("audio-running")) {
|
||||||
|
flashBeatSyncButton(topSync);
|
||||||
|
}
|
||||||
|
const modalSync = el("audio-modal-beat-sync");
|
||||||
|
if (modalSync && audioDetectorRunning) {
|
||||||
|
flashBeatSyncButton(modalSync);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function gainPercentToDb(pct) {
|
||||||
|
const gain = Math.max(0.001, pct / 100);
|
||||||
|
return 20 * Math.log10(gain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGainReadout(pct) {
|
||||||
|
const db = gainPercentToDb(pct);
|
||||||
|
const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2);
|
||||||
|
return `${pct}% (${dbText} dB)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInputLevelDisplay(level) {
|
||||||
|
const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0;
|
||||||
|
const bar = el("audio-input-level-bar");
|
||||||
|
const meter = el("audio-modal")?.querySelector(".audio-input-level-meter");
|
||||||
|
if (bar) bar.style.width = `${pct}%`;
|
||||||
|
if (meter) meter.setAttribute("aria-valuenow", String(pct));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBeatPhaseTimers() {
|
||||||
|
pendingBeatPhaseTimers.forEach((t) => clearTimeout(t));
|
||||||
|
pendingBeatPhaseTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBeatPhaseDelayMs() {
|
||||||
|
return Math.min(500, Math.max(0, cachedBeatPhaseMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputVolumePercent() {
|
||||||
|
const inp = el("audio-input-volume");
|
||||||
|
if (!inp) return 100;
|
||||||
|
const n = parseInt(String(inp.value).trim(), 10);
|
||||||
|
if (!Number.isFinite(n)) return 100;
|
||||||
|
return Math.min(200, Math.max(0, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInputVolumeReadout() {
|
||||||
|
const readout = el("audio-input-volume-readout");
|
||||||
|
const slider = el("audio-input-volume");
|
||||||
|
const pct = getInputVolumePercent();
|
||||||
|
if (readout) readout.textContent = formatGainReadout(pct);
|
||||||
|
if (slider) {
|
||||||
|
slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistInputVolume() {
|
||||||
|
const vol = getInputVolumePercent();
|
||||||
|
updateInputVolumeReadout();
|
||||||
|
try {
|
||||||
|
await fetch("/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ audio_input_volume: vol }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("input volume 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() {
|
||||||
|
audioDetectorRunning = false;
|
||||||
|
setTopBpmVisible(false);
|
||||||
|
setResetDetectorEnabled(false);
|
||||||
|
clearBeatPhaseTimers();
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
lastBeatSeq = 0;
|
||||||
|
prevZoneSequencePlaybackActive = false;
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
updateBeatReadoutDisplays({});
|
||||||
|
updateInputLevelDisplay(0);
|
||||||
|
try {
|
||||||
|
await fetch("/api/audio/stop", { method: "POST" });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio stop failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-initiated stop (run intent cleared on server). */
|
||||||
|
async function stopAudio() {
|
||||||
|
await stopAudioOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
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({});
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
|
updateBpmDisplay(null);
|
||||||
|
updateInputLevelDisplay(0);
|
||||||
|
setTopBpmVisible(!!status.running);
|
||||||
|
setResetDetectorEnabled(!!status.running);
|
||||||
|
if (!status.running && pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
|
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||||
|
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||||
|
setResetDetectorEnabled(!!status.running);
|
||||||
|
updateSequenceSyncControls(zoneSeqActive);
|
||||||
|
updateBpmDisplay(status.bpm);
|
||||||
|
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||||
|
updateBarPhaseDisplay(status);
|
||||||
|
updateInputLevelDisplay(
|
||||||
|
status.running ? Number(status.input_level) : 0,
|
||||||
|
);
|
||||||
|
applyServerAudioUiFields(status);
|
||||||
|
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
|
||||||
|
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* `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 endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||||
|
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||||
|
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||||
|
if (startedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
}
|
||||||
|
if (endedSeq) {
|
||||||
|
headerBeatStickyIdleAfterSeq = true;
|
||||||
|
clearBeatPhaseTimers();
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
}
|
||||||
|
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
|
||||||
|
if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
headerBeatStickyIdleAfterSeq = false;
|
||||||
|
}
|
||||||
|
} else if (beatSeq > lastBeatSeq) {
|
||||||
|
lastBeatSeq = beatSeq;
|
||||||
|
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
|
||||||
|
}
|
||||||
|
updateBeatReadoutDisplays(status);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio status poll failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ignore server device sync briefly after the user picks from the dropdown. */
|
||||||
|
let deviceSelectLockUntil = 0;
|
||||||
|
/** Suppress change handler while rebuilding or programmatically setting the select. */
|
||||||
|
let suppressDeviceSelectEvents = false;
|
||||||
|
/** Last explicit UI choice (dropdown); not overwritten by server poll. */
|
||||||
|
let uiDeviceSelectId = "";
|
||||||
|
|
||||||
|
function lockDeviceSelect(ms = 10000) {
|
||||||
|
deviceSelectLockUntil = Date.now() + ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredSavedDeviceId() {
|
||||||
|
return cachedAudioRun.device_select ? String(cachedAudioRun.device_select) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionIdForSavedDevice(select, savedId) {
|
||||||
|
const saved = savedId == null ? "" : String(savedId);
|
||||||
|
if (!saved || !select) return "";
|
||||||
|
if (selectHasDeviceOptionId(select, saved)) return saved;
|
||||||
|
if (!/^-?\d+$/.test(saved)) return "";
|
||||||
|
for (const opt of select.options) {
|
||||||
|
if (String(opt.dataset.sdIndex ?? "") === saved) return opt.value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDeviceSelectAfterRefresh(select, defaultId, restoreId = "") {
|
||||||
|
const picked = restoreId || getSelectedDeviceId();
|
||||||
|
if (picked && selectHasDeviceOptionId(select, picked)) {
|
||||||
|
setSelectedDeviceId(picked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = preferredSavedDeviceId();
|
||||||
|
const savedId = optionIdForSavedDevice(select, saved) || saved;
|
||||||
|
if (savedId && selectHasDeviceOptionId(select, savedId)) {
|
||||||
|
setSelectedDeviceId(savedId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (defaultId && selectHasDeviceOptionId(select, defaultId)) {
|
||||||
|
setSelectedDeviceId(defaultId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedDeviceId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedDeviceId() {
|
||||||
|
return String(el("audio-device-select")?.value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectHasDeviceOptionId(select, deviceId) {
|
||||||
|
const id = deviceId == null ? "" : String(deviceId);
|
||||||
|
return [...select.options].some((opt) => opt.value === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioRunPreferredDeviceId(run) {
|
||||||
|
return run.device_select ? String(run.device_select) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelectedDeviceId(deviceId, { force = false } = {}) {
|
||||||
|
const id = deviceId == null ? "" : String(deviceId);
|
||||||
|
const select = el("audio-device-select");
|
||||||
|
if (!select) return false;
|
||||||
|
if (id !== "" && !selectHasDeviceOptionId(select, id)) {
|
||||||
|
if (!force) return false;
|
||||||
|
}
|
||||||
|
suppressDeviceSelectEvents = true;
|
||||||
|
try {
|
||||||
|
select.value = id;
|
||||||
|
uiDeviceSelectId = id;
|
||||||
|
} finally {
|
||||||
|
suppressDeviceSelectEvents = false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDeviceForm() {
|
||||||
|
return { override: "", selected: getSelectedDeviceId() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistDeviceSelection(deviceId) {
|
||||||
|
const selected = deviceId != null ? String(deviceId) : getSelectedDeviceId();
|
||||||
|
uiDeviceSelectId = selected;
|
||||||
|
cachedAudioRun.device_select = selected;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/device", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ device_select: selected, device_override: "" }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data?.audio_run && typeof data.audio_run === "object") {
|
||||||
|
const saved = data.audio_run.device_select
|
||||||
|
? String(data.audio_run.device_select)
|
||||||
|
: "";
|
||||||
|
if (saved === selected) {
|
||||||
|
cachedAudioRun.device_select = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("device selection save failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startAudio(deviceId) {
|
||||||
|
const selected =
|
||||||
|
deviceId != null && deviceId !== undefined
|
||||||
|
? String(deviceId)
|
||||||
|
: uiDeviceSelectId || getSelectedDeviceId();
|
||||||
|
lockDeviceSelect();
|
||||||
|
uiDeviceSelectId = selected;
|
||||||
|
cachedAudioRun.device_select = selected;
|
||||||
|
await stopAudioOnly();
|
||||||
|
await persistDeviceSelection(selected);
|
||||||
|
const rawDevice = selected;
|
||||||
|
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
||||||
|
const body = {
|
||||||
|
device: rawDevice === "" ? null : numeric,
|
||||||
|
device_override: "",
|
||||||
|
device_select: selected,
|
||||||
|
};
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
cachedAudioRun.device_select = selected;
|
||||||
|
setSelectedDeviceId(selected);
|
||||||
|
updateBpmDisplay(null);
|
||||||
|
updateHitTypeDisplay("unknown", NaN);
|
||||||
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
|
await pollStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDevices() {
|
||||||
|
const select = el("audio-device-select");
|
||||||
|
if (!select) return;
|
||||||
|
const res = await fetch("/api/audio/devices");
|
||||||
|
const data = await res.json();
|
||||||
|
// Re-read after fetch so a pick during the request is not overwritten by a stale value.
|
||||||
|
const restoreId = getSelectedDeviceId();
|
||||||
|
const inputs = Array.isArray(data?.devices) ? data.devices.slice() : [];
|
||||||
|
select.innerHTML = "";
|
||||||
|
const defaultOpt = document.createElement("option");
|
||||||
|
defaultOpt.value = "";
|
||||||
|
defaultOpt.textContent = "System default input";
|
||||||
|
select.appendChild(defaultOpt);
|
||||||
|
let defaultId = "";
|
||||||
|
inputs.forEach((d, idx) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = String(d.id);
|
||||||
|
const text = d.display_name || d.name || `Input ${idx + 1}`;
|
||||||
|
opt.textContent = text;
|
||||||
|
const title = d.label || d.name || "";
|
||||||
|
if (title && title !== text) opt.title = title;
|
||||||
|
if (d.sounddevice_index != null && d.sounddevice_index !== "") {
|
||||||
|
opt.dataset.sdIndex = String(d.sounddevice_index);
|
||||||
|
}
|
||||||
|
select.appendChild(opt);
|
||||||
|
if (d.is_default) defaultId = String(d.id);
|
||||||
|
});
|
||||||
|
suppressDeviceSelectEvents = true;
|
||||||
|
try {
|
||||||
|
restoreDeviceSelectAfterRefresh(select, defaultId, restoreId);
|
||||||
|
} finally {
|
||||||
|
suppressDeviceSelectEvents = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resetBtn = el("audio-reset-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);
|
||||||
|
}
|
||||||
|
await loadServerAudioUiFields();
|
||||||
|
setResetDetectorEnabled(audioDetectorRunning);
|
||||||
|
});
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.addEventListener("click", () => {
|
||||||
|
modal.classList.remove("active");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (startBtn) {
|
||||||
|
startBtn.addEventListener("click", async () => {
|
||||||
|
const picked = getSelectedDeviceId();
|
||||||
|
try {
|
||||||
|
await startAudio(picked);
|
||||||
|
} 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 (resetBtn) {
|
||||||
|
resetBtn.addEventListener("click", () => resetAudioTracking());
|
||||||
|
}
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await refreshDevices();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("refresh devices failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deviceSelect = el("audio-device-select");
|
||||||
|
if (deviceSelect) {
|
||||||
|
deviceSelect.addEventListener("change", async () => {
|
||||||
|
if (suppressDeviceSelectEvents) return;
|
||||||
|
const picked = getSelectedDeviceId();
|
||||||
|
uiDeviceSelectId = picked;
|
||||||
|
lockDeviceSelect();
|
||||||
|
cachedAudioRun.device_select = picked;
|
||||||
|
await persistDeviceSelection(picked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const volInp = el("audio-input-volume");
|
||||||
|
if (volInp) {
|
||||||
|
volInp.addEventListener("input", () => {
|
||||||
|
updateInputVolumeReadout();
|
||||||
|
void persistInputVolume();
|
||||||
|
});
|
||||||
|
volInp.addEventListener("change", () => {
|
||||||
|
updateInputVolumeReadout();
|
||||||
|
void persistInputVolume();
|
||||||
|
});
|
||||||
|
updateInputVolumeReadout();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
|
||||||
|
const btn = el(id);
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
void handleTopBpmButtonClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||||
|
const k = String(ev.key || "").toLowerCase();
|
||||||
|
if (k !== "s") return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const mode = ev.shiftKey ? "pass" : "step";
|
||||||
|
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumePollingIfDetectorRunning() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
const status = data?.status || {};
|
||||||
|
audioDetectorRunning = !!status.running;
|
||||||
|
if (status.running && !pollTimer) {
|
||||||
|
pollTimer = setInterval(pollStatus, 250);
|
||||||
|
lastBeatSeq = Number(status.beat_seq || 0);
|
||||||
|
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
|
||||||
|
await pollStatus();
|
||||||
|
} else {
|
||||||
|
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio resume poll check failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply server-owned audio UI fields from status (volume; device dropdown is user-owned). */
|
||||||
|
function applyServerAudioUiFields(status) {
|
||||||
|
if (!status || typeof status !== "object") return;
|
||||||
|
const run = status.audio_run;
|
||||||
|
if (run && typeof run === "object") {
|
||||||
|
cachedAudioRun = {
|
||||||
|
device: run.device ?? null,
|
||||||
|
device_override: run.device_override != null ? String(run.device_override) : "",
|
||||||
|
device_select: run.device_select ? String(run.device_select) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (status.beat_phase_ms != null) {
|
||||||
|
const ms = parseInt(String(status.beat_phase_ms), 10);
|
||||||
|
if (Number.isFinite(ms)) {
|
||||||
|
cachedBeatPhaseMs = Math.min(500, Math.max(0, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const volInp = el("audio-input-volume");
|
||||||
|
if (
|
||||||
|
volInp &&
|
||||||
|
status.input_volume != null &&
|
||||||
|
document.activeElement !== volInp
|
||||||
|
) {
|
||||||
|
const vol = parseInt(String(status.input_volume), 10);
|
||||||
|
if (Number.isFinite(vol)) {
|
||||||
|
volInp.value = String(Math.min(200, Math.max(0, vol)));
|
||||||
|
updateInputVolumeReadout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServerAudioUiFields() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||||
|
const data = await res.json();
|
||||||
|
const status = data?.status || {};
|
||||||
|
applyServerAudioUiFields(status);
|
||||||
|
const select = el("audio-device-select");
|
||||||
|
const saved = audioRunPreferredDeviceId(status.audio_run || {});
|
||||||
|
if (select && saved && selectHasDeviceOptionId(select, saved)) {
|
||||||
|
uiDeviceSelectId = saved;
|
||||||
|
setSelectedDeviceId(saved);
|
||||||
|
}
|
||||||
|
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("audio status load failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from sequences.js when server playback starts/stops without audio polling. */
|
||||||
|
window.ledControllerSequencePlaybackChanged = (active) => {
|
||||||
|
updateSequenceSyncControls(!!active);
|
||||||
|
if (active) {
|
||||||
|
setTopBpmVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pollTimer) {
|
||||||
|
setTopBpmVisible(false);
|
||||||
|
updateSequenceSyncControls(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
bind();
|
||||||
|
await loadServerAudioUiFields();
|
||||||
|
await resumePollingIfDetectorRunning();
|
||||||
|
});
|
||||||
|
})();
|
||||||
48
src/static/bundle_io.js
Normal file
48
src/static/bundle_io.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
|
||||||
|
|
||||||
|
window.downloadJsonFile = function downloadJsonFile(filename, data) {
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([text], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename || 'bundle.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pickJsonFile = function pickJsonFile() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'application/json,.json';
|
||||||
|
input.style.display = 'none';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
input.remove();
|
||||||
|
if (!file) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => resolve(null);
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.parseJsonFileText = function parseJsonFileText(text) {
|
||||||
|
if (text == null || text === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
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();
|
||||||
|
})();
|
||||||
@@ -18,6 +18,15 @@ const DEVICES_MODAL_POLL_MS = 1000;
|
|||||||
|
|
||||||
let devicesModalLiveTimer = null;
|
let devicesModalLiveTimer = null;
|
||||||
|
|
||||||
|
/** Last ESP-NOW ping result per MAC (hex, no separators). Cleared only when a new ping marks offline. */
|
||||||
|
const espnowPingStatusByMac = new Map();
|
||||||
|
|
||||||
|
/** Aggregate ping dot state (Devices / Settings ping buttons). */
|
||||||
|
let lastEspnowPingAggregate = {
|
||||||
|
state: 'unknown',
|
||||||
|
title: 'Not pinged yet',
|
||||||
|
};
|
||||||
|
|
||||||
function stopDevicesModalLiveRefresh() {
|
function stopDevicesModalLiveRefresh() {
|
||||||
if (devicesModalLiveTimer != null) {
|
if (devicesModalLiveTimer != null) {
|
||||||
clearInterval(devicesModalLiveTimer);
|
clearInterval(devicesModalLiveTimer);
|
||||||
@@ -53,11 +62,196 @@ function startDevicesModalLiveRefresh() {
|
|||||||
}, DEVICES_MODAL_POLL_MS);
|
}, DEVICES_MODAL_POLL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_DOT_CLASSES = [
|
||||||
|
'device-status-dot--online',
|
||||||
|
'device-status-dot--offline',
|
||||||
|
'device-status-dot--unknown',
|
||||||
|
'device-status-dot--pinging',
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeDeviceMacKey(mac) {
|
||||||
|
return String(mac || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[:-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMacInput(raw) {
|
||||||
|
return String(raw || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[:-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPingResponse(responses, deviceId) {
|
||||||
|
if (!responses || typeof responses !== 'object') return null;
|
||||||
|
const want = normalizeDeviceMacKey(deviceId);
|
||||||
|
for (const [mac, info] of Object.entries(responses)) {
|
||||||
|
if (normalizeDeviceMacKey(mac) === want) return info;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDeviceStatusDot(dot, state, title) {
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.remove(...DEVICE_DOT_CLASSES);
|
||||||
|
if (state === 'online') dot.classList.add('device-status-dot--online');
|
||||||
|
else if (state === 'offline') dot.classList.add('device-status-dot--offline');
|
||||||
|
else if (state === 'pinging') dot.classList.add('device-status-dot--pinging');
|
||||||
|
else dot.classList.add('device-status-dot--unknown');
|
||||||
|
dot.title = title;
|
||||||
|
dot.setAttribute('aria-label', title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePingStatusDot(dotEl, state, title) {
|
||||||
|
if (!dotEl) return;
|
||||||
|
dotEl.classList.remove(...DEVICE_DOT_CLASSES);
|
||||||
|
if (state === 'online') dotEl.classList.add('device-status-dot--online');
|
||||||
|
else if (state === 'offline') dotEl.classList.add('device-status-dot--offline');
|
||||||
|
else if (state === 'pinging') dotEl.classList.add('device-status-dot--pinging');
|
||||||
|
else dotEl.classList.add('device-status-dot--unknown');
|
||||||
|
dotEl.title = title;
|
||||||
|
dotEl.setAttribute('aria-label', title);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberEspnowPingAggregate(state, title) {
|
||||||
|
lastEspnowPingAggregate = { state, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEspnowPingAggregateToDots() {
|
||||||
|
for (const id of ['devices-ping-dot']) {
|
||||||
|
updatePingStatusDot(document.getElementById(id), lastEspnowPingAggregate.state, lastEspnowPingAggregate.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdateGroups(btn) {
|
||||||
|
const statusEl = document.getElementById('devices-groups-status');
|
||||||
|
const prevLabel = btn ? btn.textContent : '';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Updating…';
|
||||||
|
}
|
||||||
|
if (statusEl) statusEl.textContent = 'Sending group membership…';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/devices/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
const err = data.error || 'Update groups failed';
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sent = Number(data.sent) || 0;
|
||||||
|
const failed = Number(data.failed) || 0;
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent =
|
||||||
|
failed > 0
|
||||||
|
? `Sent to ${sent} driver${sent === 1 ? '' : 's'}, ${failed} failed`
|
||||||
|
: `Sent to ${sent} driver${sent === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (statusEl) statusEl.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = prevLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runEspnowPing({ btn, dot, statusEl } = {}) {
|
||||||
|
const prevLabel = btn ? btn.textContent : '';
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Pinging…';
|
||||||
|
}
|
||||||
|
updatePingStatusDot(dot, 'pinging', 'Ping in progress…');
|
||||||
|
if (statusEl) statusEl.textContent = 'Waiting for replies (3 s)…';
|
||||||
|
applyEspnowPingToDeviceRows(null, 'pinging');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/devices/ping', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ timeout_s: 3 }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
const err = data.error || 'Ping failed';
|
||||||
|
rememberEspnowPingAggregate('offline', err);
|
||||||
|
updatePingStatusDot(dot, 'offline', err);
|
||||||
|
applyEspnowPingAggregateToDots();
|
||||||
|
if (statusEl) statusEl.textContent = err;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const count = Object.keys(data.responses || {}).length;
|
||||||
|
const registered = Number(data.registered) || 0;
|
||||||
|
const aggState = count > 0 ? 'online' : 'offline';
|
||||||
|
const aggTitle =
|
||||||
|
count > 0
|
||||||
|
? `${count} driver${count === 1 ? '' : 's'} replied`
|
||||||
|
: 'No drivers replied';
|
||||||
|
rememberEspnowPingAggregate(aggState, aggTitle);
|
||||||
|
updatePingStatusDot(dot, aggState, aggTitle);
|
||||||
|
applyEspnowPingAggregateToDots();
|
||||||
|
if (statusEl) {
|
||||||
|
let msg = `${count} response${count === 1 ? '' : 's'}`;
|
||||||
|
if (registered > 0) {
|
||||||
|
msg += ` · ${registered} new in list`;
|
||||||
|
}
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
}
|
||||||
|
await refreshDevicesListQuiet();
|
||||||
|
applyEspnowPingToDeviceRows(data.responses, 'done');
|
||||||
|
} catch (error) {
|
||||||
|
const msg = `Error: ${error.message}`;
|
||||||
|
rememberEspnowPingAggregate('offline', msg);
|
||||||
|
updatePingStatusDot(dot, 'offline', msg);
|
||||||
|
applyEspnowPingAggregateToDots();
|
||||||
|
if (statusEl) statusEl.textContent = error.message;
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = prevLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEspnowPingToDeviceRows(responses, phase) {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.querySelectorAll('.profiles-row[data-device-transport="espnow"]').forEach((row) => {
|
||||||
|
const dot = row.querySelector('.device-status-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
if (phase === 'pinging') {
|
||||||
|
setDeviceStatusDot(dot, 'pinging', 'Ping in progress…');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const macKey = normalizeDeviceMacKey(row.dataset.deviceId);
|
||||||
|
const info = findPingResponse(responses, row.dataset.deviceId);
|
||||||
|
if (info) {
|
||||||
|
const rtt = info.rtt_ms != null ? `${info.rtt_ms} ms` : 'ok';
|
||||||
|
const title = `Ping reply (${rtt})`;
|
||||||
|
setDeviceStatusDot(dot, 'online', title);
|
||||||
|
espnowPingStatusByMac.set(macKey, { state: 'online', title });
|
||||||
|
} else {
|
||||||
|
const title = 'No ping reply';
|
||||||
|
setDeviceStatusDot(dot, 'offline', title);
|
||||||
|
espnowPingStatusByMac.set(macKey, { state: 'offline', title });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function espnowPingStatusForMac(devId) {
|
||||||
|
return espnowPingStatusByMac.get(normalizeDeviceMacKey(devId)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
function updateWifiRowDot(row, connected) {
|
function updateWifiRowDot(row, connected) {
|
||||||
const dot = row.querySelector('.device-status-dot');
|
const dot = row.querySelector('.device-status-dot');
|
||||||
if (!dot) return;
|
if (!dot) return;
|
||||||
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
||||||
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
|
dot.classList.remove(...DEVICE_DOT_CLASSES);
|
||||||
if (connected) {
|
if (connected) {
|
||||||
dot.classList.add('device-status-dot--online');
|
dot.classList.add('device-status-dot--online');
|
||||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||||
@@ -149,8 +343,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 +362,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;
|
||||||
@@ -184,6 +437,69 @@ async function loadDevicesModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createDeviceFromModal() {
|
||||||
|
const nameEl = document.getElementById('devices-add-name');
|
||||||
|
const trEl = document.getElementById('devices-add-transport');
|
||||||
|
const macEl = document.getElementById('devices-add-mac');
|
||||||
|
const addrEl = document.getElementById('devices-add-address');
|
||||||
|
const statusEl = document.getElementById('devices-add-status');
|
||||||
|
const btn = document.getElementById('devices-add-btn');
|
||||||
|
const name = (nameEl && nameEl.value.trim()) || '';
|
||||||
|
const transport = (trEl && trEl.value) || 'espnow';
|
||||||
|
const mac = normalizeMacInput(macEl && macEl.value);
|
||||||
|
const address = (addrEl && addrEl.value.trim()) || '';
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
if (statusEl) statusEl.textContent = 'Name is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mac.length !== 12) {
|
||||||
|
if (statusEl) statusEl.textContent = 'MAC must be 12 hex characters';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transport === 'wifi' && !address) {
|
||||||
|
if (statusEl) statusEl.textContent = 'Address is required for Wi-Fi devices';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Adding…';
|
||||||
|
}
|
||||||
|
if (statusEl) statusEl.textContent = 'Creating device…';
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
transport,
|
||||||
|
type: 'led',
|
||||||
|
mac,
|
||||||
|
address: transport === 'wifi' ? address : mac,
|
||||||
|
};
|
||||||
|
const res = await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
if (statusEl) statusEl.textContent = data.error || 'Create failed';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusEl) statusEl.textContent = 'Device added';
|
||||||
|
if (nameEl) nameEl.value = '';
|
||||||
|
if (macEl) macEl.value = '';
|
||||||
|
if (addrEl) addrEl.value = '';
|
||||||
|
await loadDevicesModal();
|
||||||
|
} catch (e) {
|
||||||
|
if (statusEl) statusEl.textContent = e.message || 'Create failed';
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Add device';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderDevicesList(devices) {
|
function renderDevicesList(devices) {
|
||||||
const container = document.getElementById('devices-list-modal');
|
const container = document.getElementById('devices-list-modal');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -218,17 +534,16 @@ function renderDevicesList(devices) {
|
|||||||
dot.setAttribute('role', 'img');
|
dot.setAttribute('role', 'img');
|
||||||
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
||||||
if (live === true) {
|
if (live === true) {
|
||||||
dot.classList.add('device-status-dot--online');
|
setDeviceStatusDot(dot, 'online', 'Connected (Wi-Fi TCP session)');
|
||||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
|
||||||
dot.setAttribute('aria-label', dot.title);
|
|
||||||
} else if (live === false) {
|
} else if (live === false) {
|
||||||
dot.classList.add('device-status-dot--offline');
|
setDeviceStatusDot(dot, 'offline', 'Not connected (no Wi-Fi TCP session)');
|
||||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
|
||||||
dot.setAttribute('aria-label', dot.title);
|
|
||||||
} else {
|
} else {
|
||||||
dot.classList.add('device-status-dot--unknown');
|
const pingCached = espnowPingStatusForMac(devId);
|
||||||
dot.title = 'ESP-NOW — TCP status does not apply';
|
if (pingCached) {
|
||||||
dot.setAttribute('aria-label', dot.title);
|
setDeviceStatusDot(dot, pingCached.state, pingCached.title);
|
||||||
|
} else {
|
||||||
|
setDeviceStatusDot(dot, 'unknown', 'ESP-NOW — ping or identify to test reachability');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
@@ -307,6 +622,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 +645,83 @@ function openEditDeviceModal(devId, dev) {
|
|||||||
applyTransportVisibility(tr);
|
applyTransportVisibility(tr);
|
||||||
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
|
||||||
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
|
||||||
|
const wName = document.getElementById('edit-device-wifi-driver-name');
|
||||||
|
const wLeds = document.getElementById('edit-device-wifi-num-leds');
|
||||||
|
const wCo = document.getElementById('edit-device-wifi-color-order');
|
||||||
|
const wStart = document.getElementById('edit-device-wifi-startup-mode');
|
||||||
|
if (wName) {
|
||||||
|
const savedDisp =
|
||||||
|
dev && Object.prototype.hasOwnProperty.call(dev, 'wifi_driver_display_name')
|
||||||
|
? dev.wifi_driver_display_name
|
||||||
|
: undefined;
|
||||||
|
if (savedDisp != null && String(savedDisp).trim() !== '') {
|
||||||
|
wName.value = String(savedDisp).trim();
|
||||||
|
} else {
|
||||||
|
wName.value = dev && dev.name ? String(dev.name) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wLeds) {
|
||||||
|
wLeds.value =
|
||||||
|
dev && dev.wifi_driver_num_leds != null && dev.wifi_driver_num_leds !== ''
|
||||||
|
? String(dev.wifi_driver_num_leds)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
if (wCo) {
|
||||||
|
const co = (dev && dev.wifi_color_order) || 'rgb';
|
||||||
|
wCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
|
||||||
|
? String(co).toLowerCase()
|
||||||
|
: 'rgb';
|
||||||
|
}
|
||||||
|
if (wStart) {
|
||||||
|
const sm = (dev && dev.wifi_startup_mode) || 'default';
|
||||||
|
wStart.value = ['default', 'last', 'off'].includes(String(sm).toLowerCase())
|
||||||
|
? String(sm).toLowerCase()
|
||||||
|
: 'default';
|
||||||
|
}
|
||||||
|
const obr = document.getElementById('edit-device-output-brightness');
|
||||||
|
const obv = document.getElementById('edit-device-output-brightness-value');
|
||||||
|
if (obr) {
|
||||||
|
let bv = 255;
|
||||||
|
if (dev && dev.output_brightness != null && dev.output_brightness !== '') {
|
||||||
|
const n = parseInt(String(dev.output_brightness), 10);
|
||||||
|
if (!Number.isNaN(n)) bv = Math.max(0, Math.min(255, n));
|
||||||
|
}
|
||||||
|
obr.value = String(bv);
|
||||||
|
if (obv) obv.textContent = String(bv);
|
||||||
|
}
|
||||||
|
refreshEditDeviceDebug();
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateDevice(devId, name, type, transport, address) {
|
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
const payload = {
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
name,
|
||||||
type: type || 'led',
|
type: type || 'led',
|
||||||
transport: transport || 'espnow',
|
transport: transport || 'espnow',
|
||||||
address,
|
address,
|
||||||
}),
|
};
|
||||||
|
if (typeof outputBrightness === 'number') {
|
||||||
|
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
|
||||||
|
}
|
||||||
|
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
|
||||||
|
if (wifiDriverFields.wifi_driver_display_name != null) {
|
||||||
|
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_driver_num_leds != null) {
|
||||||
|
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_color_order != null) {
|
||||||
|
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
|
||||||
|
}
|
||||||
|
if (wifiDriverFields.wifi_startup_mode != null) {
|
||||||
|
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -354,6 +737,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 +798,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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,6 +820,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const editForm = document.getElementById('edit-device-form');
|
const editForm = document.getElementById('edit-device-form');
|
||||||
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||||
const editDeviceModal = document.getElementById('edit-device-modal');
|
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||||
|
const addTransport = document.getElementById('devices-add-transport');
|
||||||
|
const addAddress = document.getElementById('devices-add-address');
|
||||||
|
const addBtn = document.getElementById('devices-add-btn');
|
||||||
|
|
||||||
if (devicesBtn && devicesModal) {
|
if (devicesBtn && devicesModal) {
|
||||||
devicesBtn.addEventListener('click', () => {
|
devicesBtn.addEventListener('click', () => {
|
||||||
@@ -400,6 +830,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (typeof window.getEspnowSocket === 'function') {
|
if (typeof window.getEspnowSocket === 'function') {
|
||||||
window.getEspnowSocket();
|
window.getEspnowSocket();
|
||||||
}
|
}
|
||||||
|
applyEspnowPingAggregateToDots();
|
||||||
loadDevicesModal();
|
loadDevicesModal();
|
||||||
startDevicesModalLiveRefresh();
|
startDevicesModalLiveRefresh();
|
||||||
});
|
});
|
||||||
@@ -410,6 +841,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addTransport && addAddress) {
|
||||||
|
const syncAddAddress = () => {
|
||||||
|
addAddress.hidden = addTransport.value !== 'wifi';
|
||||||
|
};
|
||||||
|
addTransport.addEventListener('change', syncAddAddress);
|
||||||
|
syncAddAddress();
|
||||||
|
}
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', () => createDeviceFromModal());
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesPingBtn = document.getElementById('devices-ping-btn');
|
||||||
|
if (devicesPingBtn) {
|
||||||
|
devicesPingBtn.addEventListener('click', () => {
|
||||||
|
runEspnowPing({
|
||||||
|
btn: devicesPingBtn,
|
||||||
|
dot: document.getElementById('devices-ping-dot'),
|
||||||
|
statusEl: document.getElementById('devices-ping-status'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const devicesUpdateGroupsBtn = document.getElementById('devices-update-groups-btn');
|
||||||
|
if (devicesUpdateGroupsBtn) {
|
||||||
|
devicesUpdateGroupsBtn.addEventListener('click', () => runUpdateGroups(devicesUpdateGroupsBtn));
|
||||||
|
}
|
||||||
|
|
||||||
const devicesModalEl = document.getElementById('devices-modal');
|
const devicesModalEl = document.getElementById('devices-modal');
|
||||||
if (devicesModalEl) {
|
if (devicesModalEl) {
|
||||||
new MutationObserver(() => {
|
new MutationObserver(() => {
|
||||||
@@ -420,27 +878,76 @@ 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) {
|
||||||
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.applyEspnowPingToDeviceRows = applyEspnowPingToDeviceRows;
|
||||||
|
window.runEspnowPing = runEspnowPing;
|
||||||
|
window.applyEspnowPingAggregateToDots = applyEspnowPingAggregateToDots;
|
||||||
|
}
|
||||||
|
|||||||
565
src/static/groups.js
Normal file
565
src/static/groups.js
Normal file
@@ -0,0 +1,565 @@
|
|||||||
|
// 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;
|
||||||
|
const panel =
|
||||||
|
typeof window.prepareZoneDevicesPanel === 'function'
|
||||||
|
? window.prepareZoneDevicesPanel(containerEl)
|
||||||
|
: null;
|
||||||
|
const listEl = panel ? panel.listEl : containerEl;
|
||||||
|
if (!panel) {
|
||||||
|
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);
|
||||||
|
listEl.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);
|
||||||
|
if (panel) {
|
||||||
|
panel.addSlot.appendChild(addWrap);
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
} 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 errs = Array.isArray(data.errors) ? data.errors : [];
|
||||||
|
if (errs.some((e) => e && e.error)) {
|
||||||
|
console.warn('Group identify errors', errs);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const settingsButton = document.getElementById('settings-btn');
|
const settingsButton = document.getElementById('settings-btn');
|
||||||
const settingsModal = document.getElementById('settings-modal');
|
const settingsModal = document.getElementById('settings-modal');
|
||||||
const settingsCloseButton = document.getElementById('settings-close-btn');
|
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||||
|
const settingsTabButtons = document.querySelectorAll('[data-settings-tab]');
|
||||||
|
const settingsTabPanels = document.querySelectorAll('[data-settings-panel]');
|
||||||
|
const ledToolIframe = document.getElementById('led-tool-iframe');
|
||||||
|
let settingsActiveTab = 'bridge';
|
||||||
|
|
||||||
const showSettingsMessage = (text, type = 'success') => {
|
function loadLedToolIframe() {
|
||||||
const messageEl = document.getElementById('settings-message');
|
if (!ledToolIframe) return;
|
||||||
if (!messageEl) return;
|
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
|
||||||
messageEl.textContent = text;
|
if (blank) {
|
||||||
messageEl.className = `message ${type} show`;
|
ledToolIframe.src = '/led-tool/editor';
|
||||||
setTimeout(() => {
|
}
|
||||||
messageEl.classList.remove('show');
|
}
|
||||||
}, 5000);
|
|
||||||
|
function unloadLedToolIframe() {
|
||||||
|
if (ledToolIframe) {
|
||||||
|
ledToolIframe.src = 'about:blank';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSettingsTab(tabId) {
|
||||||
|
if (!tabId) tabId = 'bridge';
|
||||||
|
settingsActiveTab = tabId;
|
||||||
|
for (const btn of settingsTabButtons) {
|
||||||
|
const on = btn.getAttribute('data-settings-tab') === tabId;
|
||||||
|
btn.classList.toggle('active', on);
|
||||||
|
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
for (const panel of settingsTabPanels) {
|
||||||
|
const on = panel.getAttribute('data-settings-panel') === tabId;
|
||||||
|
panel.classList.toggle('active', on);
|
||||||
|
panel.hidden = !on;
|
||||||
|
}
|
||||||
|
if (settingsModal) {
|
||||||
|
settingsModal.classList.toggle('settings-modal--led-tool', tabId === 'led-tool');
|
||||||
|
}
|
||||||
|
if (tabId === 'led-tool') {
|
||||||
|
loadLedToolIframe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const btn of settingsTabButtons) {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
switchSettingsTab(btn.getAttribute('data-settings-tab'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openSettingsModal = (tabId) => {
|
||||||
|
if (!settingsModal) return;
|
||||||
|
if (tabId) {
|
||||||
|
switchSettingsTab(tabId);
|
||||||
|
} else {
|
||||||
|
switchSettingsTab(settingsActiveTab);
|
||||||
|
}
|
||||||
|
settingsModal.classList.add('active');
|
||||||
|
if (!tabId || tabId === 'bridge') {
|
||||||
|
loadBridgeSettings();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadDeviceSettings() {
|
const bridgeWsStatus = document.getElementById('bridge-ws-status');
|
||||||
try {
|
const bridgeConnectionDetails = document.getElementById('bridge-connection-details');
|
||||||
const response = await fetch('/settings');
|
const bridgeProfilesList = document.getElementById('bridge-profiles-list');
|
||||||
const data = await response.json();
|
let lastBridgeSettings = null;
|
||||||
const nameInput = document.getElementById('device-name-input');
|
const bridgeSerialPortSelect = document.getElementById('bridge-serial-port');
|
||||||
if (nameInput && data && typeof data === 'object') {
|
const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud');
|
||||||
nameInput.value = data.device_name || 'led-controller';
|
const bridgeSerialConnectBtn = document.getElementById('bridge-serial-connect-btn');
|
||||||
|
const bridgeSerialSaveProfileBtn = document.getElementById('bridge-serial-save-profile-btn');
|
||||||
|
const bridgeSerialRefreshBtn = document.getElementById('bridge-serial-refresh-btn');
|
||||||
|
const bridgeWifiInterfaceSelect = document.getElementById('bridge-wifi-interface');
|
||||||
|
const bridgeWifiRefreshInterfacesBtn = document.getElementById('bridge-wifi-refresh-interfaces-btn');
|
||||||
|
const bridgeWifiSsidSelect = document.getElementById('bridge-wifi-ssid');
|
||||||
|
const bridgeWifiSsidManual = document.getElementById('bridge-wifi-ssid-manual');
|
||||||
|
const bridgeWifiPassword = document.getElementById('bridge-wifi-password');
|
||||||
|
const bridgeWifiConnectBtn = document.getElementById('bridge-wifi-connect-btn');
|
||||||
|
const bridgeWifiSaveProfileBtn = document.getElementById('bridge-wifi-save-profile-btn');
|
||||||
|
const bridgeWifiScanBtn = document.getElementById('bridge-wifi-scan-btn');
|
||||||
|
const bridgeWifiApIp = document.getElementById('bridge-wifi-ap-ip');
|
||||||
|
const bridgeWifiWsPort = document.getElementById('bridge-wifi-ws-port');
|
||||||
|
|
||||||
|
function setBridgeWsStatus(text, isError = false) {
|
||||||
|
if (!bridgeWsStatus) return;
|
||||||
|
bridgeWsStatus.textContent = text || '';
|
||||||
|
bridgeWsStatus.style.color = isError ? '#f44336' : '';
|
||||||
}
|
}
|
||||||
const chInput = document.getElementById('wifi-channel-input');
|
|
||||||
if (chInput && data && typeof data === 'object') {
|
function connLabel(ok) {
|
||||||
const ch = data.wifi_channel;
|
return ok ? 'connected' : 'not connected';
|
||||||
chInput.value =
|
|
||||||
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading device settings:', error);
|
function bridgeStatusLine(data) {
|
||||||
|
if (!data) return '';
|
||||||
|
const mode = data.bridge_transport === 'serial' ? 'USB serial' : 'Wi‑Fi';
|
||||||
|
const active = data.active_bridge_id
|
||||||
|
? (data.bridges || []).find((b) => b.id === data.active_bridge_id)
|
||||||
|
: null;
|
||||||
|
const activeBit = active ? ` — active profile: ${active.label}` : '';
|
||||||
|
if (data.bridge_transport === 'wifi' && data.bridge_ws_url) {
|
||||||
|
return `${mode}: ${data.bridge_ws_url} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||||
|
}
|
||||||
|
if (data.bridge_serial_port) {
|
||||||
|
return `${mode}: ${data.bridge_serial_port} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||||
|
}
|
||||||
|
return `Bridge ${mode} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBridgeConnectionDetails(data) {
|
||||||
|
if (!bridgeConnectionDetails) return;
|
||||||
|
bridgeConnectionDetails.innerHTML = '';
|
||||||
|
if (!data) return;
|
||||||
|
const rows = [
|
||||||
|
['Transport in use', data.bridge_transport === 'serial' ? 'USB serial' : 'Wi‑Fi'],
|
||||||
|
[
|
||||||
|
'Wi‑Fi WebSocket',
|
||||||
|
data.bridge_ws_url
|
||||||
|
? `${data.bridge_ws_url} (${connLabel(data.bridge_wifi_connected)})`
|
||||||
|
: connLabel(false),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'USB serial',
|
||||||
|
data.bridge_serial_port
|
||||||
|
? `${data.bridge_serial_port} (${connLabel(data.bridge_serial_connected)})`
|
||||||
|
: connLabel(false),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const active = (data.bridges || []).find((b) => b.id === data.active_bridge_id);
|
||||||
|
if (active) {
|
||||||
|
const detail =
|
||||||
|
active.transport === 'wifi'
|
||||||
|
? `Wi‑Fi ${active.ssid}`
|
||||||
|
: `USB ${active.serial_port}`;
|
||||||
|
rows.push(['Active saved profile', `${active.label} (${detail})`]);
|
||||||
|
} else if (data.bridge_connected) {
|
||||||
|
rows.push(['Active saved profile', '— (connected, no matching saved profile)']);
|
||||||
|
}
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.textContent = `${k}: ${v}`;
|
||||||
|
bridgeConnectionDetails.appendChild(li);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAPStatus() {
|
function resolvedBridgeSsid() {
|
||||||
|
const manual = bridgeWifiSsidManual?.value?.trim();
|
||||||
|
if (manual) return manual;
|
||||||
|
return bridgeWifiSsidSelect?.value?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBridgeSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/settings/wifi/ap');
|
const bridgesRes = await fetch('/settings/wifi/bridges');
|
||||||
const config = await response.json();
|
const bridgesData = await bridgesRes.json().catch(() => ({}));
|
||||||
const statusEl = document.getElementById('ap-status');
|
lastBridgeSettings = bridgesData;
|
||||||
if (!statusEl) return;
|
if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) {
|
||||||
if (config.active) {
|
bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate);
|
||||||
statusEl.innerHTML = `
|
}
|
||||||
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
await loadSerialPorts(bridgesData.bridge_serial_port || '');
|
||||||
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
await loadWifiInterfaces(bridgesData.wifi_interface || '');
|
||||||
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
renderBridgeConnectionDetails(bridgesData);
|
||||||
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
setBridgeWsStatus(bridgeStatusLine(bridgesData));
|
||||||
`;
|
renderBridgeProfiles(bridgesData.bridges || [], bridgesData);
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWifiInterfaces(selectedDevice) {
|
||||||
|
if (!bridgeWifiInterfaceSelect) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/settings/wifi/interfaces');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Wi‑Fi interfaces unavailable', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = selectedDevice || bridgeWifiInterfaceSelect.value;
|
||||||
|
bridgeWifiInterfaceSelect.innerHTML = '<option value="">— select adapter —</option>';
|
||||||
|
for (const iface of data.interfaces || []) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = iface.device;
|
||||||
|
const bits = [iface.device];
|
||||||
|
if (iface.label && iface.label !== iface.device) bits.push(iface.label);
|
||||||
|
if (iface.state) bits.push(`(${iface.state})`);
|
||||||
|
opt.textContent = bits.join(' — ');
|
||||||
|
bridgeWifiInterfaceSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (current) bridgeWifiInterfaceSelect.value = current;
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanBridgeWifi() {
|
||||||
|
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||||
|
if (!device) {
|
||||||
|
setBridgeWsStatus('Select a Wi‑Fi adapter first', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus('Scanning…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/settings/wifi/scan?device=${encodeURIComponent(device)}`
|
||||||
|
);
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Scan failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bridgeWifiSsidSelect) return;
|
||||||
|
const prev = resolvedBridgeSsid();
|
||||||
|
bridgeWifiSsidSelect.innerHTML = '<option value="">— select network —</option>';
|
||||||
|
for (const net of data.networks || []) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = net.ssid;
|
||||||
|
opt.textContent = `${net.ssid} (${net.signal}%)`;
|
||||||
|
bridgeWifiSsidSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (prev) {
|
||||||
|
bridgeWifiSsidSelect.value = prev;
|
||||||
|
if (!bridgeWifiSsidSelect.value && bridgeWifiSsidManual) {
|
||||||
|
bridgeWifiSsidManual.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBridgeWsStatus(`Found ${(data.networks || []).length} network(s)`);
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSerialPorts(selectedPort) {
|
||||||
|
if (!bridgeSerialPortSelect) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/led-tool/ports');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const current = selectedPort || bridgeSerialPortSelect.value;
|
||||||
|
bridgeSerialPortSelect.innerHTML = '<option value="">— select port —</option>';
|
||||||
|
for (const p of data.ports || []) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = p.device;
|
||||||
|
opt.textContent = p.description ? `${p.device} — ${p.description}` : p.device;
|
||||||
|
bridgeSerialPortSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (current) bridgeSerialPortSelect.value = current;
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileStatusFor(p, data) {
|
||||||
|
const activeId = data.active_bridge_id || '';
|
||||||
|
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
|
||||||
|
if (isActive) {
|
||||||
|
return { text: 'Connected', className: 'settings-bridge-profile-status--connected' };
|
||||||
|
}
|
||||||
|
return { text: 'Not connected', className: 'settings-bridge-profile-status--idle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBridgeProfile(id, label) {
|
||||||
|
const name = label || id;
|
||||||
|
if (!window.confirm(`Delete saved bridge profile “${name}”?`)) return;
|
||||||
|
setBridgeWsStatus('Deleting…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Delete failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus(data.message || 'Profile deleted');
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBridgeProfiles(profiles, bridgesData) {
|
||||||
|
if (!bridgeProfilesList) return;
|
||||||
|
bridgeProfilesList.innerHTML = '';
|
||||||
|
const data = bridgesData || lastBridgeSettings || {};
|
||||||
|
const activeId = data.active_bridge_id || '';
|
||||||
|
if (!profiles.length) {
|
||||||
|
bridgeProfilesList.innerHTML = '<li>No saved bridge profiles.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const p of profiles) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
|
||||||
|
li.className =
|
||||||
|
'settings-bridge-profile-row' + (isActive ? ' settings-bridge-profile-row--active' : '');
|
||||||
|
const main = document.createElement('div');
|
||||||
|
main.className = 'settings-bridge-profile-main';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'settings-bridge-profile-label';
|
||||||
|
if (p.transport === 'wifi') {
|
||||||
|
label.textContent = `${p.label} — Wi‑Fi ${p.ssid}`;
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = `
|
label.textContent = `${p.label} — USB ${p.serial_port}`;
|
||||||
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
|
||||||
<p>Access Point is not currently active</p>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
const status = document.createElement('span');
|
||||||
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
const st = profileStatusFor(p, data);
|
||||||
} catch (error) {
|
status.className = 'settings-bridge-profile-status ' + st.className;
|
||||||
console.error('Error loading AP status:', error);
|
status.textContent = st.text;
|
||||||
|
main.appendChild(label);
|
||||||
|
main.appendChild(status);
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'settings-bridge-profile-actions';
|
||||||
|
const connectBtn = document.createElement('button');
|
||||||
|
connectBtn.type = 'button';
|
||||||
|
connectBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
connectBtn.textContent = 'Connect';
|
||||||
|
connectBtn.addEventListener('click', () => connectSavedBridge(p.id));
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.className = 'btn btn-secondary btn-small settings-bridge-profile-delete';
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', () => deleteBridgeProfile(p.id, p.label));
|
||||||
|
actions.appendChild(connectBtn);
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
li.appendChild(main);
|
||||||
|
li.appendChild(actions);
|
||||||
|
bridgeProfilesList.appendChild(li);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectSavedBridge(id) {
|
||||||
|
setBridgeWsStatus('Connecting…');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}/connect`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBridgeWifi(saveProfile) {
|
||||||
|
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||||
|
const ssid = resolvedBridgeSsid();
|
||||||
|
const password = bridgeWifiPassword?.value || '';
|
||||||
|
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
|
||||||
|
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
|
||||||
|
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
|
||||||
|
if (!device) {
|
||||||
|
setBridgeWsStatus('Select a Wi‑Fi adapter', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ssid) {
|
||||||
|
setBridgeWsStatus('Enter or select a bridge SSID', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus('Connecting…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/settings/wifi/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
device,
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
ap_ip: apIp,
|
||||||
|
ws_port: wsPort,
|
||||||
|
label,
|
||||||
|
save_profile: saveProfile,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectBridgeSerial(saveProfile) {
|
||||||
|
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
|
||||||
|
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
|
||||||
|
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
|
||||||
|
if (!port) {
|
||||||
|
setBridgeWsStatus('Select a USB serial port', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus('Connecting…');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/settings/wifi/serial/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ port, baudrate: baud, label, save_profile: saveProfile }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeSerialRefreshBtn) {
|
||||||
|
bridgeSerialRefreshBtn.addEventListener('click', () => loadSerialPorts());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeSerialConnectBtn) {
|
||||||
|
bridgeSerialConnectBtn.addEventListener('click', () => connectBridgeSerial(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeWifiRefreshInterfacesBtn) {
|
||||||
|
bridgeWifiRefreshInterfacesBtn.addEventListener('click', () => loadWifiInterfaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeWifiScanBtn) {
|
||||||
|
bridgeWifiScanBtn.addEventListener('click', () => scanBridgeWifi());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeWifiConnectBtn) {
|
||||||
|
bridgeWifiConnectBtn.addEventListener('click', () => connectBridgeWifi(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeWifiSaveProfileBtn) {
|
||||||
|
bridgeWifiSaveProfileBtn.addEventListener('click', async () => {
|
||||||
|
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||||
|
const ssid = resolvedBridgeSsid();
|
||||||
|
if (!ssid) {
|
||||||
|
setBridgeWsStatus('SSID required to save profile', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const password = bridgeWifiPassword?.value || '';
|
||||||
|
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
|
||||||
|
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
|
||||||
|
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/settings/wifi/bridges');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
|
||||||
|
bridges.push({
|
||||||
|
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
|
||||||
|
label,
|
||||||
|
transport: 'wifi',
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
ap_ip: apIp,
|
||||||
|
ws_port: wsPort,
|
||||||
|
});
|
||||||
|
const putRes = await fetch('/settings/wifi/bridges', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ bridges, wifi_interface: device || data.wifi_interface }),
|
||||||
|
});
|
||||||
|
const putData = await putRes.json().catch(() => ({}));
|
||||||
|
if (!putRes.ok || !putData.ok) {
|
||||||
|
setBridgeWsStatus(putData.error || 'Save failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus('Wi‑Fi profile saved');
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bridgeSerialSaveProfileBtn) {
|
||||||
|
bridgeSerialSaveProfileBtn.addEventListener('click', async () => {
|
||||||
|
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
|
||||||
|
if (!port) {
|
||||||
|
setBridgeWsStatus('Port required to save profile', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
|
||||||
|
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/settings/wifi/bridges');
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
|
||||||
|
bridges.push({
|
||||||
|
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
|
||||||
|
label,
|
||||||
|
transport: 'serial',
|
||||||
|
serial_port: port,
|
||||||
|
serial_baudrate: baud,
|
||||||
|
});
|
||||||
|
const putRes = await fetch('/settings/wifi/bridges', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({ bridges }),
|
||||||
|
});
|
||||||
|
const putData = await putRes.json().catch(() => ({}));
|
||||||
|
if (!putRes.ok || !putData.ok) {
|
||||||
|
setBridgeWsStatus(putData.error || 'Save failed', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBridgeWsStatus('Serial profile saved');
|
||||||
|
await loadBridgeSettings();
|
||||||
|
} catch (err) {
|
||||||
|
setBridgeWsStatus(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (settingsButton && settingsModal) {
|
if (settingsButton && settingsModal) {
|
||||||
settingsButton.addEventListener('click', () => {
|
settingsButton.addEventListener('click', () => {
|
||||||
|
switchSettingsTab('bridge');
|
||||||
settingsModal.classList.add('active');
|
settingsModal.classList.add('active');
|
||||||
// Load current WiFi status/config when opening
|
loadBridgeSettings();
|
||||||
loadDeviceSettings();
|
|
||||||
loadAPStatus();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsCloseButton && settingsModal) {
|
if (settingsCloseButton && settingsModal) {
|
||||||
settingsCloseButton.addEventListener('click', () => {
|
settingsCloseButton.addEventListener('click', () => {
|
||||||
settingsModal.classList.remove('active');
|
settingsModal.classList.remove('active');
|
||||||
});
|
settingsModal.classList.remove('settings-modal--led-tool');
|
||||||
}
|
unloadLedToolIframe();
|
||||||
|
|
||||||
const deviceForm = document.getElementById('device-form');
|
|
||||||
if (deviceForm) {
|
|
||||||
deviceForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const nameInput = document.getElementById('device-name-input');
|
|
||||||
const deviceName = nameInput ? nameInput.value.trim() : '';
|
|
||||||
if (!deviceName) {
|
|
||||||
showSettingsMessage('Device name is required', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chRaw = document.getElementById('wifi-channel-input')
|
|
||||||
? document.getElementById('wifi-channel-input').value
|
|
||||||
: '6';
|
|
||||||
const wifiChannel = parseInt(chRaw, 10);
|
|
||||||
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
|
||||||
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/settings', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
device_name: deviceName,
|
|
||||||
wifi_channel: wifiChannel,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
showSettingsMessage(
|
|
||||||
'Device settings saved. They will apply on next restart where relevant.',
|
|
||||||
'success',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const apForm = document.getElementById('ap-form');
|
|
||||||
if (apForm) {
|
|
||||||
apForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = {
|
|
||||||
ssid: document.getElementById('ap-ssid').value,
|
|
||||||
password: document.getElementById('ap-password').value,
|
|
||||||
channel: document.getElementById('ap-channel').value || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
|
||||||
showSettingsMessage('AP password must be at least 8 characters', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.channel) {
|
|
||||||
formData.channel = parseInt(formData.channel, 10);
|
|
||||||
if (formData.channel < 1 || formData.channel > 11) {
|
|
||||||
showSettingsMessage('Channel must be between 1 and 11', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/settings/wifi/ap', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
if (response.ok) {
|
|
||||||
showSettingsMessage('Access Point configured successfully!', 'success');
|
|
||||||
setTimeout(loadAPStatus, 1000);
|
|
||||||
} else {
|
|
||||||
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showSettingsMessage(`Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,255 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const openBtn = document.getElementById('led-tool-btn');
|
|
||||||
const modal = document.getElementById('led-tool-modal');
|
|
||||||
const closeBtn = document.getElementById('led-tool-close-btn');
|
|
||||||
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
|
|
||||||
const form = document.getElementById('led-tool-form');
|
|
||||||
const readBtn = document.getElementById('led-tool-read-btn');
|
|
||||||
const resetBtn = document.getElementById('led-tool-reset-btn');
|
|
||||||
const portSelect = document.getElementById('led-tool-port');
|
|
||||||
const outputEl = document.getElementById('led-tool-output');
|
|
||||||
const messageEl = document.getElementById('led-tool-message');
|
|
||||||
|
|
||||||
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const showMessage = (text, type = 'success') => {
|
|
||||||
messageEl.textContent = text;
|
|
||||||
messageEl.className = `message ${type} show`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setOutput = (text) => {
|
|
||||||
outputEl.value = text || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseApiResponse = async (response) => {
|
|
||||||
const bodyText = await response.text();
|
|
||||||
let data = null;
|
|
||||||
try {
|
|
||||||
data = bodyText ? JSON.parse(bodyText) : {};
|
|
||||||
} catch (error) {
|
|
||||||
data = { error: bodyText || `HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setFieldValue = (id, value) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
if (value === undefined || value === null) return;
|
|
||||||
el.value = String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const populateFormFromSettings = (settings) => {
|
|
||||||
if (!settings || typeof settings !== 'object') return false;
|
|
||||||
setFieldValue('led-tool-name', settings.name);
|
|
||||||
setFieldValue('led-tool-num-leds', settings.num_leds);
|
|
||||||
setFieldValue('led-tool-led-pin', settings.led_pin);
|
|
||||||
setFieldValue('led-tool-brightness', settings.brightness);
|
|
||||||
setFieldValue('led-tool-transport', settings.transport_type);
|
|
||||||
setFieldValue('led-tool-ssid', settings.ssid);
|
|
||||||
setFieldValue('led-tool-password', settings.password);
|
|
||||||
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
|
|
||||||
setFieldValue('led-tool-default', settings.default);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadPorts = async () => {
|
|
||||||
const defaultPort = '/dev/ttyACM0';
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/ports');
|
|
||||||
const data = await response.json();
|
|
||||||
const previous = portSelect.value;
|
|
||||||
portSelect.innerHTML = '<option value="">Select a serial port</option>';
|
|
||||||
|
|
||||||
for (const port of data.ports || []) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = port.device;
|
|
||||||
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
|
|
||||||
portSelect.appendChild(option);
|
|
||||||
}
|
|
||||||
if (previous) {
|
|
||||||
portSelect.value = previous;
|
|
||||||
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
} else {
|
|
||||||
const fallback = document.createElement('option');
|
|
||||||
fallback.value = defaultPort;
|
|
||||||
fallback.textContent = `${defaultPort} - default`;
|
|
||||||
portSelect.appendChild(fallback);
|
|
||||||
portSelect.value = defaultPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.led_cli_exists) {
|
|
||||||
showMessage('led-tool/cli.py was not found on the host.', 'error');
|
|
||||||
} else if ((data.ports || []).length === 0) {
|
|
||||||
showMessage('No serial ports found.', 'error');
|
|
||||||
} else {
|
|
||||||
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
openBtn.addEventListener('click', () => {
|
|
||||||
modal.classList.add('active');
|
|
||||||
loadPorts();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (closeBtn) {
|
|
||||||
closeBtn.addEventListener('click', () => {
|
|
||||||
modal.classList.remove('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshPortsBtn) {
|
|
||||||
refreshPortsBtn.addEventListener('click', () => {
|
|
||||||
loadPorts();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (readBtn) {
|
|
||||||
readBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Reading settings from device...');
|
|
||||||
showMessage('Reading settings over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Read failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
const populated = populateFormFromSettings(data.settings);
|
|
||||||
if (populated) {
|
|
||||||
showMessage('Settings read and fields populated.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Settings read successfully.', 'success');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showMessage('Read completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resetBtn) {
|
|
||||||
resetBtn.addEventListener('click', async () => {
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOutput('Resetting device and following output...');
|
|
||||||
showMessage('Resetting device over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/reset', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ port }),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Reset failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Device reset complete.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Reset completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const port = portSelect.value.trim();
|
|
||||||
if (!port) {
|
|
||||||
showMessage('Select a serial port first.', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
port,
|
|
||||||
name: document.getElementById('led-tool-name')?.value?.trim() || '',
|
|
||||||
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
|
|
||||||
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
|
|
||||||
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
|
|
||||||
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
|
|
||||||
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
|
|
||||||
password: document.getElementById('led-tool-password')?.value?.trim() || '',
|
|
||||||
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
|
|
||||||
default: document.getElementById('led-tool-default')?.value?.trim() || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
setOutput('Running led-tool command...');
|
|
||||||
showMessage('Running command over USB...', 'success');
|
|
||||||
try {
|
|
||||||
const response = await fetch('/led-tool/settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await parseApiResponse(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
showMessage(data.error || 'Command failed.', 'error');
|
|
||||||
setOutput(data.error || 'Request failed.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const output = [
|
|
||||||
`exit code: ${data.returncode}`,
|
|
||||||
'',
|
|
||||||
'stdout:',
|
|
||||||
data.stdout || '(none)',
|
|
||||||
'',
|
|
||||||
'stderr:',
|
|
||||||
data.stderr || '(none)',
|
|
||||||
].join('\n');
|
|
||||||
setOutput(output);
|
|
||||||
if (data.ok) {
|
|
||||||
showMessage('Settings applied via USB.', 'success');
|
|
||||||
} else {
|
|
||||||
showMessage('Command completed with errors. Check output.', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(`Request failed: ${error.message}`, 'error');
|
|
||||||
setOutput(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
117
src/static/numpad.js
Normal file
117
src/static/numpad.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Bluetooth / USB HID numpad shortcuts (browser focus required).
|
||||||
|
*
|
||||||
|
* Numpad1–9,0 → zone 1–10 (visible zone list order)
|
||||||
|
* NumpadEnter → sequence beat sync (step), same as S
|
||||||
|
* NumpadDecimal → sequence beat sync (pass), same as Shift+S
|
||||||
|
* NumpadMultiply → reset audio detector
|
||||||
|
* NumpadAdd → brightness +16
|
||||||
|
* NumpadSubtract → brightness −16
|
||||||
|
* NumpadDivide → stop zone sequence playback
|
||||||
|
*/
|
||||||
|
(() => {
|
||||||
|
const BRIGHTNESS_STEP = 16;
|
||||||
|
|
||||||
|
function isTypingTarget(target) {
|
||||||
|
if (!target || typeof target !== "object") return false;
|
||||||
|
const tag = String(target.tagName || "").toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoneIdsInListOrder() {
|
||||||
|
return [...document.querySelectorAll("#zones-list .zone-button[data-zone-id]")]
|
||||||
|
.map((el) => el.getAttribute("data-zone-id"))
|
||||||
|
.filter((id) => id != null && id !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectZoneByListIndex(oneBased) {
|
||||||
|
const order = zoneIdsInListOrder();
|
||||||
|
if (oneBased < 1 || oneBased > order.length) return;
|
||||||
|
const zoneId = order[oneBased - 1];
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.selectZone === "function") {
|
||||||
|
await window.tabsManager.selectZone(zoneId);
|
||||||
|
} else if (typeof selectZone === "function") {
|
||||||
|
await selectZone(zoneId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSequenceBeatPhase(mode) {
|
||||||
|
const res = await fetch("/sequences/sync-phase", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ mode: mode || "step" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetAudioTracking() {
|
||||||
|
const res = await fetch("/api/audio/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `Reset failed (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustZoneBrightness(delta) {
|
||||||
|
const zoneId =
|
||||||
|
(window.tabsManager && typeof window.tabsManager.getCurrentTabId === "function"
|
||||||
|
? window.tabsManager.getCurrentTabId()
|
||||||
|
: null) ||
|
||||||
|
(window.tabsManager && typeof window.tabsManager.getCurrentZoneId === "function"
|
||||||
|
? window.tabsManager.getCurrentZoneId()
|
||||||
|
: null);
|
||||||
|
if (!zoneId) return;
|
||||||
|
const slider =
|
||||||
|
document.getElementById("header-brightness-slider") ||
|
||||||
|
document.getElementById("menu-brightness-slider");
|
||||||
|
if (!slider) return;
|
||||||
|
const cur = parseInt(slider.value, 10);
|
||||||
|
const base = Number.isFinite(cur) ? cur : 127;
|
||||||
|
const next = Math.max(0, Math.min(255, base + delta));
|
||||||
|
if (String(slider.value) === String(next)) return;
|
||||||
|
slider.value = String(next);
|
||||||
|
slider.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSequencePlayback() {
|
||||||
|
if (typeof window.stopZoneSequencePlayback === "function") {
|
||||||
|
await window.stopZoneSequencePlayback(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Record<string, () => void | Promise<void>>} */
|
||||||
|
const actions = {
|
||||||
|
NumpadEnter: () => syncSequenceBeatPhase("step"),
|
||||||
|
NumpadDecimal: () => syncSequenceBeatPhase("pass"),
|
||||||
|
NumpadMultiply: () => resetAudioTracking(),
|
||||||
|
NumpadAdd: () => adjustZoneBrightness(BRIGHTNESS_STEP),
|
||||||
|
NumpadSubtract: () => adjustZoneBrightness(-BRIGHTNESS_STEP),
|
||||||
|
NumpadDivide: () => stopSequencePlayback(),
|
||||||
|
Numpad1: () => selectZoneByListIndex(1),
|
||||||
|
Numpad2: () => selectZoneByListIndex(2),
|
||||||
|
Numpad3: () => selectZoneByListIndex(3),
|
||||||
|
Numpad4: () => selectZoneByListIndex(4),
|
||||||
|
Numpad5: () => selectZoneByListIndex(5),
|
||||||
|
Numpad6: () => selectZoneByListIndex(6),
|
||||||
|
Numpad7: () => selectZoneByListIndex(7),
|
||||||
|
Numpad8: () => selectZoneByListIndex(8),
|
||||||
|
Numpad9: () => selectZoneByListIndex(9),
|
||||||
|
Numpad0: () => selectZoneByListIndex(10),
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (ev) => {
|
||||||
|
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
|
||||||
|
const code = ev.code;
|
||||||
|
if (!code || !code.startsWith("Numpad")) return;
|
||||||
|
const action = actions[code];
|
||||||
|
if (!action) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
Promise.resolve(action()).catch((e) => console.warn("numpad shortcut failed:", 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' } });
|
||||||
@@ -71,24 +98,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
: [];
|
: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
|
const postDriverSequence = (sequence, targetMacs, delayS, pushOptions) =>
|
||||||
const body = {
|
window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
|
||||||
sequence,
|
|
||||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
|
||||||
delay_s: delayS,
|
|
||||||
};
|
|
||||||
const res = await fetch('/presets/push', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}));
|
|
||||||
throw new Error((err && err.error) || res.statusText || 'Send failed');
|
|
||||||
}
|
|
||||||
return res.json().catch(() => ({}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const nReadableStringFromMeta = (meta, key) => {
|
const nReadableStringFromMeta = (meta, key) => {
|
||||||
if (!meta || typeof meta !== 'object') {
|
if (!meta || typeof meta !== 'object') {
|
||||||
@@ -531,6 +542,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const colors = Array.isArray(preset.colors) && preset.colors.length
|
const colors = Array.isArray(preset.colors) && preset.colors.length
|
||||||
? preset.colors
|
? preset.colors
|
||||||
: ['#FFFFFF'];
|
: ['#FFFFFF'];
|
||||||
|
const presetAuto = coercePresetAuto(preset);
|
||||||
wirePresets[presetId] = {
|
wirePresets[presetId] = {
|
||||||
pattern: preset.pattern || 'off',
|
pattern: preset.pattern || 'off',
|
||||||
colors,
|
colors,
|
||||||
@@ -538,13 +550,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
brightness: typeof preset.brightness === 'number'
|
brightness: typeof preset.brightness === 'number'
|
||||||
? preset.brightness
|
? preset.brightness
|
||||||
: (typeof preset.br === 'number' ? preset.br : 127),
|
: (typeof preset.br === 'number' ? preset.br : 127),
|
||||||
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
|
auto: presetAuto,
|
||||||
|
a: presetAuto,
|
||||||
n1: coercePresetInt(preset.n1),
|
n1: coercePresetInt(preset.n1),
|
||||||
n2: coercePresetInt(preset.n2),
|
n2: coercePresetInt(preset.n2),
|
||||||
n3: coercePresetInt(preset.n3),
|
n3: coercePresetInt(preset.n3),
|
||||||
n4: coercePresetInt(preset.n4),
|
n4: coercePresetInt(preset.n4),
|
||||||
n5: coercePresetInt(preset.n5),
|
n5: coercePresetInt(preset.n5),
|
||||||
n6: coercePresetInt(preset.n6),
|
n6: (() => {
|
||||||
|
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
|
||||||
|
return coercePresetInt(preset.mode);
|
||||||
|
}
|
||||||
|
return coercePresetInt(preset.n6);
|
||||||
|
})(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!Object.keys(wirePresets).length) {
|
if (!Object.keys(wirePresets).length) {
|
||||||
@@ -552,26 +570,28 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const select = {};
|
const groupIds =
|
||||||
deviceNames.forEach((name) => {
|
typeof window.zonesManager !== 'undefined' &&
|
||||||
if (name) {
|
typeof window.zonesManager.effectiveGroupIdsForZonePreset === 'function'
|
||||||
select[name] = zonePresetIds.slice();
|
? window.zonesManager.effectiveGroupIdsForZonePreset(zoneData)
|
||||||
}
|
: Array.isArray(zoneData.group_ids)
|
||||||
});
|
? zoneData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||||
const targetMacs =
|
|
||||||
typeof window.tabsManager !== 'undefined' &&
|
|
||||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
|
||||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const sequence = [
|
const sequence = [
|
||||||
{ v: '1', clear_presets: true, save: true },
|
{ v: '1', clear_presets: true, save: true },
|
||||||
{ v: '1', presets: wirePresets, save: true },
|
{ v: '1', presets: wirePresets, save: true },
|
||||||
];
|
];
|
||||||
if (Object.keys(select).length) {
|
if (groupIds.length) {
|
||||||
sequence.push({ v: '1', select });
|
sequence[0].groups = groupIds;
|
||||||
|
sequence[1].groups = groupIds;
|
||||||
}
|
}
|
||||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
if (deviceNames.length > 0 && zonePresetIds.length > 0) {
|
||||||
|
const sel = { v: '1', select: zonePresetIds.slice() };
|
||||||
|
if (groupIds.length) sel.groups = groupIds;
|
||||||
|
sequence.push(sel);
|
||||||
|
}
|
||||||
|
await postDriverSequence(sequence, [], 0.05, { groupIds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Send all patterns failed:', error);
|
console.error('Send all patterns failed:', error);
|
||||||
alert('Failed to send all patterns.');
|
alert('Failed to send all patterns.');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
const importProfileButton = document.getElementById("import-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
return;
|
return;
|
||||||
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportButton = document.createElement("button");
|
||||||
|
exportButton.className = "btn btn-secondary btn-small";
|
||||||
|
exportButton.textContent = "Export";
|
||||||
|
exportButton.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/export`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Export failed");
|
||||||
|
}
|
||||||
|
const bundle = await response.json();
|
||||||
|
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
|
||||||
|
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Export profile failed:", error);
|
||||||
|
alert("Failed to export profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const cloneButton = document.createElement("button");
|
const cloneButton = document.createElement("button");
|
||||||
cloneButton.className = "btn btn-secondary btn-small";
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
cloneButton.textContent = "Clone";
|
cloneButton.textContent = "Clone";
|
||||||
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
|
row.appendChild(exportButton);
|
||||||
row.appendChild(cloneButton);
|
row.appendChild(cloneButton);
|
||||||
row.appendChild(deleteButton);
|
row.appendChild(deleteButton);
|
||||||
}
|
}
|
||||||
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (createProfileButton) {
|
if (createProfileButton) {
|
||||||
createProfileButton.addEventListener("click", createProfile);
|
createProfileButton.addEventListener("click", createProfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importProfile = async () => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await window.pickJsonFile();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bundle = window.parseJsonFileText(text);
|
||||||
|
if (!bundle || typeof bundle !== "object") {
|
||||||
|
alert("Invalid JSON file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const defaultName =
|
||||||
|
(bundle.profile && bundle.profile.name) || "Imported profile";
|
||||||
|
const name = prompt("Profile name for import:", defaultName);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch("/profiles/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Import failed");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
await refreshTabsForActiveProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Import profile failed:", error);
|
||||||
|
alert(error.message || "Failed to import profile.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (importProfileButton) {
|
||||||
|
importProfileButton.addEventListener("click", importProfile);
|
||||||
|
}
|
||||||
if (newProfileInput) {
|
if (newProfileInput) {
|
||||||
newProfileInput.addEventListener("keypress", (event) => {
|
newProfileInput.addEventListener("keypress", (event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
|
|||||||
1291
src/static/sequences.js
Normal file
1291
src/static/sequences.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
27
src/static/zone-devices-panel.js
Normal file
27
src/static/zone-devices-panel.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Scrollable device/group list with a fixed add row (dropdown + button) below.
|
||||||
|
* Used by group and zone edit modals.
|
||||||
|
*/
|
||||||
|
function prepareZoneDevicesPanel(containerEl) {
|
||||||
|
if (!containerEl) return null;
|
||||||
|
let listEl = containerEl.querySelector('.zone-devices-list');
|
||||||
|
let addSlot = containerEl.querySelector('.zone-devices-add-slot');
|
||||||
|
if (!listEl) {
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
containerEl.classList.add('zone-devices-panel');
|
||||||
|
listEl = document.createElement('div');
|
||||||
|
listEl.className = 'zone-devices-list';
|
||||||
|
addSlot = document.createElement('div');
|
||||||
|
addSlot.className = 'zone-devices-add-slot';
|
||||||
|
containerEl.appendChild(listEl);
|
||||||
|
containerEl.appendChild(addSlot);
|
||||||
|
} else {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
addSlot.innerHTML = '';
|
||||||
|
}
|
||||||
|
return { listEl, addSlot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
|
||||||
|
}
|
||||||
@@ -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,10 +70,52 @@ 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,
|
||||||
|
{ unicast: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
// Fallback to raw websocket sender if presets.js helper isn't available yet.
|
// Fallback to raw websocket bridge helper if presets.js helper isn't available yet.
|
||||||
if (typeof window.sendEspnowRaw === 'function') {
|
if (typeof window.sendEspnowRaw === 'function') {
|
||||||
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||||
}
|
}
|
||||||
@@ -107,8 +155,236 @@ 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() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registry MACs for preset push (same targeting as ``resolveDeviceNamesForZonePreset``). */
|
||||||
|
async function resolveMacsForZonePreset(zoneDoc, presetId) {
|
||||||
|
void presetId;
|
||||||
|
const gids = effectiveGroupIdsForZonePreset(zoneDoc);
|
||||||
|
if (gids.length) {
|
||||||
|
const t = await resolveTargetsFromGroupIds(gids);
|
||||||
|
if (t.macs.length) return [...new Set(t.macs)];
|
||||||
|
}
|
||||||
|
const zt = await computeZoneTargets(zoneDoc);
|
||||||
|
return Array.isArray(zt.macs) ? [...new Set(zt.macs.filter(Boolean))] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. Only devices in checked lane groups (within the zone tab).
|
||||||
|
*/
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
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 +412,17 @@ 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;
|
||||||
|
const panel =
|
||||||
|
typeof window.prepareZoneDevicesPanel === "function"
|
||||||
|
? window.prepareZoneDevicesPanel(containerEl)
|
||||||
|
: null;
|
||||||
|
const listEl = panel ? panel.listEl : containerEl;
|
||||||
|
if (!panel) {
|
||||||
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 +430,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,51 +444,44 @@ 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);
|
listEl.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);
|
||||||
|
if (panel) {
|
||||||
|
panel.addSlot.appendChild(addWrap);
|
||||||
|
} else {
|
||||||
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). */
|
||||||
@@ -237,6 +513,55 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
|
||||||
|
function effectiveZoneContentKind(zoneDoc) {
|
||||||
|
const explicit = normalizeZoneContentKind(zoneDoc);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
|
||||||
|
? zoneDoc.sequence_ids.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
|
||||||
|
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
|
||||||
|
return 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||||
|
void zoneId;
|
||||||
|
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||||
|
void zoneId;
|
||||||
|
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoneContentKindEditModal(kind) {
|
||||||
|
const presetsBlock = document.getElementById('edit-zone-block-presets');
|
||||||
|
const groupsBlock = document.getElementById('edit-zone-block-groups');
|
||||||
|
const seqBlock = document.getElementById('edit-zone-block-sequences');
|
||||||
|
const vis = (el, show) => {
|
||||||
|
if (el) el.style.display = show ? '' : 'none';
|
||||||
|
};
|
||||||
|
const k = kind === 'sequences' ? 'sequences' : 'presets';
|
||||||
|
vis(groupsBlock, true);
|
||||||
|
vis(presetsBlock, k === 'presets');
|
||||||
|
vis(seqBlock, k === 'sequences');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.normalizeZoneContentKind = normalizeZoneContentKind;
|
||||||
|
window.effectiveZoneContentKind = effectiveZoneContentKind;
|
||||||
|
window.zoneAllowsPresets = zoneAllowsPresets;
|
||||||
|
window.zoneAllowsSequences = zoneAllowsSequences;
|
||||||
|
|
||||||
// Load tabs list
|
// Load tabs list
|
||||||
async function loadZones() {
|
async function loadZones() {
|
||||||
try {
|
try {
|
||||||
@@ -293,14 +618,14 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
|
|||||||
for (const zoneId of tabOrder) {
|
for (const zoneId of tabOrder) {
|
||||||
const zone = tabs[zoneId];
|
const zone = tabs[zoneId];
|
||||||
if (zone) {
|
if (zone) {
|
||||||
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
|
||||||
const tabName = zone.name || `Zone ${zoneId}`;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
html += `
|
html += `
|
||||||
<button class="zone-button ${activeClass}"
|
<button class="zone-button ${activeClass}"
|
||||||
data-zone-id="${zoneId}"
|
data-zone-id="${zoneId}"
|
||||||
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||||
onclick="selectZone('${zoneId}')">
|
onclick="selectZone('${zoneId}')">
|
||||||
${tabName}
|
${escapeHtmlAttr(disp)}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -340,9 +665,10 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
|||||||
row.dataset.zoneId = String(zoneId);
|
row.dataset.zoneId = String(zoneId);
|
||||||
|
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.textContent = (zone && zone.name) || zoneId;
|
const disp = zone.name || `Zone ${zoneId}`;
|
||||||
|
label.textContent = disp;
|
||||||
if (String(zoneId) === String(currentZoneId)) {
|
if (String(zoneId) === String(currentZoneId)) {
|
||||||
label.textContent = `✓ ${label.textContent}`;
|
label.textContent = `✓ ${disp}`;
|
||||||
label.style.fontWeight = "bold";
|
label.style.fontWeight = "bold";
|
||||||
label.style.color = "#FFD700";
|
label.style.color = "#FFD700";
|
||||||
}
|
}
|
||||||
@@ -539,12 +865,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 +890,14 @@ async function loadZoneContent(zoneId) {
|
|||||||
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
|
||||||
: 255;
|
: 255;
|
||||||
applyBrightnessSliders(normalizedBrightness);
|
applyBrightnessSliders(normalizedBrightness);
|
||||||
// Apply this zone's saved brightness when switching zones.
|
const initialHydration = isFirstZoneContentHydration;
|
||||||
|
if (isFirstZoneContentHydration) {
|
||||||
|
isFirstZoneContentHydration = false;
|
||||||
|
}
|
||||||
|
if (!suppressZoneContentDriverSideEffects && !initialHydration) {
|
||||||
|
// Apply this zone's saved brightness when switching zones (not initial page load or UI-only strip refresh).
|
||||||
sendZoneBrightness(zoneId, normalizedBrightness);
|
sendZoneBrightness(zoneId, normalizedBrightness);
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger presets loading if the function exists
|
// Trigger presets loading if the function exists
|
||||||
if (typeof renderTabPresets === 'function') {
|
if (typeof renderTabPresets === 'function') {
|
||||||
@@ -573,127 +909,8 @@ async function loadZoneContent(zoneId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send all presets used by all tabs in the current profile via /presets/send.
|
|
||||||
async function sendProfilePresets() {
|
|
||||||
try {
|
|
||||||
// Load current profile to get its tabs
|
|
||||||
const profileRes = await fetch('/profiles/current', {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!profileRes.ok) {
|
|
||||||
alert('Failed to load current profile.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const profileData = await profileRes.json();
|
|
||||||
const profile = profileData.profile || {};
|
|
||||||
let zoneList = null;
|
|
||||||
if (Array.isArray(profile.zones)) {
|
|
||||||
zoneList = profile.zones;
|
|
||||||
} else if (profile.zones) {
|
|
||||||
zoneList = [profile.zones];
|
|
||||||
}
|
|
||||||
if (!zoneList || zoneList.length === 0) {
|
|
||||||
if (Array.isArray(profile.zones)) {
|
|
||||||
zoneList = profile.zones;
|
|
||||||
} else if (profile.zones) {
|
|
||||||
zoneList = [profile.zones];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!zoneList || zoneList.length === 0) {
|
|
||||||
console.warn('sendProfilePresets: no zones found', {
|
|
||||||
profileData,
|
|
||||||
profile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zoneList.length) {
|
|
||||||
alert('Current profile has no zones to send presets for.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalSent = 0;
|
|
||||||
let totalMessages = 0;
|
|
||||||
let zonesWithPresets = 0;
|
|
||||||
|
|
||||||
for (const zoneId of zoneList) {
|
|
||||||
try {
|
|
||||||
const tabResp = await fetch(`/zones/${zoneId}`, {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
});
|
|
||||||
if (!tabResp.ok) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const tabData = await tabResp.json();
|
|
||||||
let presetIds = [];
|
|
||||||
if (Array.isArray(tabData.presets_flat)) {
|
|
||||||
presetIds = tabData.presets_flat;
|
|
||||||
} else if (Array.isArray(tabData.presets)) {
|
|
||||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
|
||||||
presetIds = tabData.presets;
|
|
||||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
|
||||||
presetIds = tabData.presets.flat();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
presetIds = (presetIds || []).filter(Boolean);
|
|
||||||
if (!presetIds.length) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
zonesWithPresets += 1;
|
|
||||||
const zoneNames = Array.isArray(tabData.names) ? tabData.names : [];
|
|
||||||
const targets = await resolveZoneDeviceMacs(zoneNames);
|
|
||||||
const payload = { preset_ids: presetIds };
|
|
||||||
if (tabData.default_preset) {
|
|
||||||
payload.default = tabData.default_preset;
|
|
||||||
}
|
|
||||||
if (targets.length > 0) {
|
|
||||||
payload.targets = targets;
|
|
||||||
}
|
|
||||||
const response = await fetch('/presets/send', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok) {
|
|
||||||
const msg = (data && data.error) || `Failed to send presets for zone ${zoneId}.`;
|
|
||||||
console.warn(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
|
||||||
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to send profile presets for zone:', zoneId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zonesWithPresets) {
|
|
||||||
alert('No presets to send for the current profile.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesLabel = totalMessages ? totalMessages : '?';
|
|
||||||
alert(`Sent ${totalSent} preset(s) across ${zonesWithPresets} zone(s) (${messagesLabel} driver send(s)).`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to send profile presets:', error);
|
|
||||||
alert('Failed to send profile presets.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +931,12 @@ async function refreshEditTabPresetsUi(zoneId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tabData = await tabRes.json();
|
const tabData = await tabRes.json();
|
||||||
|
if (!zoneAllowsPresets(tabData, zoneId)) {
|
||||||
|
currentEl.innerHTML =
|
||||||
|
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||||
|
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const inTabIds = tabPresetIdsInOrder(tabData);
|
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||||
|
|
||||||
@@ -737,8 +960,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 +977,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 +1042,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,39 +1059,67 @@ async function openEditZoneModal(zoneId, zone) {
|
|||||||
if (idInput) idInput.value = zoneId;
|
if (idInput) idInput.value = zoneId;
|
||||||
if (nameInput) nameInput.value = tabData.name || "";
|
if (nameInput) nameInput.value = tabData.name || "";
|
||||||
|
|
||||||
const devicesMap = await fetchDevicesMap();
|
const groupsEditor = document.getElementById("edit-zone-groups-editor");
|
||||||
const zoneNames =
|
const groupsMap = await fetchGroupsMap();
|
||||||
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"];
|
const rawGids = Array.isArray(tabData.group_ids) ? tabData.group_ids : [];
|
||||||
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap);
|
window.__editTabGroupRows = rawGids.map((gid) => {
|
||||||
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap);
|
const id = String(gid);
|
||||||
|
const g = groupsMap[id];
|
||||||
|
return { id, name: g && g.name ? String(g.name).trim() : id };
|
||||||
|
});
|
||||||
|
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
|
||||||
|
|
||||||
|
const kind = effectiveZoneContentKind(tabData);
|
||||||
|
const typeLabel = document.getElementById('edit-zone-type-label');
|
||||||
|
if (typeLabel) {
|
||||||
|
typeLabel.textContent =
|
||||||
|
kind === 'sequences'
|
||||||
|
? 'Zone type: Sequences (set when the zone was created)'
|
||||||
|
: 'Zone type: Presets (set when the zone was created)';
|
||||||
|
}
|
||||||
|
|
||||||
if (modal) modal.classList.add("active");
|
if (modal) modal.classList.add("active");
|
||||||
|
applyZoneContentKindEditModal(kind);
|
||||||
await refreshEditTabPresetsUi(zoneId);
|
await refreshEditTabPresetsUi(zoneId);
|
||||||
|
if (typeof window.refreshEditTabSequencesUi === "function") {
|
||||||
|
await window.refreshEditTabSequencesUi(zoneId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTabNamesArg(namesOrString) {
|
// Update an existing zone (name, group list; devices come from groups only).
|
||||||
if (Array.isArray(namesOrString)) {
|
async function updateZone(zoneId, name, groupRows) {
|
||||||
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)
|
||||||
|
: [];
|
||||||
|
let existing = {};
|
||||||
|
try {
|
||||||
|
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (cur.ok) {
|
||||||
|
const j = await cur.json();
|
||||||
|
if (j && typeof j === 'object') existing = j;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* use empty existing */
|
||||||
|
}
|
||||||
|
const lockedKind = effectiveZoneContentKind(existing);
|
||||||
const response = await fetch(`/zones/${zoneId}`, {
|
const response = await fetch(`/zones/${zoneId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
...existing,
|
||||||
name: name,
|
name: name,
|
||||||
names: names
|
names: [],
|
||||||
|
group_ids: gids,
|
||||||
|
preset_group_ids:
|
||||||
|
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||||
|
? existing.preset_group_ids
|
||||||
|
: {},
|
||||||
|
content_kind: lockedKind,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -872,6 +1128,9 @@ async function updateZone(zoneId, name, namesOrString) {
|
|||||||
// Reload tabs list
|
// Reload tabs list
|
||||||
await loadZonesModal();
|
await loadZonesModal();
|
||||||
await loadZones();
|
await loadZones();
|
||||||
|
if (String(currentZoneId) === String(zoneId)) {
|
||||||
|
await loadZoneContent(zoneId);
|
||||||
|
}
|
||||||
// Close modal
|
// Close modal
|
||||||
document.getElementById('edit-zone-modal').classList.remove('active');
|
document.getElementById('edit-zone-modal').classList.remove('active');
|
||||||
return true;
|
return true;
|
||||||
@@ -886,11 +1145,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 +1157,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 +1240,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,28 +1272,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const zoneId = idInput ? idInput.value : null;
|
const zoneId = idInput ? idInput.value : null;
|
||||||
const name = nameInput ? nameInput.value.trim() : "";
|
const name = nameInput ? nameInput.value.trim() : "";
|
||||||
const rows = window.__editTabDeviceRows || [];
|
const 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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile-wide "Send Presets" button in header
|
|
||||||
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
|
||||||
if (sendProfilePresetsBtn) {
|
|
||||||
sendProfilePresetsBtn.addEventListener('click', async () => {
|
|
||||||
await sendProfilePresets();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
||||||
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -1049,14 +1301,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
|
suppressZoneContentDriverSideEffects = true;
|
||||||
|
try {
|
||||||
await loadZones();
|
await loadZones();
|
||||||
if (zonesModal && zonesModal.classList.contains("active")) {
|
if (zonesModal && zonesModal.classList.contains("active")) {
|
||||||
await loadZonesModal();
|
await loadZonesModal();
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
suppressZoneContentDriverSideEffects = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.selectZone = selectZone;
|
||||||
|
|
||||||
// Export for use in other scripts
|
// Export for use in other scripts
|
||||||
window.zonesManager = {
|
window.zonesManager = {
|
||||||
loadZones,
|
loadZones,
|
||||||
@@ -1066,8 +1325,18 @@ window.zonesManager = {
|
|||||||
updateZone,
|
updateZone,
|
||||||
openEditZoneModal,
|
openEditZoneModal,
|
||||||
resolveZoneDeviceMacs,
|
resolveZoneDeviceMacs,
|
||||||
|
resolveZoneDeviceMacsFromZoneData,
|
||||||
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
||||||
getCurrentZoneId: () => currentZoneId,
|
getCurrentZoneId: () => currentZoneId,
|
||||||
|
computeZoneTargets,
|
||||||
|
computeZoneNamesTargets,
|
||||||
|
computeZonePresetUnionTargets,
|
||||||
|
effectiveGroupIdsForZonePreset,
|
||||||
|
resolveDeviceNamesForZonePreset,
|
||||||
|
resolveMacsForZonePreset,
|
||||||
|
resolveSequenceStepDeviceNames,
|
||||||
|
fetchGroupsMap,
|
||||||
|
renderZoneGroupsEditor,
|
||||||
};
|
};
|
||||||
window.tabsManager = window.zonesManager;
|
window.tabsManager = window.zonesManager;
|
||||||
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
||||||
|
|||||||
@@ -9,10 +9,21 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header>
|
<header>
|
||||||
<div class="zones-container">
|
<div class="header-end">
|
||||||
<div id="zones-list">
|
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
|
||||||
Loading zones...
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||||
|
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
|
||||||
|
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||||
|
</button>
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="audio-top-indicator" class="audio-top-indicator">
|
||||||
|
<button type="button" id="audio-top-beat-sync" class="audio-beat-sync-btn audio-top-beat-sync" disabled title="Sync step to music (S)">
|
||||||
|
<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>
|
||||||
|
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="header-brightness-control">
|
<div class="header-brightness-control">
|
||||||
@@ -21,18 +32,27 @@
|
|||||||
</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" id="audio-btn">Audio</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="settings-btn">Settings</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">
|
||||||
|
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||||
|
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
|
||||||
|
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||||
|
</button>
|
||||||
|
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||||
|
</div>
|
||||||
<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>
|
||||||
<div class="menu-brightness-control">
|
<div class="menu-brightness-control">
|
||||||
<label for="menu-brightness-slider">Brightness</label>
|
<label for="menu-brightness-slider">Brightness</label>
|
||||||
@@ -40,15 +60,23 @@
|
|||||||
</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" data-target="audio-btn">Audio</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="settings-btn">Settings</button>
|
||||||
<button type="button" data-target="help-btn">Help</button>
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="zones-container">
|
||||||
|
<div id="zones-list">
|
||||||
|
Loading zones...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
@@ -68,6 +96,10 @@
|
|||||||
<input type="text" id="new-zone-name" placeholder="Zone name">
|
<input type="text" id="new-zone-name" placeholder="Zone name">
|
||||||
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
<button class="btn btn-primary" id="create-zone-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="zone-content-kind-row muted-text">
|
||||||
|
<label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
|
||||||
|
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
|
||||||
|
</div>
|
||||||
<div id="zones-list-modal" class="profiles-list"></div>
|
<div id="zones-list-modal" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
<button class="btn btn-secondary" id="zones-close-btn">Close</button>
|
||||||
@@ -81,18 +113,29 @@
|
|||||||
<h2>Edit Zone</h2>
|
<h2>Edit Zone</h2>
|
||||||
<form id="edit-zone-form">
|
<form id="edit-zone-form">
|
||||||
<input type="hidden" id="edit-zone-id">
|
<input type="hidden" id="edit-zone-id">
|
||||||
<div class="modal-actions" style="margin-bottom: 1rem;">
|
|
||||||
<button type="submit" class="btn btn-primary">Save</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
|
||||||
</div>
|
|
||||||
<label>Zone Name:</label>
|
<label>Zone Name:</label>
|
||||||
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
|
||||||
<label class="zone-devices-label">Devices in this zone</label>
|
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
|
||||||
<div id="edit-zone-devices-editor" class="zone-devices-editor"></div>
|
<div id="edit-zone-block-groups">
|
||||||
|
<label class="zone-devices-label">Device groups on this zone</label>
|
||||||
|
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
|
||||||
|
</div>
|
||||||
|
<div id="edit-zone-block-presets">
|
||||||
<label class="zone-presets-section-label">Presets on this zone</label>
|
<label class="zone-presets-section-label">Presets on this zone</label>
|
||||||
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
<label class="zone-presets-section-label">Add presets to this zone</label>
|
<label class="zone-presets-section-label">Add presets to this zone</label>
|
||||||
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
<div id="edit-zone-presets-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
</div>
|
||||||
|
<div id="edit-zone-block-sequences">
|
||||||
|
<label class="zone-presets-section-label">Sequences on this zone</label>
|
||||||
|
<div id="edit-zone-sequences-current" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
<label class="zone-presets-section-label">Add a sequence to this zone</label>
|
||||||
|
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,6 +147,7 @@
|
|||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
@@ -122,13 +166,103 @@
|
|||||||
<div id="devices-modal" class="modal">
|
<div id="devices-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Devices</h2>
|
<h2>Devices</h2>
|
||||||
|
<div class="form-group" style="margin-bottom:0.75rem;">
|
||||||
|
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;">
|
||||||
|
<input type="text" id="devices-add-name" placeholder="Device name" autocomplete="off" style="min-width:10rem;">
|
||||||
|
<select id="devices-add-transport">
|
||||||
|
<option value="espnow">ESP-NOW</option>
|
||||||
|
<option value="wifi">Wi-Fi</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" id="devices-add-mac" placeholder="MAC (12 hex)" autocomplete="off" style="min-width:10rem;">
|
||||||
|
<input type="text" id="devices-add-address" placeholder="Address (IP/host for Wi-Fi)" autocomplete="off" style="min-width:12rem;" hidden>
|
||||||
|
<button type="button" class="btn btn-primary btn-small" id="devices-add-btn">Add device</button>
|
||||||
|
</div>
|
||||||
|
<small id="devices-add-status" class="muted-text" aria-live="polite"></small>
|
||||||
|
</div>
|
||||||
<div id="devices-list-modal" class="profiles-list"></div>
|
<div id="devices-list-modal" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||||
|
<button type="button" class="btn btn-secondary" id="devices-ping-btn" title="ESP-NOW broadcast ping (3 s)">Ping drivers</button>
|
||||||
|
<span id="devices-ping-dot" class="device-status-dot device-status-dot--unknown" role="img" title="Not pinged yet" aria-label="Not pinged yet"></span>
|
||||||
|
<span id="devices-ping-status" class="muted-text" aria-live="polite"></span>
|
||||||
|
<button type="button" class="btn btn-secondary" id="devices-update-groups-btn" title="Push group membership from Device groups to all ESP-NOW drivers">Update groups</button>
|
||||||
|
<span id="devices-groups-status" class="muted-text" aria-live="polite"></span>
|
||||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
||||||
|
<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>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="edit-device-modal" class="modal">
|
<div id="edit-device-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Edit device</h2>
|
<h2>Edit device</h2>
|
||||||
@@ -154,6 +288,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>
|
||||||
@@ -168,6 +333,7 @@
|
|||||||
<h2>Presets</h2>
|
<h2>Presets</h2>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
<button class="btn btn-primary" id="preset-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
|
||||||
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="presets-list" class="profiles-list"></div>
|
<div id="presets-list" class="profiles-list"></div>
|
||||||
@@ -177,6 +343,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequences Modal -->
|
||||||
|
<div id="sequences-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequences</h2>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
|
||||||
|
</div>
|
||||||
|
<div id="sequences-list" class="profiles-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequences-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequence Editor Modal -->
|
||||||
|
<div id="sequence-editor-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Sequence</h2>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="sequence-editor-name">Name</label>
|
||||||
|
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
|
||||||
|
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
|
||||||
|
Each step runs for the number of <strong>beats</strong> you set on that step.
|
||||||
|
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
|
||||||
|
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
|
||||||
|
</p>
|
||||||
|
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
|
||||||
|
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
|
||||||
|
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||||
|
<label style="display:block;margin-top:0.65rem;">
|
||||||
|
<input type="checkbox" id="sequence-editor-loop" checked>
|
||||||
|
Loop sequence (restart from the first step after the last)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="sequence-editor-lanes"></div>
|
||||||
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preset Editor Modal -->
|
<!-- Preset Editor Modal -->
|
||||||
<div id="preset-editor-modal" class="modal">
|
<div id="preset-editor-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -202,6 +416,36 @@
|
|||||||
<label for="preset-delay-input">Delay (ms)</label>
|
<label for="preset-delay-input">Delay (ms)</label>
|
||||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="preset-background-input">Background</label>
|
||||||
|
<div class="profiles-actions" style="gap: 0.4rem;">
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="preset-background-btn" title="Choose background colour">#000000</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="preset-background-from-palette-btn">From Palette</button>
|
||||||
|
<input type="color" id="preset-background-input" value="#000000" title="Background colour used in patterns with background support" style="position:absolute;opacity:0;pointer-events:none;width:1px;height:1px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
|
<label id="preset-manual-mode-label" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
|
<input type="checkbox" id="preset-manual-mode-input">
|
||||||
|
Manual mode (single-shot where supported)
|
||||||
|
</label>
|
||||||
|
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
|
||||||
|
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
|
||||||
|
<label for="preset-manual-beat-n-input">Audio beat: every</label>
|
||||||
|
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic" autocomplete="off">
|
||||||
|
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field" id="preset-reverse-group" hidden>
|
||||||
|
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
|
||||||
|
<input type="checkbox" id="preset-reverse-input">
|
||||||
|
Reverse direction (strip installed upside down)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
|
||||||
|
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
|
||||||
|
<select id="preset-mode-input" class="preset-mode-input"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
@@ -360,27 +604,28 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<h3>What led-tool does</h3>
|
<h3>LED Tool (Settings tab)</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
|
<li><strong>USB device setup</strong>: updates <code>settings.json</code> on ESP32 drivers over serial (for example name, pin, LED count, Wi-Fi credentials).</li>
|
||||||
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
|
<li><strong>Deploy and maintenance</strong>: uploads driver files, flashes firmware, resets device, and follows serial logs.</li>
|
||||||
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages.</li>
|
<li><strong>Scope</strong>: led-tool configures devices directly; this web UI controls profiles/zones/presets and sends runtime messages. Open <strong>Settings → LED Tool</strong> in Edit mode.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -389,66 +634,148 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Modal -->
|
||||||
|
<div id="audio-modal" class="modal">
|
||||||
|
<div class="modal-content audio-modal-content">
|
||||||
|
<h2>Audio Beat Detection</h2>
|
||||||
|
<div class="form-group audio-device-block">
|
||||||
|
<label for="audio-device-select">Input device</label>
|
||||||
|
<div class="profiles-actions audio-device-select-row">
|
||||||
|
<select id="audio-device-select">
|
||||||
|
<option value="">System default input</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<small class="muted-text">Same sources as PulseAudio volume control. Pick a <strong>monitor</strong> source to follow playback.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beat indicators</label>
|
||||||
|
<button type="button" id="audio-modal-beat-sync" class="audio-beat-sync-btn audio-modal-beat-sync" disabled title="Start beat detection">
|
||||||
|
<span class="audio-top-indicator-label">BPM</span>
|
||||||
|
<span id="audio-bpm-value" class="audio-top-indicator-value">--</span>
|
||||||
|
<span id="audio-modal-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
|
||||||
|
<span id="audio-bar-phase-value" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
|
||||||
|
</button>
|
||||||
|
<small class="muted-text">Flashes on each beat (same as the header). Tap on a downbeat while a sequence is playing to sync (<kbd>S</kbd>).</small>
|
||||||
|
</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 audio-volume-block">
|
||||||
|
<div class="audio-volume-header">
|
||||||
|
<label for="audio-input-volume">Volume</label>
|
||||||
|
<span id="audio-input-volume-readout" class="audio-volume-readout" aria-live="polite">100% (0.00 dB)</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-volume-slider-row">
|
||||||
|
<input type="range" id="audio-input-volume" class="audio-volume-slider" min="0" max="200" value="100" step="1" aria-label="Input gain">
|
||||||
|
</div>
|
||||||
|
<div class="audio-volume-scale" aria-hidden="true">
|
||||||
|
<span class="audio-volume-scale-silence">Silence</span>
|
||||||
|
<span class="audio-volume-scale-unity">100% (0 dB)</span>
|
||||||
|
</div>
|
||||||
|
<div class="audio-input-level-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Live input level">
|
||||||
|
<div id="audio-input-level-bar" class="audio-input-level-bar"></div>
|
||||||
|
</div>
|
||||||
|
<small class="muted-text">Gain before beat detection (saved on the controller). The bar shows live input level while running.</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-reset-btn" disabled title="Clear stuck BPM / beat tracking">Reset detector</button>
|
||||||
|
<button type="button" class="btn btn-secondary" id="audio-close-btn">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settings-modal" class="modal">
|
<div id="settings-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content settings-modal-content">
|
||||||
<h2>Device Settings</h2>
|
<h2>Settings</h2>
|
||||||
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
|
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||||
|
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
|
||||||
|
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="settings-message" class="message"></div>
|
<div id="settings-panel-bridge" class="settings-tab-panel active" data-settings-panel="bridge" role="tabpanel" aria-labelledby="settings-tab-bridge">
|
||||||
|
|
||||||
<!-- Device Name -->
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Device</h3>
|
<div class="profiles-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
|
||||||
<form id="device-form">
|
<span id="bridge-ws-status" class="muted-text" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
|
||||||
|
|
||||||
|
<h3 class="settings-subheading">USB serial</h3>
|
||||||
|
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses Wi‑Fi radio for ESP-NOW only.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="device-name-input">Device Name</label>
|
<label for="bridge-serial-label">Profile label</label>
|
||||||
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
|
||||||
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
|
<label for="bridge-serial-port">USB serial port</label>
|
||||||
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
|
<select id="bridge-serial-port">
|
||||||
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value everywhere.</small>
|
<option value="">— select port —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-refresh-btn" style="margin-top:0.5rem;">Refresh ports</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
|
||||||
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- WiFi Access Point Settings -->
|
|
||||||
<div class="settings-section ap-settings-section">
|
|
||||||
<h3>WiFi Access Point</h3>
|
|
||||||
|
|
||||||
<div id="ap-status" class="status-info">
|
|
||||||
<h4>AP Status</h4>
|
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="ap-form">
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-ssid">AP SSID (Network Name)</label>
|
<label for="bridge-serial-baud">Baud rate</label>
|
||||||
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
<input type="number" id="bridge-serial-baud" value="115200" min="9600" max="3000000" step="1">
|
||||||
<small>The name of the WiFi access point this device creates</small>
|
</div>
|
||||||
|
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||||||
|
<button type="button" class="btn btn-primary btn-small" id="bridge-serial-connect-btn">Connect serial</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-save-profile-btn">Save serial profile</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="settings-subheading">Wi‑Fi</h3>
|
||||||
|
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://<bridge-ip>/ws</code>.</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-password">AP Password</label>
|
<label for="bridge-wifi-interface">Wi‑Fi adapter</label>
|
||||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
<select id="bridge-wifi-interface">
|
||||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
<option value="">— select adapter —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-refresh-interfaces-btn" style="margin-top:0.5rem;">Refresh adapters</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-channel">Channel (1-11)</label>
|
<label for="bridge-wifi-ssid">Bridge SSID</label>
|
||||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
<select id="bridge-wifi-ssid" style="flex:1;min-width:12rem;">
|
||||||
|
<option value="">— scan or type below —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-scan-btn">Scan</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="bridge-wifi-ssid-manual" placeholder="Or type SSID" autocomplete="off" style="margin-top:0.5rem;">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bridge-wifi-password">Password</label>
|
||||||
|
<input type="password" id="bridge-wifi-password" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bridge-wifi-label">Profile label</label>
|
||||||
|
<input type="text" id="bridge-wifi-label" placeholder="e.g. Garden bridge" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;gap:1rem;flex-wrap:wrap;">
|
||||||
|
<div style="flex:1;min-width:8rem;">
|
||||||
|
<label for="bridge-wifi-ap-ip">Bridge IP</label>
|
||||||
|
<input type="text" id="bridge-wifi-ap-ip" value="192.168.4.1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div style="flex:0 0 6rem;">
|
||||||
|
<label for="bridge-wifi-ws-port">WS port</label>
|
||||||
|
<input type="number" id="bridge-wifi-ws-port" value="80" min="1" max="65535" step="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||||
|
<button type="button" class="btn btn-primary btn-small" id="bridge-wifi-connect-btn">Connect Wi‑Fi</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save Wi‑Fi profile</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group">
|
<h3 class="settings-subheading">Saved profiles</h3>
|
||||||
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-panel-led-tool" class="settings-tab-panel" data-settings-panel="led-tool" role="tabpanel" aria-labelledby="settings-tab-led-tool" hidden>
|
||||||
|
<p class="muted-text settings-led-tool-intro">USB serial setup for drivers and bridges: device settings, deploy, and firmware.</p>
|
||||||
|
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -457,91 +784,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED Tool Modal -->
|
|
||||||
<div id="led-tool-modal" class="modal">
|
|
||||||
<div class="modal-content">
|
|
||||||
<h2>LED Tool (USB)</h2>
|
|
||||||
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
|
|
||||||
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
|
|
||||||
<form id="led-tool-form">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-port">Serial port</label>
|
|
||||||
<div class="profiles-actions" style="gap: 0.5rem;">
|
|
||||||
<select id="led-tool-port" required style="flex:1;">
|
|
||||||
<option value="">Select a serial port</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-name">Name</label>
|
|
||||||
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-num-leds">Num LEDs</label>
|
|
||||||
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-led-pin">LED pin</label>
|
|
||||||
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-brightness">Brightness</label>
|
|
||||||
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-wifi-channel">WiFi channel</label>
|
|
||||||
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="profiles-actions">
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-transport">Transport</label>
|
|
||||||
<select id="led-tool-transport">
|
|
||||||
<option value="">(no change)</option>
|
|
||||||
<option value="espnow">espnow</option>
|
|
||||||
<option value="wifi">wifi</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="preset-editor-field">
|
|
||||||
<label for="led-tool-default">Default preset</label>
|
|
||||||
<input type="text" id="led-tool-default" placeholder="on">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-ssid">SSID</label>
|
|
||||||
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="led-tool-password">WiFi password</label>
|
|
||||||
<input type="password" id="led-tool-password" placeholder="WiFi password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Apply via USB</button>
|
|
||||||
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
|
|
||||||
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Styles moved to /static/style.css -->
|
<!-- Styles moved to /static/style.css -->
|
||||||
|
<script src="/static/zone-devices-panel.js"></script>
|
||||||
|
<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/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
|
<script src="/static/bundle_io.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/zone_palette.js"></script>
|
<script src="/static/zone_palette.js"></script>
|
||||||
<script src="/static/patterns.js"></script>
|
<script src="/static/patterns.js"></script>
|
||||||
<script src="/static/presets.js"></script>
|
<script src="/static/presets.js"></script>
|
||||||
|
<script src="/static/sequences.js"></script>
|
||||||
<script src="/static/devices.js"></script>
|
<script src="/static/devices.js"></script>
|
||||||
|
<script src="/static/audio.js"></script>
|
||||||
|
<script src="/static/numpad.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
|
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
|
||||||
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
|
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
|
||||||
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value on every device.</small>
|
<small>2.4 GHz channel (1–11) for ESP-NOW drivers and the bridge AP/STA. Set the same <code>wifi_channel</code> on the bridge and each led-driver; those devices need a reboot after a change. Saving here updates the Pi setting (restart led-controller to apply).</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
|
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ap-channel">Channel (1-11)</label>
|
<label for="ap-channel">Channel (1-11)</label>
|
||||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
<small>Bridge AP channel (1–11). Stored for reference; set <code>wifi_channel</code> on the bridge itself and reboot it to apply.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/settings/settings', {
|
const response = await fetch('/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
# Driver message builder (`espnow_message`)
|
# Driver message builder (`espnow_message`)
|
||||||
|
|
||||||
This utility builds **v1** JSON payloads for LED drivers (serial/ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
|
This utility builds **v1** JSON payloads for LED drivers (ESP-NOW bridge and Wi-Fi TCP). See **`docs/API.md`** for the full wire format.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Message Building
|
### Basic Message Building
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from util.espnow_message import build_message, build_preset_dict, build_select_dict
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
|
|
||||||
# Build a message with presets and select
|
# Build a message with presets and select (list form; routing is by MAC envelope / groups)
|
||||||
presets = {
|
presets = {
|
||||||
"red_blink": build_preset_dict({
|
"red_blink": build_preset_dict({
|
||||||
"pattern": "blink",
|
"pattern": "blink",
|
||||||
@@ -20,27 +20,17 @@ presets = {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
select = build_select_dict({
|
message = build_message(presets=presets, select=["red_blink"])
|
||||||
"device1": "red_blink"
|
# Result: {"v": "1", "presets": {...}, "select": ["red_blink"]}
|
||||||
})
|
|
||||||
|
|
||||||
message = build_message(presets=presets, select=select)
|
|
||||||
# Result: {"v": "1", "presets": {...}, "select": {...}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building Select Messages with Step Synchronization
|
### Select with step
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from util.espnow_message import build_message, build_select_dict
|
from util.espnow_message import build_message
|
||||||
|
|
||||||
# Select with step for synchronization
|
message = build_message(select=["rainbow_preset", 10])
|
||||||
select = build_select_dict(
|
# Result: {"v": "1", "select": ["rainbow_preset", 10]}
|
||||||
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
|
|
||||||
step_mapping={"device1": 10, "device2": 10}
|
|
||||||
)
|
|
||||||
|
|
||||||
message = build_message(select=select)
|
|
||||||
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Converting Presets
|
### Converting Presets
|
||||||
|
|||||||
667
src/util/audio_detector.py
Normal file
667
src/util/audio_detector.py
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
import collections
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_HOLDOVER_BPM_MIN = 30.0
|
||||||
|
_HOLDOVER_BPM_MAX = 300.0
|
||||||
|
_HOLDOVER_MAX_S = 300.0
|
||||||
|
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
|
||||||
|
# (same window as status() uses to hide stale BPM).
|
||||||
|
_SILENCE_GAP_S = 4.0
|
||||||
|
|
||||||
|
|
||||||
|
class AudioBeatDetector:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._thread = None
|
||||||
|
self._stream = None
|
||||||
|
self._running = False
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._runtime = None
|
||||||
|
self._pending_reset = False
|
||||||
|
self._holdover_thread: threading.Thread | None = None
|
||||||
|
self._holdover_stop = threading.Event()
|
||||||
|
self._holdover_active = False
|
||||||
|
self._last_real_beat_ts: float | None = None
|
||||||
|
self._last_gap_tempo_reset_ts: float = 0.0
|
||||||
|
self._status = {
|
||||||
|
"running": False,
|
||||||
|
"bpm": None,
|
||||||
|
"last_beat_ts": None,
|
||||||
|
"beat_seq": 0,
|
||||||
|
"beat_type": "unknown",
|
||||||
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"beats_per_bar": 4,
|
||||||
|
"is_downbeat": False,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": "1/4",
|
||||||
|
"error": None,
|
||||||
|
"device": None,
|
||||||
|
"input_level": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_input_devices(self):
|
||||||
|
try:
|
||||||
|
from util.pulse_audio_devices import list_pulse_matched_input_devices
|
||||||
|
|
||||||
|
pulse = list_pulse_matched_input_devices()
|
||||||
|
if pulse:
|
||||||
|
return pulse
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] pulse device list skipped: {e!r}")
|
||||||
|
|
||||||
|
sd_list = self._list_sounddevice_input_devices()
|
||||||
|
if sd_list:
|
||||||
|
print("[audio] device list: sounddevice fallback (install/use pactl for Pulse names)")
|
||||||
|
return sd_list
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _skip_sounddevice_virtual(name: str, hostapi_name: str) -> bool:
|
||||||
|
"""Hide PortAudio/Pulse aggregate devices (pipewire, pulse, default)."""
|
||||||
|
n = name.strip().lower()
|
||||||
|
if n in ("pipewire", "pulse", "default", "sysdefault"):
|
||||||
|
return True
|
||||||
|
ha = hostapi_name.strip().lower()
|
||||||
|
if ha in ("pulse", "pipewire") and n in ("default", "pipewire", "pulse"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _list_sounddevice_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()
|
||||||
|
hostapi_idx = int(dev.get("hostapi", -1))
|
||||||
|
hostapi_name = (
|
||||||
|
str(hostapis[hostapi_idx].get("name", "unknown"))
|
||||||
|
if 0 <= hostapi_idx < len(hostapis)
|
||||||
|
else "unknown"
|
||||||
|
)
|
||||||
|
if self._skip_sounddevice_virtual(name, hostapi_name):
|
||||||
|
continue
|
||||||
|
if chans <= 0 and not is_monitor_named:
|
||||||
|
continue
|
||||||
|
sr = int(dev.get("default_samplerate", 44100))
|
||||||
|
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]"
|
||||||
|
display_name = name
|
||||||
|
if is_default:
|
||||||
|
display_name = f"{display_name} (default)"
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": idx,
|
||||||
|
"name": name,
|
||||||
|
"display_name": display_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):
|
||||||
|
try:
|
||||||
|
from util.pulse_audio_devices import resolve_capture_device
|
||||||
|
|
||||||
|
device = resolve_capture_device(device)
|
||||||
|
except Exception as e:
|
||||||
|
self._set_error(str(e))
|
||||||
|
raise
|
||||||
|
should_restart = False
|
||||||
|
with self._lock:
|
||||||
|
should_restart = self._running
|
||||||
|
if should_restart:
|
||||||
|
self.stop()
|
||||||
|
with self._lock:
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._last_real_beat_ts = None
|
||||||
|
self._last_gap_tempo_reset_ts = 0.0
|
||||||
|
self._status.update(
|
||||||
|
{
|
||||||
|
"running": True,
|
||||||
|
"bpm": None,
|
||||||
|
"last_beat_ts": None,
|
||||||
|
"beat_seq": 0,
|
||||||
|
"beat_type": "unknown",
|
||||||
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"beats_per_bar": 4,
|
||||||
|
"is_downbeat": False,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": "1/4",
|
||||||
|
"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):
|
||||||
|
self._stop_bpm_holdover()
|
||||||
|
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._pending_reset = False
|
||||||
|
self._last_real_beat_ts = None
|
||||||
|
self._last_gap_tempo_reset_ts = 0.0
|
||||||
|
self._status["running"] = False
|
||||||
|
self._status["input_level"] = 0.0
|
||||||
|
|
||||||
|
def _update_input_level(self, mono) -> None:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
arr = np.asarray(mono, dtype=np.float32)
|
||||||
|
if arr.size == 0:
|
||||||
|
inst = 0.0
|
||||||
|
else:
|
||||||
|
peak = float(np.max(np.abs(arr)))
|
||||||
|
rms = float(np.sqrt(np.mean(arr * arr)))
|
||||||
|
inst = min(1.0, max(peak, rms * 2.0))
|
||||||
|
with self._lock:
|
||||||
|
prev = float(self._status.get("input_level") or 0.0)
|
||||||
|
if inst >= prev:
|
||||||
|
self._status["input_level"] = inst
|
||||||
|
else:
|
||||||
|
self._status["input_level"] = max(inst, prev * 0.82)
|
||||||
|
|
||||||
|
def _decay_input_level(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
prev = float(self._status.get("input_level") or 0.0)
|
||||||
|
self._status["input_level"] = prev * 0.82
|
||||||
|
|
||||||
|
def _input_gain(self) -> float:
|
||||||
|
try:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
|
vol = int(get_settings().get("audio_input_volume") or 100)
|
||||||
|
except (TypeError, ValueError, ImportError):
|
||||||
|
vol = 100
|
||||||
|
vol = max(0, min(200, vol))
|
||||||
|
return vol / 100.0
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
with self._lock:
|
||||||
|
st = dict(self._status)
|
||||||
|
holdover = self._holdover_active
|
||||||
|
last = st.get("last_beat_ts")
|
||||||
|
if st.get("running") and last is not None and not holdover:
|
||||||
|
try:
|
||||||
|
if (time.time() - float(last)) > 4.0:
|
||||||
|
st["bpm"] = None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return st
|
||||||
|
|
||||||
|
def _apply_tracking_reset_status(self) -> None:
|
||||||
|
"""Refresh published status after a tracking reset (lock must be held)."""
|
||||||
|
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||||
|
self._status.update(
|
||||||
|
{
|
||||||
|
"running": True,
|
||||||
|
"beat_type": "unknown",
|
||||||
|
"beat_type_confidence": 0.0,
|
||||||
|
"bar_beat": 1,
|
||||||
|
"is_downbeat": True,
|
||||||
|
"phase_confidence": 0.0,
|
||||||
|
"bar_phase_readout": f"1/{bpb}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
v = float(bpm)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
def _holdover_interval_s(self, bpm: float) -> float:
|
||||||
|
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
|
||||||
|
|
||||||
|
def _stop_bpm_holdover(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._holdover_active = False
|
||||||
|
self._holdover_stop.set()
|
||||||
|
t = self._holdover_thread
|
||||||
|
if t and t.is_alive() and t is not threading.current_thread():
|
||||||
|
t.join(timeout=2.0)
|
||||||
|
with self._lock:
|
||||||
|
if self._holdover_thread is t:
|
||||||
|
self._holdover_thread = None
|
||||||
|
|
||||||
|
def _advance_holdover_bar_phase_locked(self) -> dict:
|
||||||
|
"""Advance bar phase for one synthetic beat (lock must be held)."""
|
||||||
|
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||||
|
prev = int(self._status.get("bar_beat") or 1)
|
||||||
|
bar_beat = (prev % bpb) + 1
|
||||||
|
is_downbeat = bar_beat == 1
|
||||||
|
bar_readout = f"{bar_beat}/{bpb}"
|
||||||
|
self._status["bar_beat"] = bar_beat
|
||||||
|
self._status["is_downbeat"] = is_downbeat
|
||||||
|
self._status["bar_phase_readout"] = bar_readout
|
||||||
|
return {
|
||||||
|
"bar_beat": bar_beat,
|
||||||
|
"beats_per_bar": bpb,
|
||||||
|
"is_downbeat": is_downbeat,
|
||||||
|
"bar_phase_readout": bar_readout,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _emit_holdover_beat(self, bpm: float) -> None:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or not self._holdover_active:
|
||||||
|
return
|
||||||
|
self._advance_holdover_bar_phase_locked()
|
||||||
|
self._status["last_beat_ts"] = now
|
||||||
|
self._status["bpm"] = float(bpm)
|
||||||
|
self._status["beat_type"] = "holdover"
|
||||||
|
self._status["beat_type_confidence"] = 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] holdover beat queue: {e}")
|
||||||
|
|
||||||
|
def _holdover_loop(self, bpm: float, started_at: float) -> None:
|
||||||
|
interval = self._holdover_interval_s(bpm)
|
||||||
|
while not self._holdover_stop.is_set():
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or not self._holdover_active:
|
||||||
|
return
|
||||||
|
if (time.time() - started_at) > _HOLDOVER_MAX_S:
|
||||||
|
self._holdover_active = False
|
||||||
|
return
|
||||||
|
last = self._status.get("last_beat_ts")
|
||||||
|
if last is not None:
|
||||||
|
try:
|
||||||
|
delay = max(0.02, float(last) + interval - time.time())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
delay = interval
|
||||||
|
else:
|
||||||
|
delay = interval
|
||||||
|
if self._holdover_stop.wait(delay):
|
||||||
|
return
|
||||||
|
self._emit_holdover_beat(bpm)
|
||||||
|
|
||||||
|
def _start_bpm_holdover(self, bpm: float) -> None:
|
||||||
|
bpm_v = self._clamp_holdover_bpm(bpm)
|
||||||
|
if bpm_v is None:
|
||||||
|
return
|
||||||
|
self._stop_bpm_holdover()
|
||||||
|
self._holdover_stop.clear()
|
||||||
|
started_at = time.time()
|
||||||
|
with self._lock:
|
||||||
|
self._holdover_active = True
|
||||||
|
self._holdover_thread = threading.Thread(
|
||||||
|
target=self._holdover_loop,
|
||||||
|
args=(bpm_v, started_at),
|
||||||
|
name="audio-bpm-holdover",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t = self._holdover_thread
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _process_pending_reset(self, runtime) -> None:
|
||||||
|
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._pending_reset:
|
||||||
|
return
|
||||||
|
self._pending_reset = False
|
||||||
|
try:
|
||||||
|
runtime.reset_state()
|
||||||
|
with self._lock:
|
||||||
|
self._apply_tracking_reset_status()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] pending reset: {e}")
|
||||||
|
|
||||||
|
def reset_tracking(self) -> bool:
|
||||||
|
"""Clear detector tempo history without stopping the input stream."""
|
||||||
|
holdover_bpm = None
|
||||||
|
with self._lock:
|
||||||
|
if not self._running or self._runtime is None:
|
||||||
|
return False
|
||||||
|
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||||
|
self._pending_reset = True
|
||||||
|
self._apply_tracking_reset_status()
|
||||||
|
if holdover_bpm is not None:
|
||||||
|
self._start_bpm_holdover(holdover_bpm)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _set_error(self, msg):
|
||||||
|
print(f"[audio] {msg}")
|
||||||
|
with self._lock:
|
||||||
|
self._status["error"] = msg
|
||||||
|
self._status["running"] = False
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def anchor_bar_phase(self) -> bool:
|
||||||
|
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
|
||||||
|
with self._lock:
|
||||||
|
rt = self._runtime
|
||||||
|
if rt is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
rt.anchor_bar_phase(time.time())
|
||||||
|
with self._lock:
|
||||||
|
self._status["bar_beat"] = 1
|
||||||
|
self._status["is_downbeat"] = True
|
||||||
|
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
|
||||||
|
self._status["phase_confidence"] = max(
|
||||||
|
float(self._status.get("phase_confidence") or 0.0), 0.85
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] anchor_bar_phase: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _maybe_recover_after_silence_gap(self, runtime) -> None:
|
||||||
|
"""After a quiet spell, reset tempo tracking and run holdover until real beats return."""
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
last_real = self._last_real_beat_ts
|
||||||
|
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||||
|
holdover = self._holdover_active
|
||||||
|
last_reset = self._last_gap_tempo_reset_ts
|
||||||
|
if last_real is None or bpm is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
gap = now - float(last_real)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
if gap < _SILENCE_GAP_S:
|
||||||
|
return
|
||||||
|
if not holdover:
|
||||||
|
self._start_bpm_holdover(bpm)
|
||||||
|
try:
|
||||||
|
since_reset = (
|
||||||
|
now - float(last_reset) if last_reset else _SILENCE_GAP_S
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
since_reset = _SILENCE_GAP_S
|
||||||
|
if since_reset >= _SILENCE_GAP_S:
|
||||||
|
try:
|
||||||
|
runtime.reset_tempo_state()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[audio] silence gap tempo reset: {e}")
|
||||||
|
else:
|
||||||
|
with self._lock:
|
||||||
|
self._last_gap_tempo_reset_ts = now
|
||||||
|
|
||||||
|
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
|
||||||
|
self._stop_bpm_holdover()
|
||||||
|
now = time.time()
|
||||||
|
self._last_real_beat_ts = now
|
||||||
|
with self._lock:
|
||||||
|
self._last_gap_tempo_reset_ts = 0.0
|
||||||
|
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
|
||||||
|
if phase_fields.get("bar_beat") is not None:
|
||||||
|
self._status["bar_beat"] = int(phase_fields["bar_beat"])
|
||||||
|
if phase_fields.get("beats_per_bar") is not None:
|
||||||
|
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
|
||||||
|
if phase_fields.get("is_downbeat") is not None:
|
||||||
|
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
|
||||||
|
if phase_fields.get("phase_confidence") is not None:
|
||||||
|
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
|
||||||
|
if phase_fields.get("bar_phase_readout"):
|
||||||
|
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
|
||||||
|
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)
|
||||||
|
|
||||||
|
from util.pulse_audio_devices import resolve_capture_device
|
||||||
|
|
||||||
|
device = resolve_capture_device(device)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
if not isinstance(device, int):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"internal error: unresolved capture device {device!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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=100.0,
|
||||||
|
bpm_window=8,
|
||||||
|
post_url="",
|
||||||
|
aubio_method="default",
|
||||||
|
aubio_threshold=0.14,
|
||||||
|
beats_per_bar=4,
|
||||||
|
)
|
||||||
|
runtime = beat_mod.BeatDetectRuntime(args)
|
||||||
|
runtime.setup(sample_rate=sample_rate)
|
||||||
|
with self._lock:
|
||||||
|
self._runtime = runtime
|
||||||
|
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():
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
|
try:
|
||||||
|
frame = audio_q.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
self._decay_input_level()
|
||||||
|
self._maybe_recover_after_silence_gap(runtime)
|
||||||
|
continue
|
||||||
|
self._process_pending_reset(runtime)
|
||||||
|
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]))
|
||||||
|
gain = self._input_gain()
|
||||||
|
if gain != 1.0:
|
||||||
|
frame = frame * gain
|
||||||
|
self._update_input_level(frame)
|
||||||
|
event = runtime.process_frame(frame, now_s=time.time())
|
||||||
|
if event is None:
|
||||||
|
self._maybe_recover_after_silence_gap(runtime)
|
||||||
|
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),
|
||||||
|
bar_beat=event.get("bar_beat"),
|
||||||
|
beats_per_bar=event.get("beats_per_bar"),
|
||||||
|
is_downbeat=event.get("is_downbeat"),
|
||||||
|
phase_confidence=event.get("phase_confidence"),
|
||||||
|
bar_phase_readout=event.get("bar_phase_readout"),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
self._runtime = None
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
def shared_beat_status_snapshot() -> dict:
|
||||||
|
"""Thread-safe copy of live detector status, or {} if audio is off."""
|
||||||
|
d = _shared_beat_detector
|
||||||
|
if d is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return dict(d.status())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def anchor_shared_bar_phase() -> bool:
|
||||||
|
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
|
||||||
|
d = _shared_beat_detector
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(d.anchor_bar_phase())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
94
src/util/audio_run_persist.py
Normal file
94
src/util/audio_run_persist.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""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,
|
||||||
|
"device_override": "",
|
||||||
|
"device_select": "",
|
||||||
|
}
|
||||||
|
enabled = bool(raw.get("enabled"))
|
||||||
|
dev = raw.get("device", None)
|
||||||
|
return {
|
||||||
|
"enabled": enabled,
|
||||||
|
"device": dev,
|
||||||
|
"device_override": str(raw.get("device_override") or ""),
|
||||||
|
"device_select": str(raw.get("device_select") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_audio_run_state(
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
device: Any = None,
|
||||||
|
device_override: str | None = None,
|
||||||
|
device_select: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
|
||||||
|
path = _db_path()
|
||||||
|
prev = read_audio_run_state()
|
||||||
|
if enabled:
|
||||||
|
data = {
|
||||||
|
"enabled": True,
|
||||||
|
"device": device,
|
||||||
|
"device_override": (
|
||||||
|
str(device_override)
|
||||||
|
if device_override is not None
|
||||||
|
else str(prev.get("device_override") or "")
|
||||||
|
),
|
||||||
|
"device_select": (
|
||||||
|
str(device_select)
|
||||||
|
if device_select is not None
|
||||||
|
else str(prev.get("device_select") or "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
data = {
|
||||||
|
"enabled": False,
|
||||||
|
"device": prev.get("device"),
|
||||||
|
"device_override": (
|
||||||
|
str(device_override)
|
||||||
|
if device_override is not None
|
||||||
|
else str(prev.get("device_override") or "")
|
||||||
|
),
|
||||||
|
"device_select": (
|
||||||
|
str(device_select)
|
||||||
|
if device_select is not None
|
||||||
|
else str(prev.get("device_select") or "")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
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}")
|
||||||
688
src/util/beat_driver_route.py
Normal file
688
src/util/beat_driver_route.py
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
"""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,
|
||||||
|
group_ids: Optional[List[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Enable audio→driver routing for one manual preset (clears all lanes, including sequence)."""
|
||||||
|
global _lane_manual
|
||||||
|
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||||
|
if not device_names and not gids:
|
||||||
|
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,
|
||||||
|
"group_ids": gids,
|
||||||
|
}
|
||||||
|
_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,
|
||||||
|
group_ids: Optional[List[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Register manual beat routing on lane ``-1`` only, keeping sequence lanes ``0..n`` intact."""
|
||||||
|
global _lane_manual
|
||||||
|
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||||
|
if not device_names and not gids:
|
||||||
|
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:
|
||||||
|
if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_sync_public_beat_route_from_lane_table()
|
||||||
|
return
|
||||||
|
_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,
|
||||||
|
"group_ids": gids,
|
||||||
|
}
|
||||||
|
_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,
|
||||||
|
group_ids: Optional[List[str]] = None,
|
||||||
|
) -> 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()]
|
||||||
|
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||||
|
if (not names and not gids) 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,
|
||||||
|
"group_ids": gids,
|
||||||
|
}
|
||||||
|
overlay = _lane_manual.get(-1)
|
||||||
|
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
|
||||||
|
overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
|
||||||
|
):
|
||||||
|
_lane_manual.pop(-1, None)
|
||||||
|
_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 _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
|
||||||
|
names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
|
||||||
|
return names, str(wire_preset_id or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
|
||||||
|
"""True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
|
||||||
|
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||||
|
for lane_key, entry in _lane_manual.items():
|
||||||
|
if not isinstance(lane_key, int) or lane_key < 0:
|
||||||
|
continue
|
||||||
|
other = _lane_route_targets_key(
|
||||||
|
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||||
|
)
|
||||||
|
if other == key:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def mark_manual_select_sent_for_targets(
|
||||||
|
device_names: List[str], wire_preset_id: str
|
||||||
|
) -> None:
|
||||||
|
"""A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
|
||||||
|
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||||
|
with _route_lock:
|
||||||
|
for entry in _lane_manual.values():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
other = _lane_route_targets_key(
|
||||||
|
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||||
|
)
|
||||||
|
if other == key:
|
||||||
|
entry["suppress_next_notify"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
|
||||||
|
"""A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
|
||||||
|
with _route_lock:
|
||||||
|
e = _lane_manual.get(lane_index)
|
||||||
|
if e is not None:
|
||||||
|
e["suppress_next_notify"] = True
|
||||||
|
|
||||||
|
|
||||||
|
def reset_manual_lane_strides() -> None:
|
||||||
|
"""Zero manual-lane beat counters after a sequence change (routes unchanged)."""
|
||||||
|
global _preset_session_beats
|
||||||
|
with _route_lock:
|
||||||
|
_preset_session_beats = 0
|
||||||
|
for e in _lane_manual.values():
|
||||||
|
if isinstance(e, dict):
|
||||||
|
e["beat_counter"] = 0
|
||||||
|
|
||||||
|
|
||||||
|
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 ``select`` as ``[preset_id, step?]``: use ``target_macs`` for device names.
|
||||||
|
Legacy name-map ``select`` still uses map keys as device names.
|
||||||
|
|
||||||
|
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_list: Optional[List[Any]] = None
|
||||||
|
last_select_map: Optional[Dict[str, Any]] = None
|
||||||
|
last_group_ids: Optional[List[str]] = 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, list) and sel:
|
||||||
|
last_select_list = sel
|
||||||
|
elif isinstance(sel, dict) and sel:
|
||||||
|
last_select_map = sel
|
||||||
|
gr = item.get("groups")
|
||||||
|
if isinstance(gr, list) and gr:
|
||||||
|
last_group_ids = [str(g).strip() for g in gr if str(g).strip()]
|
||||||
|
|
||||||
|
if last_select_list:
|
||||||
|
device_names = _registry_names_for_macs(target_macs)
|
||||||
|
if not device_names and not last_group_ids:
|
||||||
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
|
wire_preset_id = str(last_select_list[0]).strip()
|
||||||
|
if not wire_preset_id:
|
||||||
|
if not preserve_parallel_lane_routes:
|
||||||
|
update_beat_route({"enabled": False})
|
||||||
|
return
|
||||||
|
elif last_select_map:
|
||||||
|
device_names = [str(k).strip() for k in last_select_map.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_map.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()
|
||||||
|
else:
|
||||||
|
wire_preset_id = None
|
||||||
|
|
||||||
|
if wire_preset_id is not None:
|
||||||
|
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, group_ids=last_group_ids
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_apply_manual_beat_route(
|
||||||
|
device_names, wire_preset_id, preset_body, group_ids=last_group_ids
|
||||||
|
)
|
||||||
|
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
|
||||||
|
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, group_ids=last_group_ids
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids)
|
||||||
|
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(
|
||||||
|
wire_preset_id: str,
|
||||||
|
group_ids: Optional[List[str]] = None,
|
||||||
|
) -> None:
|
||||||
|
from models.device import Device
|
||||||
|
from models.transport import get_current_bridge
|
||||||
|
from util.driver_delivery import deliver_json_messages
|
||||||
|
|
||||||
|
bridge = get_current_bridge()
|
||||||
|
if not bridge:
|
||||||
|
return
|
||||||
|
devices = Device()
|
||||||
|
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||||
|
body: Dict[str, Any] = {"v": "1", "select": [wire_preset_id]}
|
||||||
|
if gids:
|
||||||
|
body["groups"] = gids
|
||||||
|
msg = json.dumps(body, separators=(",", ":"))
|
||||||
|
try:
|
||||||
|
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[beat-route] deliver failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _deliver_select_batch(pairs: List[Tuple[str, Optional[List[str]]]]) -> None:
|
||||||
|
for pid, gids in pairs:
|
||||||
|
await _deliver_select(pid, gids)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_beat_detected() -> None:
|
||||||
|
"""Invoked from the audio thread when a beat is detected.
|
||||||
|
|
||||||
|
Only manual presets are registered in ``_lane_manual`` (auto presets are cleared on step
|
||||||
|
change and get ``select`` from sequence/UI only when the preset changes).
|
||||||
|
"""
|
||||||
|
global _preset_session_beats
|
||||||
|
work: List[Tuple[str, Optional[List[str]]]] = []
|
||||||
|
with _route_lock:
|
||||||
|
if not _lane_manual:
|
||||||
|
return
|
||||||
|
work = []
|
||||||
|
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
|
||||||
|
for key in sorted(_lane_manual.keys()):
|
||||||
|
e = _lane_manual[key]
|
||||||
|
names = e.get("device_names") or []
|
||||||
|
if not isinstance(names, list):
|
||||||
|
names = []
|
||||||
|
gids_raw = e.get("group_ids") or []
|
||||||
|
gids = (
|
||||||
|
[str(g).strip() for g in gids_raw if str(g).strip()]
|
||||||
|
if isinstance(gids_raw, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if not names and not gids:
|
||||||
|
continue
|
||||||
|
pattern = str(e.get("pattern") or "")
|
||||||
|
if pattern and not _pattern_supports_manual(pattern):
|
||||||
|
continue
|
||||||
|
if e.pop("suppress_next_notify", False):
|
||||||
|
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
|
||||||
|
wire = str(e.get("wire_preset_id") or "2")
|
||||||
|
target_key = (
|
||||||
|
(tuple(sorted(gids)), wire) if gids else _lane_route_targets_key(names, wire)
|
||||||
|
)
|
||||||
|
if target_key in seen_targets:
|
||||||
|
continue
|
||||||
|
seen_targets.add(target_key)
|
||||||
|
work.append((wire, gids or None))
|
||||||
|
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}")
|
||||||
62
src/util/binary_driver_messages.py
Normal file
62
src/util/binary_driver_messages.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Build binary ESP-NOW CMD / GROUP_CMD packets from preset/select data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from util.binary_envelope import pack_binary_envelope_v2
|
||||||
|
from util.espnow_wire import MAX_ESPNOW_PAYLOAD, pack_cmd, pack_group_cmd
|
||||||
|
|
||||||
|
|
||||||
|
def v1_dict_to_cmd_packet(body: Dict[str, Any]) -> bytes:
|
||||||
|
save = bool(body.get("save"))
|
||||||
|
kw: Dict[str, Any] = {}
|
||||||
|
if "presets" in body:
|
||||||
|
kw["presets"] = body["presets"]
|
||||||
|
if "select" in body:
|
||||||
|
kw["select"] = body["select"]
|
||||||
|
if "default" in body:
|
||||||
|
kw["default"] = body["default"]
|
||||||
|
kw["default_targets"] = body.get("targets")
|
||||||
|
if "b" in body:
|
||||||
|
kw["brightness_0_255"] = int(body["b"])
|
||||||
|
return pack_cmd(pack_binary_envelope_v2(**kw), save=save)
|
||||||
|
|
||||||
|
|
||||||
|
def build_preset_cmd_chunks(
|
||||||
|
presets_by_name: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
save: bool = False,
|
||||||
|
default: Optional[str] = None,
|
||||||
|
max_payload: int = MAX_ESPNOW_PAYLOAD,
|
||||||
|
) -> List[bytes]:
|
||||||
|
"""Chunk presets into CMD packets each ≤ max_payload bytes."""
|
||||||
|
entries = list(presets_by_name.items())
|
||||||
|
chunks: List[bytes] = []
|
||||||
|
batch: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _packet_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]):
|
||||||
|
kw: Dict[str, Any] = {"presets": presets_map}
|
||||||
|
if def_id is not None:
|
||||||
|
kw["default"] = def_id
|
||||||
|
return pack_cmd(pack_binary_envelope_v2(**kw), save=final_save)
|
||||||
|
|
||||||
|
for name, preset_obj in entries:
|
||||||
|
trial = dict(batch)
|
||||||
|
trial[name] = preset_obj
|
||||||
|
try:
|
||||||
|
pkt = _packet_for(trial, final_save=False, def_id=None)
|
||||||
|
except ValueError:
|
||||||
|
pkt = b"\xff\xff"
|
||||||
|
if len(pkt) <= max_payload or not batch:
|
||||||
|
batch = trial
|
||||||
|
else:
|
||||||
|
chunks.append(_packet_for(batch, final_save=False, def_id=None))
|
||||||
|
batch = {name: preset_obj}
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
chunks.append(
|
||||||
|
_packet_for(batch, final_save=save, def_id=str(default) if default else None),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [c for c in chunks if c and c[0] == 0x4C]
|
||||||
@@ -43,6 +43,8 @@ import json
|
|||||||
import struct
|
import struct
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from util.espnow_message import wire_n6
|
||||||
|
|
||||||
BINARY_ENVELOPE_VERSION_1 = 1
|
BINARY_ENVELOPE_VERSION_1 = 1
|
||||||
BINARY_ENVELOPE_VERSION_2 = 2
|
BINARY_ENVELOPE_VERSION_2 = 2
|
||||||
HEADER_LEN = 5
|
HEADER_LEN = 5
|
||||||
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
|
|||||||
n3 = _clamp_i16(preset.get("n3", 0))
|
n3 = _clamp_i16(preset.get("n3", 0))
|
||||||
n4 = _clamp_i16(preset.get("n4", 0))
|
n4 = _clamp_i16(preset.get("n4", 0))
|
||||||
n5 = _clamp_i16(preset.get("n5", 0))
|
n5 = _clamp_i16(preset.get("n5", 0))
|
||||||
n6 = _clamp_i16(preset.get("n6", 0))
|
n6 = _clamp_i16(wire_n6(preset))
|
||||||
parts.append(
|
parts.append(
|
||||||
struct.pack(
|
struct.pack(
|
||||||
"<HBBhhhhhh",
|
"<HBBhhhhhh",
|
||||||
|
|||||||
151
src/util/bridge_envelope.py
Normal file
151
src/util/bridge_envelope.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Build v1 devices envelope for Pi → bridge WebSocket (short wire keys)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
from util.v1_wire import (
|
||||||
|
ENV_DEVICES,
|
||||||
|
K_GROUPS,
|
||||||
|
K_SAVE,
|
||||||
|
K_SET_GROUPS,
|
||||||
|
compact_body,
|
||||||
|
compact_envelope,
|
||||||
|
wire_json_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
BROADCAST_MAC = "ff:ff:ff:ff:ff:ff"
|
||||||
|
BROADCAST_HEX = "ffffffffffff"
|
||||||
|
MAX_ESPNOW_PAYLOAD = 250
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mac_key(mac: Optional[str]) -> Optional[str]:
|
||||||
|
if mac is None:
|
||||||
|
return None
|
||||||
|
s = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_mac_key(mac_hex: str) -> str:
|
||||||
|
h = normalize_mac_key(mac_hex)
|
||||||
|
if not h:
|
||||||
|
raise ValueError("invalid mac")
|
||||||
|
return ":".join(h[i : i + 2] for i in range(0, 12, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def is_broadcast_mac(mac: Optional[str]) -> bool:
|
||||||
|
h = normalize_mac_key(mac)
|
||||||
|
return h == BROADCAST_HEX
|
||||||
|
|
||||||
|
|
||||||
|
def build_devices_envelope(devices: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Wrap per-MAC bodies in a v1 envelope (short ``dv`` key)."""
|
||||||
|
compact_devices = {
|
||||||
|
mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)
|
||||||
|
}
|
||||||
|
return {"v": "1", ENV_DEVICES: compact_devices}
|
||||||
|
|
||||||
|
|
||||||
|
def build_groups_envelope(mac_hex: str, group_ids: List[str]) -> Dict[str, Any]:
|
||||||
|
key = format_mac_key(mac_hex)
|
||||||
|
return build_devices_envelope(
|
||||||
|
{
|
||||||
|
key: {
|
||||||
|
K_GROUPS: [str(g) for g in group_ids],
|
||||||
|
K_SET_GROUPS: True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_v1_body(
|
||||||
|
*,
|
||||||
|
presets: Optional[Dict[str, Any]] = None,
|
||||||
|
select: Optional[Union[List[Any], Dict[str, Any], str]] = None,
|
||||||
|
save: bool = False,
|
||||||
|
default: Optional[str] = None,
|
||||||
|
brightness: Optional[int] = None,
|
||||||
|
groups: Optional[List[str]] = None,
|
||||||
|
set_groups: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
body: Dict[str, Any] = {}
|
||||||
|
if presets:
|
||||||
|
body["presets"] = presets
|
||||||
|
if select is not None:
|
||||||
|
body["select"] = select
|
||||||
|
if save:
|
||||||
|
body["save"] = True
|
||||||
|
if default is not None:
|
||||||
|
body["default"] = str(default)
|
||||||
|
if brightness is not None:
|
||||||
|
body["b"] = max(0, min(255, int(brightness)))
|
||||||
|
if groups is not None:
|
||||||
|
body["groups"] = [str(g) for g in groups]
|
||||||
|
if set_groups:
|
||||||
|
body["set_groups"] = True
|
||||||
|
return compact_body(body)
|
||||||
|
|
||||||
|
|
||||||
|
def v1_body_size(body: Dict[str, Any]) -> int:
|
||||||
|
return wire_json_size({"v": "1", **compact_body(body)})
|
||||||
|
|
||||||
|
|
||||||
|
def envelope_payload_size(envelope: Dict[str, Any]) -> int:
|
||||||
|
return wire_json_size(compact_envelope(envelope))
|
||||||
|
|
||||||
|
|
||||||
|
def split_v1_body_for_espnow(body: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
"""Split a device body into chunks each <= MAX_ESPNOW_PAYLOAD bytes on the wire."""
|
||||||
|
from util.v1_wire import K_PRESETS, K_SAVE, K_SELECT, expand_body
|
||||||
|
|
||||||
|
long_body = expand_body(body)
|
||||||
|
compact = compact_body(long_body)
|
||||||
|
if v1_body_size(long_body) <= MAX_ESPNOW_PAYLOAD:
|
||||||
|
return [compact]
|
||||||
|
|
||||||
|
chunks: List[Dict[str, Any]] = []
|
||||||
|
meta = {k: v for k, v in compact.items() if k not in (K_PRESETS, K_SELECT)}
|
||||||
|
presets = compact.get(K_PRESETS)
|
||||||
|
select = compact.get(K_SELECT)
|
||||||
|
|
||||||
|
if presets and isinstance(presets, dict):
|
||||||
|
preset_msg = {**meta, K_PRESETS: presets}
|
||||||
|
if wire_json_size({"v": "1", **preset_msg}) <= MAX_ESPNOW_PAYLOAD:
|
||||||
|
chunks.append(preset_msg)
|
||||||
|
else:
|
||||||
|
for pid, pdata in presets.items():
|
||||||
|
one = {**meta, K_PRESETS: {pid: pdata}}
|
||||||
|
if wire_json_size({"v": "1", **one}) > MAX_ESPNOW_PAYLOAD:
|
||||||
|
raise ValueError(f"preset {pid!r} too large for ESP-NOW")
|
||||||
|
chunks.append(one)
|
||||||
|
|
||||||
|
if select is not None:
|
||||||
|
sel_meta = {k: v for k, v in meta.items() if k != K_SAVE}
|
||||||
|
sel_msg = {**sel_meta, K_SELECT: select}
|
||||||
|
if wire_json_size({"v": "1", **sel_msg}) > MAX_ESPNOW_PAYLOAD:
|
||||||
|
raise ValueError("select payload too large for ESP-NOW")
|
||||||
|
chunks.append(sel_msg)
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
raise ValueError("device body too large to split for ESP-NOW")
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def merge_preset_and_select(
|
||||||
|
preset_body: Dict[str, Any],
|
||||||
|
select_body: Dict[str, Any],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Merge preset + select bodies if combined envelope fits ESP-NOW limit."""
|
||||||
|
merged = dict(preset_body)
|
||||||
|
if "select" in select_body:
|
||||||
|
merged["select"] = select_body["select"]
|
||||||
|
for key in ("groups", "set_groups"):
|
||||||
|
if key in select_body and key not in merged:
|
||||||
|
merged[key] = select_body[key]
|
||||||
|
env = build_devices_envelope({BROADCAST_MAC: merged})
|
||||||
|
if envelope_payload_size(env) <= MAX_ESPNOW_PAYLOAD:
|
||||||
|
return compact_body(merged)
|
||||||
|
return None
|
||||||
201
src/util/bridge_for_group.py
Normal file
201
src/util/bridge_for_group.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""Resolve and connect the bridge assigned to device groups."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Set, Any
|
||||||
|
|
||||||
|
from models.group import Group
|
||||||
|
from settings import get_settings
|
||||||
|
from util.bridge_profiles import find_bridge_profile
|
||||||
|
from util.bridge_runtime import connect_bridge_profile
|
||||||
|
from util.espnow_registry import push_groups_for_group_devices
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bridge_id(raw: object) -> Optional[str]:
|
||||||
|
bid = str(raw or "").strip()
|
||||||
|
return bid if bid else None
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_id_for_group_doc(gdoc: dict) -> Optional[str]:
|
||||||
|
if not isinstance(gdoc, dict):
|
||||||
|
return None
|
||||||
|
return _normalize_bridge_id(gdoc.get("bridge_id"))
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_ids_for_group_docs(docs: list) -> Set[Optional[str]]:
|
||||||
|
ids: Set[Optional[str]] = set()
|
||||||
|
for doc in docs:
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
ids.add(bridge_id_for_group_doc(doc))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_id_for_group_id(group_id: str) -> Optional[str]:
|
||||||
|
gid = str(group_id or "").strip()
|
||||||
|
if not gid:
|
||||||
|
return None
|
||||||
|
gdoc = Group().read(gid)
|
||||||
|
if not gdoc:
|
||||||
|
return None
|
||||||
|
return bridge_id_for_group_doc(gdoc)
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_to_bridge_map(group_ids: List[str]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Map group id -> bridge profile id (``None`` = default / current connection)."""
|
||||||
|
groups = Group()
|
||||||
|
out: Dict[str, Optional[str]] = {}
|
||||||
|
for gid in group_ids:
|
||||||
|
s = str(gid).strip()
|
||||||
|
if not s or s in out:
|
||||||
|
continue
|
||||||
|
gdoc = groups.read(s)
|
||||||
|
out[s] = bridge_id_for_group_doc(gdoc) if gdoc else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_ids_for_group_ids(group_ids: List[str]) -> Set[Optional[str]]:
|
||||||
|
if not group_ids:
|
||||||
|
return set()
|
||||||
|
return set(build_group_to_bridge_map(group_ids).values())
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_bridge_ids(bridge_ids: Set[Optional[str]]) -> List[Optional[str]]:
|
||||||
|
"""Stable order: default bridge first, then profile ids sorted."""
|
||||||
|
if not bridge_ids:
|
||||||
|
return []
|
||||||
|
rest = sorted(b for b in bridge_ids if b)
|
||||||
|
if None in bridge_ids:
|
||||||
|
return [None, *rest]
|
||||||
|
return rest
|
||||||
|
|
||||||
|
|
||||||
|
def bridges_needed_for_body(
|
||||||
|
body: dict, group_to_bridge: Dict[str, Optional[str]]
|
||||||
|
) -> Set[Optional[str]]:
|
||||||
|
"""Which bridge(s) must receive this v1 body (by ``groups`` / ``g``)."""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return {None}
|
||||||
|
g = body.get("groups") or body.get("g")
|
||||||
|
if not isinstance(g, list) or not g:
|
||||||
|
return {None}
|
||||||
|
needed: Set[Optional[str]] = set()
|
||||||
|
for item in g:
|
||||||
|
gid = str(item).strip()
|
||||||
|
if gid:
|
||||||
|
needed.add(group_to_bridge.get(gid))
|
||||||
|
return needed if needed else {None}
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridge_for_bridge_id(bridge_id: Optional[str]) -> tuple[bool, Optional[str]]:
|
||||||
|
if not bridge_id or not str(bridge_id).strip():
|
||||||
|
return True, None
|
||||||
|
settings = get_settings()
|
||||||
|
profile = find_bridge_profile(settings, bridge_id)
|
||||||
|
if not profile:
|
||||||
|
return False, f"Unknown bridge profile {bridge_id!r}"
|
||||||
|
ok, err = await connect_bridge_profile(profile, settings)
|
||||||
|
if not ok:
|
||||||
|
return False, err or "Bridge connect failed"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridges_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Join each distinct bridge used by these groups (sequential; last stays active)."""
|
||||||
|
bridge_ids = bridge_ids_for_group_ids(group_ids)
|
||||||
|
for bid in ordered_bridge_ids(bridge_ids):
|
||||||
|
ok, err = await ensure_bridge_for_bridge_id(bid)
|
||||||
|
if not ok:
|
||||||
|
return False, err
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridge_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Connect to every bridge referenced by these groups."""
|
||||||
|
if not group_ids:
|
||||||
|
return True, None
|
||||||
|
return await ensure_bridges_for_group_ids(group_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridge_for_group_doc(gdoc: dict) -> tuple[bool, Optional[str]]:
|
||||||
|
if not isinstance(gdoc, dict):
|
||||||
|
return True, None
|
||||||
|
bid = bridge_id_for_group_doc(gdoc)
|
||||||
|
if not bid:
|
||||||
|
return True, None
|
||||||
|
return await ensure_bridge_for_bridge_id(bid)
|
||||||
|
|
||||||
|
|
||||||
|
def count_groups_by_bridge() -> Dict[str, int]:
|
||||||
|
"""Map bridge profile id -> number of groups assigned."""
|
||||||
|
counts: Dict[str, int] = {}
|
||||||
|
groups = Group()
|
||||||
|
for _gid, doc in groups.items():
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
bid = bridge_id_for_group_doc(doc)
|
||||||
|
if bid:
|
||||||
|
counts[bid] = counts.get(bid, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def groups_for_bridge_assignment(bridge_id: str) -> List[Dict[str, Any]]:
|
||||||
|
"""All groups with ``assigned`` flag for bridge profile ``bridge_id``."""
|
||||||
|
bid = str(bridge_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return []
|
||||||
|
groups = Group()
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for gid, doc in groups.items():
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
gbid = bridge_id_for_group_doc(doc)
|
||||||
|
devs = doc.get("devices") if isinstance(doc.get("devices"), list) else []
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": str(gid),
|
||||||
|
"name": str(doc.get("name") or gid),
|
||||||
|
"assigned": gbid == bid,
|
||||||
|
"bridge_id": gbid,
|
||||||
|
"device_count": len(devs),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda row: str(row.get("name") or "").lower())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def assign_groups_to_bridge(
|
||||||
|
bridge_id: str, group_ids: List[str]
|
||||||
|
) -> tuple[bool, Optional[str]]:
|
||||||
|
"""Set ``bridge_id`` on listed groups; clear it on others that used this bridge."""
|
||||||
|
bid = str(bridge_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return False, "bridge_id required"
|
||||||
|
settings = get_settings()
|
||||||
|
if not find_bridge_profile(settings, bid):
|
||||||
|
return False, f"Unknown bridge profile {bid!r}"
|
||||||
|
want = {str(g).strip() for g in group_ids if str(g).strip()}
|
||||||
|
groups = Group()
|
||||||
|
for gid in want:
|
||||||
|
if str(gid) not in groups or not isinstance(groups.read(str(gid)), dict):
|
||||||
|
return False, f"Unknown group id {gid!r}"
|
||||||
|
changed: List[dict] = []
|
||||||
|
for gid, doc in list(groups.items()):
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
continue
|
||||||
|
gsid = str(gid)
|
||||||
|
current = bridge_id_for_group_doc(doc)
|
||||||
|
if gsid in want:
|
||||||
|
if current != bid:
|
||||||
|
groups.update(gsid, {"bridge_id": bid})
|
||||||
|
g = groups.read(gsid)
|
||||||
|
if g:
|
||||||
|
changed.append(g)
|
||||||
|
elif current == bid:
|
||||||
|
groups.update(gsid, {"bridge_id": None})
|
||||||
|
g = groups.read(gsid)
|
||||||
|
if g:
|
||||||
|
changed.append(g)
|
||||||
|
for gdoc in changed:
|
||||||
|
await push_groups_for_group_devices(gdoc)
|
||||||
|
return True, None
|
||||||
67
src/util/bridge_profiles.py
Normal file
67
src/util/bridge_profiles.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Saved ESP-NOW bridge profiles from settings.json."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def normalise_bridges(raw: Any) -> List[Dict[str, Any]]:
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
bid = str(item.get("id") or "").strip() or uuid.uuid4().hex[:12]
|
||||||
|
label = str(item.get("label") or "").strip()
|
||||||
|
transport = str(item.get("transport") or "serial").strip().lower()
|
||||||
|
if transport == "wifi":
|
||||||
|
ssid = str(item.get("ssid") or "").strip()
|
||||||
|
if not ssid:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(item.get("ws_port") or 80)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
port = 80
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": bid,
|
||||||
|
"label": label or ssid,
|
||||||
|
"transport": "wifi",
|
||||||
|
"ssid": ssid,
|
||||||
|
"password": str(item.get("password") or ""),
|
||||||
|
"ap_ip": str(item.get("ap_ip") or "192.168.4.1").strip(),
|
||||||
|
"ws_port": port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
serial_port = str(item.get("serial_port") or "").strip()
|
||||||
|
if not serial_port:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
baud = int(item.get("serial_baudrate") or 921600)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
baud = 921600
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": bid,
|
||||||
|
"label": label or serial_port,
|
||||||
|
"transport": "serial",
|
||||||
|
"serial_port": serial_port,
|
||||||
|
"serial_baudrate": baud,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_bridge_profile(settings: Any, bridge_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
|
if not bridge_id:
|
||||||
|
return None
|
||||||
|
bid = str(bridge_id).strip()
|
||||||
|
if not bid:
|
||||||
|
return None
|
||||||
|
for profile in normalise_bridges(settings.get("bridges")):
|
||||||
|
if profile.get("id") == bid:
|
||||||
|
return profile
|
||||||
|
return None
|
||||||
233
src/util/bridge_runtime.py
Normal file
233
src/util/bridge_runtime.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Start or refresh the bridge client after Wi‑Fi or USB serial connect."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Awaitable, Callable, Optional
|
||||||
|
|
||||||
|
from models.bridge_serial_client import get_bridge_serial_client, init_bridge_serial_client
|
||||||
|
from models.bridge_ws_client import get_bridge_client, init_bridge_client
|
||||||
|
from models.transport import BridgeSerialTransport, BridgeWsTransport, get_current_bridge, set_bridge
|
||||||
|
from settings import WIFI_CHANNEL_DEFAULT
|
||||||
|
from util.bridge_profiles import normalise_bridges
|
||||||
|
from util.pi_wifi import (
|
||||||
|
build_bridge_ws_url,
|
||||||
|
connect_wifi,
|
||||||
|
nmcli_available,
|
||||||
|
ssid_visible,
|
||||||
|
wait_for_device,
|
||||||
|
)
|
||||||
|
|
||||||
|
UplinkHandler = Callable[..., Awaitable[None]]
|
||||||
|
|
||||||
|
_uplink_handler: Optional[UplinkHandler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_bridge_uplink_handler(handler: Optional[UplinkHandler]) -> None:
|
||||||
|
global _uplink_handler
|
||||||
|
_uplink_handler = handler
|
||||||
|
|
||||||
|
|
||||||
|
def _bridge_transport_mode(settings) -> str:
|
||||||
|
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||||
|
return mode if mode in ("wifi", "serial") else "wifi"
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_ws_connected() -> bool:
|
||||||
|
client = get_bridge_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
return client._connected.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_serial_connected() -> bool:
|
||||||
|
client = get_bridge_serial_client()
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
return client._connected.is_set()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_bridge_ws_client() -> None:
|
||||||
|
client = get_bridge_client()
|
||||||
|
if client is not None:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_bridge_serial_client() -> None:
|
||||||
|
client = get_bridge_serial_client()
|
||||||
|
if client is not None:
|
||||||
|
client.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_connected() -> bool:
|
||||||
|
from settings import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
if _bridge_transport_mode(settings) == "serial":
|
||||||
|
return bridge_serial_connected()
|
||||||
|
return bridge_ws_connected()
|
||||||
|
|
||||||
|
|
||||||
|
def active_bridge_profile_id(settings) -> Optional[str]:
|
||||||
|
"""Saved profile id matching the current transport connection, if any."""
|
||||||
|
if not bridge_connected():
|
||||||
|
return None
|
||||||
|
mode = _bridge_transport_mode(settings)
|
||||||
|
from util.pi_wifi import build_bridge_ws_url
|
||||||
|
|
||||||
|
for profile in normalise_bridges(settings.get("bridges")):
|
||||||
|
pid = str(profile.get("id") or "").strip()
|
||||||
|
if not pid:
|
||||||
|
continue
|
||||||
|
if mode == "serial" and profile.get("transport") == "serial":
|
||||||
|
if str(profile.get("serial_port") or "") == str(
|
||||||
|
settings.get("bridge_serial_port") or ""
|
||||||
|
).strip():
|
||||||
|
return pid
|
||||||
|
if mode == "wifi" and profile.get("transport") == "wifi":
|
||||||
|
try:
|
||||||
|
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if url == str(settings.get("bridge_ws_url") or "").strip():
|
||||||
|
return pid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridge_client(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
wifi_channel: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Ensure ``BridgeWsTransport`` is active and pointed at ``url``."""
|
||||||
|
stop_bridge_serial_client()
|
||||||
|
url = str(url or "").strip()
|
||||||
|
if not url:
|
||||||
|
return False
|
||||||
|
ch = wifi_channel if wifi_channel is not None else WIFI_CHANNEL_DEFAULT
|
||||||
|
client = get_bridge_client()
|
||||||
|
if client is None:
|
||||||
|
client = init_bridge_client(url, wifi_channel=ch)
|
||||||
|
if _uplink_handler is not None:
|
||||||
|
client.set_uplink_handler(_uplink_handler)
|
||||||
|
client.start()
|
||||||
|
else:
|
||||||
|
if client._url != url:
|
||||||
|
client._url = url
|
||||||
|
client._wifi_channel = ch
|
||||||
|
if _uplink_handler is not None:
|
||||||
|
client.set_uplink_handler(_uplink_handler)
|
||||||
|
client._signal_disconnect()
|
||||||
|
current = get_current_bridge()
|
||||||
|
if current is None or not hasattr(current, "send_envelope"):
|
||||||
|
set_bridge(BridgeWsTransport())
|
||||||
|
return await client.wait_connected(timeout=30.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_bridge_serial_client(
|
||||||
|
port: str,
|
||||||
|
*,
|
||||||
|
baudrate: int = 921600,
|
||||||
|
) -> bool:
|
||||||
|
"""Ensure ``BridgeSerialTransport`` is active on ``port``."""
|
||||||
|
stop_bridge_ws_client()
|
||||||
|
port = str(port or "").strip()
|
||||||
|
if not port:
|
||||||
|
return False
|
||||||
|
baud = int(baudrate)
|
||||||
|
client = get_bridge_serial_client()
|
||||||
|
if client is None:
|
||||||
|
client = init_bridge_serial_client(port, baudrate=baud)
|
||||||
|
if _uplink_handler is not None:
|
||||||
|
client.set_uplink_handler(_uplink_handler)
|
||||||
|
client.start()
|
||||||
|
set_bridge(BridgeSerialTransport())
|
||||||
|
return await client.wait_connected(timeout=20.0)
|
||||||
|
if client._port != port or client._baudrate != baud:
|
||||||
|
client.stop()
|
||||||
|
client = init_bridge_serial_client(port, baudrate=baud)
|
||||||
|
if _uplink_handler is not None:
|
||||||
|
client.set_uplink_handler(_uplink_handler)
|
||||||
|
client.start()
|
||||||
|
elif _uplink_handler is not None:
|
||||||
|
client.set_uplink_handler(_uplink_handler)
|
||||||
|
client._signal_disconnect()
|
||||||
|
set_bridge(BridgeSerialTransport())
|
||||||
|
return await client.wait_connected(timeout=20.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_bridge_serial(profile: dict, settings) -> tuple[bool, str]:
|
||||||
|
"""Open USB/serial to the bridge and switch transport to serial."""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return False, "Invalid bridge profile"
|
||||||
|
port = str(profile.get("serial_port") or settings.get("bridge_serial_port") or "").strip()
|
||||||
|
if not port:
|
||||||
|
return False, "Serial port not configured"
|
||||||
|
try:
|
||||||
|
baud = int(profile.get("serial_baudrate") or settings.get("bridge_serial_baudrate") or 921600)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
baud = 921600
|
||||||
|
settings["bridge_transport"] = "serial"
|
||||||
|
settings["bridge_serial_port"] = port
|
||||||
|
settings["bridge_serial_baudrate"] = baud
|
||||||
|
settings.save()
|
||||||
|
stop_bridge_ws_client()
|
||||||
|
if not await ensure_bridge_serial_client(port, baudrate=baud):
|
||||||
|
return False, f"Serial bridge not connected ({port})"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_bridge_wifi(profile: dict, settings) -> tuple[bool, str]:
|
||||||
|
"""Join bridge AP and open WebSocket to ``profile``."""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return False, "Invalid bridge profile"
|
||||||
|
ssid = str(profile.get("ssid") or "").strip()
|
||||||
|
if not ssid:
|
||||||
|
return False, "Bridge SSID not configured"
|
||||||
|
device = str(profile.get("wifi_interface") or settings.get("wifi_interface") or "").strip()
|
||||||
|
if not device:
|
||||||
|
return False, "Wi‑Fi interface not configured (Settings → Bridge Wi‑Fi)"
|
||||||
|
if not nmcli_available():
|
||||||
|
return False, "nmcli not found (install NetworkManager)"
|
||||||
|
try:
|
||||||
|
if not await ssid_visible(device, ssid):
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"SSID {ssid!r} not visible on {device} — power on the bridge and scan in Settings",
|
||||||
|
)
|
||||||
|
await connect_wifi(
|
||||||
|
device=device,
|
||||||
|
ssid=ssid,
|
||||||
|
password=str(profile.get("password") or ""),
|
||||||
|
)
|
||||||
|
await wait_for_device(device)
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e).strip()
|
||||||
|
if err.startswith("Error:"):
|
||||||
|
err = err[6:].strip()
|
||||||
|
return False, err or "Wi‑Fi connect failed"
|
||||||
|
try:
|
||||||
|
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
|
||||||
|
except ValueError as e:
|
||||||
|
return False, str(e)
|
||||||
|
try:
|
||||||
|
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ch = WIFI_CHANNEL_DEFAULT
|
||||||
|
settings["bridge_transport"] = "wifi"
|
||||||
|
settings["bridge_ws_url"] = url
|
||||||
|
settings["wifi_interface"] = device
|
||||||
|
settings.save()
|
||||||
|
stop_bridge_serial_client()
|
||||||
|
if not await ensure_bridge_client(url, wifi_channel=ch):
|
||||||
|
return False, f"WebSocket bridge not connected ({url})"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
async def connect_bridge_profile(profile: dict, settings) -> tuple[bool, str]:
|
||||||
|
"""Connect using a saved bridge profile (serial or wifi)."""
|
||||||
|
if not isinstance(profile, dict):
|
||||||
|
return False, "Invalid bridge profile"
|
||||||
|
transport = str(profile.get("transport") or "serial").strip().lower()
|
||||||
|
if transport == "wifi":
|
||||||
|
return await connect_bridge_wifi(profile, settings)
|
||||||
|
return await connect_bridge_serial(profile, settings)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user