36 Commits

Author SHA1 Message Date
d682753e42 chore(submodules): bump led-driver
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
53976cdd70 chore(scripts): add mpremote ESP-NOW ch5 send helpers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
94635a8cc7 chore(db): add devices to test group
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:01:01 +12:00
de0547615c feat(ui): add device from devices modal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:59 +12:00
78dc8ffc77 feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:38:21 +12:00
2cf019079e chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 22:03:24 +12:00
b87382d2be feat(espnow): broadcast delivery with group-filtered routing
Send presets and select on broadcast with groups; unicast only for
per-device settings. V1 select as [preset_id, step?]. Sequence steps
use beat counts; manual presets get select each beat, auto only on
step change. Bridge downlink router, Pi envelope delivery, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:44:28 +12:00
1a69fabd98 fix(espnow): bridge async rx, uplink framing, driver RX handling
Bridge uses async for on AIOESPNow, pack_ws_uplink to Pi, AP channel
from settings. Driver applies binary wire and JSON commands on receive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:45:13 +12:00
4fc3f46866 feat(espnow): Pi bridge client, binary wire, and espnow-sender firmware
Replace serial/Wi-Fi driver transport paths with WebSocket bridge client,
binary espnow_wire delivery, device announce registry, and restructured
espnow-sender (AP + broadcast passthrough). Includes docs and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:44:44 +12:00
f4ef85c182 chore(db): add test group and enable auto on chase/pulse
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:07:37 +12:00
f02eaa6bad chore(submodules): bump led-tool for Web Serial fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7015032f5c test: cover zone content kind lock and sequence groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
d7a3fa96c5 feat(db): add Winter profile with 2x3 grid sequences
Winter profile, scoped groups, presets, and five multi-lane sequences;
include setup script for regeneration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7a7bedc07c fix(sequences): target only checked lane groups
Use zone group checkboxes in the editor; empty lane groups no longer
fall back to the whole zone. Remove cross-lane device splitting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
baec87068a feat(ui): lock zone type and start audio from BPM
Zone preset vs sequence is fixed at create; edit shows read-only type.
Header BPM button starts beat detection when the detector is stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:15 +12:00
b140aedf00 chore(submodules): bump led-tool for settings editor
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:24 +12:00
15f8c8a039 fix(wifi): limit outbound driver WS to hello-triggered attempts
Remove periodic UDP hello loop; dial each driver at most
wifi_driver_initial_connect_attempts times per discovery hello.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:22 +12:00
70641c63af feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial
ports, and load the modal via iframe instead of the legacy form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:18 +12:00
ef15c54593 chore(submodules): bump led-driver and led-tool for file_hashes deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:54 +12:00
301e1c64bf test: cover audio, sequences, pattern direction, and settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
c286e504eb feat(ui): numpad, audio readout, and sequence beat controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
964cfc6d91 feat(audio-sequences): beat phase sync and aligned playback
Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase
sequence switching, sync-phase endpoint, and sample sequence data.

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 17:06:56 +12:00
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
150 changed files with 23292 additions and 2615 deletions

View File

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

11
.gitignore vendored
View File

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

14
Pipfile
View File

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

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

View File

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

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

@@ -0,0 +1,22 @@
# bridge-wifi
ESP32 ESP-NOW bridge with **WiFi 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`.

View File

@@ -0,0 +1,2 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

View 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 _

File diff suppressed because it is too large Load Diff

View 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

View 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

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

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

View 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

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

View File

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

View File

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

View File

@@ -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 (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"particles": {
"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 (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
}
}

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41", "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}}

View File

@@ -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
View 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
![Three-node ESP-NOW architecture](images/espnow/system-overview.svg)
| 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
![Boot and registration sequence](images/espnow/boot-sequence.svg)
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
![Packet layer stack](images/espnow/packet-layers.svg)
### 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) |
![Message types](images/espnow/message-types.svg)
| 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 111 |
### 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 0127 (→ 0255); `128255` = 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) |

View 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 0255 |
| 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 (50500 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 (111) |
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 |
| 16 | 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 0127 (maps to 0255); 128255 = unchanged |
| 2 | Presets section length |
| 3 | Select section length |
| 4 | Default section length |
See `binary_envelope.py` for blob layouts.

View File

@@ -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 devices transport. This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **ESP-NOW bridge** (WebSocket) or **Wi-Fi** (TCP to drivers on the LAN), depending on each devices 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**.

View 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

View 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

View 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 111</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

View 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

View 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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
# Upload and run a device-side ESP-NOW sender script.
# Default channel is 5 and default destination is broadcast.
#
# Usage:
# scripts/mpremote_send_ch5.sh [port] [dest_mac_hex] [payload_hex]
#
# Examples:
# scripts/mpremote_send_ch5.sh /dev/ttyACM0
# scripts/mpremote_send_ch5.sh /dev/ttyACM0 ffffffffffff 4c0501000000
PORT="${1:-/dev/ttyACM0}"
DEST_HEX="${2:-ffffffffffff}"
PAYLOAD_HEX="${3:-4c0501000000}"
CHANNEL=5
DEVICE_SCRIPT="send_ch5.py"
mpremote connect "${PORT}" fs cp "scripts/mpremote_send_ch5_device.py" ":${DEVICE_SCRIPT}"
mpremote connect "${PORT}" exec "
import ${DEVICE_SCRIPT%.*}
${DEVICE_SCRIPT%.*}.send_once('${DEST_HEX}', '${PAYLOAD_HEX}', ${CHANNEL})
"

View File

@@ -0,0 +1,42 @@
"""Device-side ESP-NOW sender (MicroPython, channel 5)."""
import espnow
import network
import ubinascii
CHANNEL = 5
DEST_HEX = "ffffffffffff"
PAYLOAD_HEX = "4c0501000000"
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def _add_peer(esp, dest, channel):
try:
esp.add_peer(dest, channel=channel)
except TypeError:
esp.add_peer(dest)
except OSError:
pass
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
dest = ubinascii.unhexlify(dest_hex)
pkt = ubinascii.unhexlify(payload_hex)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
_add_peer(e, dest, channel)
ok = e.send(dest, pkt, True)
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
return ok
if __name__ == "__main__":
send_once()

View File

@@ -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 0255")
if b < 0 or b > 255:
raise ValueError("output_brightness must be between 0 and 255")
return b
def _brightness_save_message_json(b_val: int) -> str:
b_val = max(0, min(255, int(b_val)))
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
controller = Microdot() controller = Microdot()
devices = Device() devices = Device()
_group_registry = Group()
_pi_settings = 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 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = request.json or {}
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return json.dumps({"error": "macs must be an array"}), 400, {
"Content-Type": "application/json",
}
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
values = {}
for raw in macs:
m = normalize_mac(str(raw))
if not m:
continue
values[m] = effective_brightness_for_mac(
_pi_settings,
_group_registry,
devices,
m,
zone_brightness=zb,
)
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
@controller.get("/<id>") @controller.get("/<id>")
async def get_device(request, id): async def get_device(request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence).""" """Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
@@ -239,7 +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``). WiFi or ESPNOW.
"""
dev = devices.read(id) dev = devices.read(id)
if not dev: if not dev:
return json.dumps({"error": "Device not found"}), 404, { return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json", "Content-Type": "application/json",
} }
sender = get_current_sender() body = request.json or {}
if not sender: zb = None
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",
}

View File

@@ -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 WiFi defaults (non-empty only)."""
dc = {}
if not isinstance(doc, dict):
return dc
nm = doc.get("wifi_driver_display_name")
if isinstance(nm, str) and nm.strip():
dc["name"] = nm.strip()
nled = doc.get("wifi_driver_num_leds")
if nled is not None:
try:
n = int(nled)
if 1 <= n <= 2048:
dc["num_leds"] = n
except (TypeError, ValueError):
pass
co = doc.get("wifi_color_order")
if isinstance(co, str):
c = co.strip().lower()
if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
dc["color_order"] = c
sm = doc.get("wifi_startup_mode")
if isinstance(sm, str):
s = sm.strip().lower()
if s in ("default", "last", "off"):
dc["startup_mode"] = s
return dc
def _read_group_for_session(session, id):
g = groups.read(id)
if not g or not isinstance(g, dict):
return None
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return None
return g
@controller.post("/<id>/driver-config")
@with_session
async def push_group_driver_config(request, session, id):
"""
Push group 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"}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +1,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"}

View File

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

View File

@@ -0,0 +1,282 @@
"""Pi WiFi 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": "WiFi 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",
}

View File

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

View File

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

View 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

View 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

View File

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

View File

@@ -1,14 +1,75 @@
from models.model import Model from models.model import Model
class Group(Model): class Group(Model):
"""Device groups (members + optional WiFi driver defaults); also pattern fields for sequences.
Omit ``profile_id`` (or set it null) for a **shared** group: every profile can attach it to
zones and sequences. Set ``profile_id`` to a profile id to show the group only when that
profile is active (still one global record in ``group.json``).
"""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
def load(self):
super().load()
changed = False
for gid, doc in list(self.items()):
if not isinstance(doc, dict):
continue
if self._migrate_record(doc):
changed = True
if changed:
self.save()
def _migrate_record(self, doc):
changed = False
raw_dev = doc.get("devices")
if raw_dev is None:
doc["devices"] = []
changed = True
elif isinstance(raw_dev, list):
norm = []
for x in raw_dev:
if x is None:
continue
s = str(x).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
norm.append(s)
else:
norm.append(str(x).strip())
if norm != raw_dev:
doc["devices"] = norm
changed = True
for key in (
"wifi_driver_display_name",
"wifi_driver_num_leds",
"wifi_color_order",
"wifi_startup_mode",
):
if key not in doc:
doc[key] = None
changed = True
if "output_brightness" not in doc:
doc["output_brightness"] = 255
changed = True
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

View File

@@ -15,6 +15,9 @@ class Preset(Model):
if default_profile_id is not None: if default_profile_id is not None:
preset_data["profile_id"] = str(default_profile_id) preset_data["profile_id"] = str(default_profile_id)
changed = True changed = True
if isinstance(preset_data, dict) and "group_ids" in preset_data:
preset_data.pop("group_ids", None)
changed = True
if changed: if changed:
self.save() self.save()
except Exception: except Exception:
@@ -26,6 +29,7 @@ class Preset(Model):
"name": "", "name": "",
"pattern": "", "pattern": "",
"colors": [], "colors": [],
"background": "#000000",
"brightness": 0, "brightness": 0,
"delay": 0, "delay": 0,
"n1": 0, "n1": 0,
@@ -36,6 +40,7 @@ class Preset(Model):
"n6": 0, "n6": 0,
"n7": 0, "n7": 0,
"n8": 0, "n8": 0,
"manual_beat_n": 1,
"profile_id": str(profile_id) if profile_id is not None else None, "profile_id": str(profile_id) if profile_id is not None else None,
} }
self.save() self.save()

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

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,11 @@ def _maybe_migrate_tab_json_to_zone():
class Zone(Model): class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.""" """Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). 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

View File

@@ -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; 111 # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
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 (0255); shared across browsers/devices. # Zone UI global brightness (0255); 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, 0200).
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
View 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
View File

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

View File

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

View File

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

@@ -0,0 +1,565 @@
// Device groups: members (MAC ids) + WiFi driver defaults; persisted via /groups.
// Without ``profile_id``, a group is shared across all profiles; with ``profile_id`` it is listed only for that profile.
async function getCurrentProfileIdForGroups() {
try {
const res = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!res.ok) return null;
const data = await res.json();
const id = data && (data.id || (data.profile && data.profile.id));
return id != null ? String(id) : null;
} catch {
return null;
}
}
async function fetchGroupsMap() {
try {
const response = await fetch('/groups', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchGroupsMap:', e);
return {};
}
}
async function fetchDevicesMapForGroups() {
try {
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
if (!response.ok) return {};
const data = await response.json();
return data && typeof data === 'object' ? data : {};
} catch (e) {
console.error('fetchDevicesMapForGroups:', e);
return {};
}
}
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
if (!containerEl) return;
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 WiFi defaults.';
container.appendChild(p);
return;
}
ids.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
ids.forEach((gid) => {
const g = groups[gid];
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.5rem';
row.style.flexWrap = 'wrap';
const label = document.createElement('span');
const devs = Array.isArray(g.devices) ? g.devices : [];
label.textContent = `${g.name || gid} (${devs.length} device${devs.length === 1 ? '' : 's'})`;
const meta = document.createElement('div');
meta.className = 'muted-text';
meta.style.fontSize = '0.8em';
const rawPid = g.profile_id != null ? g.profile_id : g.profileId;
const scoped = rawPid != null && String(rawPid).trim() !== '';
meta.textContent = scoped ? `This profile only (${rawPid})` : 'Shared across profiles';
const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => openEditGroupModal(gid, g));
const brightBtn = document.createElement('button');
brightBtn.className = 'btn btn-secondary btn-small';
brightBtn.type = 'button';
brightBtn.textContent = 'Apply brightness';
brightBtn.title = 'Push group output brightness to WiFi drivers in this group';
brightBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/brightness`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply brightness failed');
return;
}
} catch (err) {
console.error(err);
alert('Apply brightness failed');
}
});
const applyBtn = document.createElement('button');
applyBtn.className = 'btn btn-primary btn-small';
applyBtn.type = 'button';
applyBtn.textContent = 'Apply defaults to drivers';
applyBtn.title = 'Push WiFi defaults to each connected driver in this group';
applyBtn.addEventListener('click', async () => {
try {
const res = await fetch(`/groups/${encodeURIComponent(gid)}/driver-config`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.error || 'Apply failed');
return;
}
} 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);
}
};
});

View File

@@ -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' : 'WiFi';
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' : 'WiFi'],
[
'WiFi 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'
? `WiFi ${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 || 'WiFi 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 WiFi 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} — WiFi ${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 WiFi 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('WiFi 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');
}
}); });
} }
}); });

View File

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

@@ -0,0 +1,117 @@
/**
* Bluetooth / USB HID numpad shortcuts (browser focus required).
*
* Numpad19,0 → zone 110 (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));
});
})();

View File

@@ -33,6 +33,33 @@ document.addEventListener('DOMContentLoaded', () => {
return Number.isFinite(t) ? t : def; return Number.isFinite(t) ? t : def;
}; };
const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') {
return true;
}
const v =
preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a;
if (typeof v === 'boolean') {
return v;
}
if (v === 0 || v === '0') {
return false;
}
if (v === 1 || v === '1') {
return true;
}
if (typeof v === 'string') {
const l = v.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(l)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(l)) {
return true;
}
}
return true;
};
const getCurrentProfileId = async () => { const getCurrentProfileId = async () => {
try { try {
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -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

View File

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

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

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

View File

@@ -1,6 +1,12 @@
// Zone management JavaScript // Zone management JavaScript
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null; let brightnessSendTimeout = null;
/**
* When true, the next `loadZoneContent` skips `sendZoneBrightness` (run/edit toggle: same zone, UI only).
*/
let suppressZoneContentDriverSideEffects = false;
/** First successful `loadZoneContent` after open: skip hardware brightness push (read-only hydration). */
let isFirstZoneContentHydration = true;
function clamp255(n) { function clamp255(n) {
const v = parseInt(n, 10); const v = parseInt(n, 10);
@@ -64,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, "&lt;"); .replace(/</g, "&lt;");
} }
/** @returns {null | 'presets' | 'sequences'} */
function normalizeZoneContentKind(zoneDoc) {
const k = zoneDoc && zoneDoc.content_kind;
if (k === 'presets' || k === 'sequences') return k;
return null;
}
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
function effectiveZoneContentKind(zoneDoc) {
const explicit = normalizeZoneContentKind(zoneDoc);
if (explicit) return explicit;
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
? zoneDoc.sequence_ids.filter(Boolean)
: [];
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
return 'presets';
}
/** @returns {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;

View File

@@ -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 + WiFi driver defaults (zones reference groups for presets) -->
<div id="groups-modal" class="modal">
<div class="modal-content">
<h2>Device groups</h2>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
<input type="checkbox" id="new-group-profile-only"> This profile only
</label>
<button class="btn btn-primary" id="create-group-btn">Create</button>
</div>
<div id="groups-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-group-modal" class="modal">
<div class="modal-content">
<h2>Edit device group</h2>
<form id="edit-group-form">
<input type="hidden" id="edit-group-id">
<label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
</label>
<label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
</div>
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-group-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">WiFi driver defaults (apply to all members via <strong>Apply defaults to drivers</strong> on the list)</p>
<label for="edit-group-wifi-driver-name">Display name</label>
<input type="text" id="edit-group-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-group-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-group-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-group-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-group-wifi-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
<label for="edit-group-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-group-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<div id="edit-device-modal" class="modal"> <div id="edit-device-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<h2>Edit device</h2> <h2>Edit device</h2>
@@ -154,6 +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 WiFi when connected). For shared defaults across several drivers, use <strong>Groups</strong>.</p>
<label for="edit-device-wifi-driver-name">Display name</label>
<input type="text" id="edit-device-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-device-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-device-wifi-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-device-wifi-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-device-wifi-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
<label for="edit-device-wifi-startup-mode" style="margin-top:0.5rem;display:block;">Power-on pattern</label>
<select id="edit-device-wifi-startup-mode">
<option value="default">Default preset</option>
<option value="last">Last preset</option>
<option value="off">Off</option>
</select>
</div>
<label for="edit-device-output-brightness" style="margin-top:0.75rem;display:block;">Output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-device-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-device-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<small class="muted-text" style="display:block;margin-top:0.25rem;">Saved on the device; use <strong>Save</strong> to push to the driver (when connected).</small>
<label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions"> <div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button> <button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
@@ -168,6 +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, WiFi driver defaults, then assign groups to zones.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li> <li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current zone to all zone devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li> <li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul> </ul>
<h3>Edit mode</h3> <h3>Edit mode</h3>
<ul> <ul>
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li> <li><strong>Tabs</strong>: create, edit, and manage zones and which <strong>device groups</strong> each zone drives.</li>
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li> <li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li> <li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li> <li><strong>Reorder presets</strong>: drag and drop preset tiles to save zone order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li> <li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> zone and can optionally seed a <strong>DJ zone</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li> <li><strong>Devices</strong>: registry rows are keyed by <strong>MAC</strong>; edit a device for transport/IP and per-driver WiFi settings, or use <strong>Groups</strong> for shared defaults.</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li> <li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul> </ul>
<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 WiFi 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 (111) 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">WiFi</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://&lt;bridge-ip&gt;/ws</code>.</p>
<div class="form-group"> <div class="form-group">
<label for="ap-password">AP Password</label> <label for="bridge-wifi-interface">WiFi 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 WiFi</button>
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save WiFi 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>

View File

@@ -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 (111) for LED drivers and the serial bridge. Use the same value on every device.</small> <small>2.4&nbsp;GHz channel (111) 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 (111). 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 }),

View File

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

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

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

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

View File

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

151
src/util/bridge_envelope.py Normal file
View 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

View 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

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

@@ -0,0 +1,233 @@
"""Start or refresh the bridge client after WiFi 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, "WiFi interface not configured (Settings → Bridge WiFi)"
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 "WiFi 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