23 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
124 changed files with 13846 additions and 2967 deletions

View File

@@ -6,6 +6,7 @@ name = "pypi"
[packages]
mpremote = "*"
pyserial = "*"
pyserial-asyncio = "*"
esptool = "*"
pyjwt = "*"
watchfiles = "*"

435
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "7975a888034cb3e0f68c7b866f7e69e5a1db7a5228fe1f02d6cb5f9c180de557"
"sha256": "2387dc49cb5166bfd75072d96e74e8fdac3b60afccba34d8bdca3d9da1552b80"
},
"pipfile-spec": 6,
"requires": {
@@ -40,13 +40,6 @@
"markers": "python_version >= '3.9'",
"version": "==26.1.0"
},
"aubio": {
"hashes": [
"sha256:df1244f6c4cf5bea382c8c2d35aa43bc31f4cf631fe325ae3992c219546a4202"
],
"index": "pypi",
"version": "==0.4.9"
},
"bitarray": {
"hashes": [
"sha256:0256d57e294414bfe4fec4f852fd1d9ae361228c71b082332bf81c8b8fc69f80",
@@ -166,11 +159,11 @@
},
"certifi": {
"hashes": [
"sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
"sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"
"sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897",
"sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"
],
"markers": "python_version >= '3.7'",
"version": "==2026.4.22"
"version": "==2026.5.20"
},
"cffi": {
"hashes": [
@@ -399,11 +392,11 @@
},
"click": {
"hashes": [
"sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2",
"sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
"sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2",
"sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"
],
"markers": "python_version >= '3.10'",
"version": "==8.3.3"
"version": "==8.4.1"
},
"cryptography": {
"hashes": [
@@ -478,11 +471,11 @@
},
"idna": {
"hashes": [
"sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242",
"sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
"sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5",
"sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"
],
"markers": "python_version >= '3.8'",
"version": "==3.13"
"markers": "python_version >= '3.9'",
"version": "==3.16"
},
"intelhex": {
"hashes": [
@@ -509,12 +502,12 @@
},
"microdot": {
"hashes": [
"sha256:3ba8bab39ae52bca08ee7024dfc71afb7cff089f0b6611d2a1f617abfcee749c",
"sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721"
"sha256:206c52870e3b1d5e6d387802e2ed0afae8c4598c80542a21e3efe377efc128c8",
"sha256:7bb9a69fa97a47d8fe07e61d9dd405804744132ca52d26705cf1173431ff7f4b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.1"
"version": "==2.6.2"
},
"mpremote": {
"hashes": [
@@ -527,82 +520,82 @@
},
"numpy": {
"hashes": [
"sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed",
"sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50",
"sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959",
"sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827",
"sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd",
"sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233",
"sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc",
"sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b",
"sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7",
"sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e",
"sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a",
"sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d",
"sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3",
"sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e",
"sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb",
"sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a",
"sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0",
"sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e",
"sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113",
"sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103",
"sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93",
"sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af",
"sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5",
"sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7",
"sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392",
"sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c",
"sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4",
"sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40",
"sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf",
"sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44",
"sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b",
"sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5",
"sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e",
"sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74",
"sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0",
"sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e",
"sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec",
"sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015",
"sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d",
"sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d",
"sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842",
"sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150",
"sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8",
"sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a",
"sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed",
"sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f",
"sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008",
"sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e",
"sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0",
"sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e",
"sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f",
"sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a",
"sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40",
"sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7",
"sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83",
"sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d",
"sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c",
"sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871",
"sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502",
"sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252",
"sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8",
"sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115",
"sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f",
"sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e",
"sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d",
"sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0",
"sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119",
"sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e",
"sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db",
"sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121",
"sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d",
"sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e"
"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.4"
"version": "==2.4.6"
},
"outcome": {
"hashes": [
@@ -638,12 +631,12 @@
},
"pyjwt": {
"hashes": [
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
"sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423",
"sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1"
"version": "==2.13.0"
},
"pyserial": {
"hashes": [
@@ -653,6 +646,14 @@
"index": "pypi",
"version": "==3.5"
},
"pyserial-asyncio": {
"hashes": [
"sha256:b6032923e05e9d75ec17a5af9a98429c46d2839adfaf80604d52e0faacd7a32f",
"sha256:de9337922619421b62b9b1a84048634b3ac520e1d690a674ed246a2af7ce1fc5"
],
"index": "pypi",
"version": "==0.6"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
@@ -757,12 +758,12 @@
},
"requests": {
"hashes": [
"sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517",
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
"sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0",
"sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.1"
"version": "==2.34.2"
},
"rich": {
"hashes": [
@@ -782,12 +783,12 @@
},
"selenium": {
"hashes": [
"sha256:4f97639055dcfa9eadf8ccf549ba7b0e49c655d4e2bde19b9a44e916b754e769",
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
"sha256:b03a831fcfcab9d912b4682f60718c48a04560d6c62f7496c16b7498c9a4427e",
"sha256:d01ea3e5ecad8149460a765f7cf5177194c21dcc0173093fc05427c289b1bf24"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0"
"version": "==4.44.0"
},
"sniffio": {
"hashes": [
@@ -883,127 +884,125 @@
"socks"
],
"hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
"sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c",
"sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.3"
"markers": "python_version >= '3.10'",
"version": "==2.7.0"
},
"watchfiles": {
"hashes": [
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
"sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43",
"sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510",
"sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0",
"sha256:08af70fd77eee58549cd69c25055dc344f918d992ff626068242259f98d598a2",
"sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b",
"sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18",
"sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219",
"sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3",
"sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4",
"sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803",
"sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94",
"sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6",
"sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce",
"sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099",
"sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae",
"sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4",
"sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43",
"sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd",
"sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10",
"sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374",
"sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051",
"sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d",
"sha256:3dbd8cbadd46984f802f6d479b7e3afa86c42d13e8f0f322d669d79722c8ec34",
"sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49",
"sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7",
"sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844",
"sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77",
"sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b",
"sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741",
"sha256:4b943d3668d61cfa528eb949577479d3b077fd25fb83c641235437bc0b5bc60e",
"sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33",
"sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42",
"sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab",
"sha256:5524298e3827105b61951a29c3512deb9578586abf3a7c5da4a8069df247cccc",
"sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5",
"sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da",
"sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e",
"sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05",
"sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a",
"sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d",
"sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701",
"sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863",
"sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2",
"sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101",
"sha256:6c3631058c37e4a0ec440bf583bc53cdbd13e5661bb6f465bc1d88ee9a0a4d02",
"sha256:6c9c9262f454d1c4d8aaa7050121eb4f3aea197360553699520767daebf2180b",
"sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6",
"sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb",
"sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620",
"sha256:74472234c8370669850e1c312490f6026d132ca2d396abfad8830b4f1c096957",
"sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6",
"sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d",
"sha256:79ff6c6eadf2e3fc0d7786331362e6ef1e51125892c75f1004bd6b52155fb956",
"sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef",
"sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261",
"sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02",
"sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af",
"sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9",
"sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21",
"sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336",
"sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d",
"sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c",
"sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31",
"sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81",
"sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9",
"sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff",
"sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2",
"sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e",
"sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc",
"sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404",
"sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01",
"sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18",
"sha256:acb08650863767cbc58bca4813b92df4d6c648459dcaa3d4155681962b2aa2d3",
"sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606",
"sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04",
"sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3",
"sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14",
"sha256:b9c4702f29ca48e023ffd9b7ff6b822acdf47cb1ff44cb490a3f1d5ec8987e9c",
"sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82",
"sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610",
"sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0",
"sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150",
"sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5",
"sha256:c1f5210f1b8fc91ead1283c6fd89f70e76fb07283ec738056cf34d51e9c1d62c",
"sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a",
"sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b",
"sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d",
"sha256:c882d69f6903ef6092bedfb7be973d9319940d56b8427ab9187d1ecd73438a70",
"sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70",
"sha256:cdab464fee731e0884c35ae3588514a9bcf718d0e2c82169c1c4a85cc19c3c7f",
"sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24",
"sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e",
"sha256:cf57a27fb986c6243d2ee78392c503826056ffe0287e8794503b10fb51b881be",
"sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5",
"sha256:d6ff426a7cb54f310d51bfe83fe9f2bbe40d540c741dc974ebc30e6aa238f52e",
"sha256:d7e7067c98040d646982daa1f37a33d3544138ea155536c2e0e63e07ff8a7e0f",
"sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88",
"sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb",
"sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849",
"sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d",
"sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c",
"sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44",
"sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac",
"sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428",
"sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b",
"sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5",
"sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa",
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
"sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9",
"sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98",
"sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551",
"sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d",
"sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7",
"sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db",
"sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69",
"sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242",
"sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925",
"sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f",
"sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5",
"sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5",
"sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427",
"sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19",
"sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4",
"sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e",
"sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa",
"sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba",
"sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df",
"sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c",
"sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906",
"sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65",
"sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c",
"sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c",
"sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30",
"sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077",
"sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374",
"sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01",
"sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33",
"sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831",
"sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9",
"sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2",
"sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b",
"sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f",
"sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658",
"sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579",
"sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5",
"sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0",
"sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7",
"sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666",
"sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5",
"sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201",
"sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103",
"sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6",
"sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8",
"sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1",
"sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631",
"sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898",
"sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d",
"sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44",
"sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2",
"sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5",
"sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a",
"sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1",
"sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b",
"sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc",
"sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5",
"sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377",
"sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8",
"sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add",
"sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281",
"sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9",
"sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994",
"sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0",
"sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e",
"sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0",
"sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28",
"sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7",
"sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55",
"sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb",
"sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07",
"sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb",
"sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4",
"sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0",
"sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e",
"sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4",
"sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9",
"sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06",
"sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26",
"sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7",
"sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4",
"sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3",
"sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3",
"sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838",
"sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71",
"sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488",
"sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717",
"sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d",
"sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44",
"sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2",
"sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b",
"sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2",
"sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22",
"sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6",
"sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e",
"sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310",
"sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165",
"sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5",
"sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799",
"sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8",
"sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7",
"sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379",
"sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925",
"sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72",
"sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4",
"sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08",
"sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1"
"markers": "python_version >= '3.10'",
"version": "==1.2.0"
},
"websocket-client": {
"hashes": [

View File

@@ -1,9 +1,11 @@
# 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).
- **Wi-Fi LED drivers**: TCP JSON lines (default port **8765** on the Pi; drivers discover the controller via **UDP 8766** broadcast).
- **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`).
- **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

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

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"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": []}
{"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

@@ -12,6 +12,7 @@
"supports_manual": true
},
"colour_cycle": {
"supports_reverse": true,
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
@@ -29,6 +30,7 @@
"supports_manual": false
},
"chase": {
"supports_reverse": true,
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
@@ -155,6 +157,7 @@
"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)",
@@ -169,6 +172,7 @@
"supports_manual": true
},
"icicles": {
"supports_reverse": true,
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
@@ -179,6 +183,7 @@
"supports_manual": true
},
"blizzard": {
"supports_reverse": true,
"n1": "Flake density",
"n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
@@ -227,6 +232,7 @@
"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)",
@@ -242,6 +248,7 @@
"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)",

File diff suppressed because one or more lines are too long

View File

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

@@ -3,7 +3,7 @@
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.
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`).
@@ -52,7 +52,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
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.
- 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
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**.

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

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

@@ -7,14 +7,9 @@ from models.device import (
validate_device_type,
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from models.transport import get_current_bridge
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
send_json_line_to_ip,
tcp_client_connected,
)
from util.driver_patterns import driver_patterns_dir
from util.espnow_message import build_message
import asyncio
@@ -77,21 +72,12 @@ def _brightness_save_message_json(b_val: int) -> str:
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
_pi_settings = get_settings()
def _device_live_connected(dev_dict):
"""
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":
"""ESP-NOW has no live session flag on the Pi."""
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):
@@ -155,14 +141,24 @@ def _http_post_pattern_source(ip, filename, code_text, reload_patterns=True, tim
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:
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
off_msg = build_message(select={name: ["off"]})
if transport == "wifi":
await send_json_line_to_ip(wifi_ip, off_msg)
else:
await sender.send(off_msg, addr=dev_id)
await bridge.send(
{"v": "1", "select": ["off"]},
addr=dev_id,
)
except Exception:
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
@@ -177,95 +173,77 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
dev = devices.read(dev_id)
if not dev:
return 404, "Device not found"
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return 503, "Transport not configured"
name = str(dev.get("name") or "").strip()
if not name:
return 400, "Device must have a name to identify"
transport = dev.get("transport") or "espnow"
wifi_ip = None
if transport == "wifi":
wifi_ip = dev.get("address")
if not wifi_ip:
return 400, "Device has no IP address"
try:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select={name: [_IDENTIFY_PRESET_KEY]},
ok = await bridge.send(
{
"v": "1",
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
"select": [_IDENTIFY_PRESET_KEY],
},
addr=dev_id,
)
if transport == "wifi":
ok = await send_json_line_to_ip(wifi_ip, msg)
if not ok:
return 503, "Wi-Fi driver not connected"
else:
await sender.send(msg, addr=dev_id)
return 503, "Send failed"
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
_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]) -> tuple[int, list[dict]]:
async def send_identify_to_group_devices(
macs: list[str],
*,
group_ids: list[str] | None = None,
) -> tuple[int, list[dict]]:
"""
Identify every listed registry MAC in one delivery round: merged ``select`` and a single
ESP-NOW split envelope when multiple peers share the serial bridge (avoids per-device
``SerialSender`` lock serialisation). Wi-Fi peers are sent in parallel as in
``deliver_json_messages``.
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] = []
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return 0, [{"mac": "*", "error": "Transport not configured"}]
merged_select: dict[str, list[str]] = {}
valid_macs: list[str] = []
for dev_id in macs:
dev = devices.read(dev_id)
if not dev:
errors.append({"mac": dev_id, "error": "Device not found"})
continue
name = str(dev.get("name") or "").strip()
if not name:
errors.append({"mac": dev_id, "error": "Device must have a name to identify"})
continue
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
if not dev.get("address"):
errors.append({"mac": dev_id, "error": "Device has no IP address"})
continue
merged_select[name] = [_IDENTIFY_PRESET_KEY]
valid_macs.append(dev_id)
if not merged_select:
return 0, errors
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:
msg = _compact_v1_json(
presets={_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
select=merged_select,
deliveries, _chunks = await deliver_json_messages(
bridge,
[json.dumps(body, separators=(",", ":"))],
None,
devices,
delay_s=0,
)
await deliver_json_messages(sender, [msg], valid_macs, devices, delay_s=0)
except Exception as e:
return 0, errors + [{"mac": "*", "error": str(e)}]
for dev_id in valid_macs:
dev = devices.read(dev_id) or {}
name = str(dev.get("name") or "").strip()
transport = (dev.get("transport") or "espnow").strip().lower()
wifi_ip = dev.get("address") if transport == "wifi" else None
asyncio.create_task(
_identify_send_off_after_delay(sender, transport, wifi_ip, dev_id, name)
)
if deliveries < 1:
return 0, errors + [{"mac": "*", "error": "Send failed"}]
return len(valid_macs), errors
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("")
@@ -435,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")
async def identify_device(request, id):
"""
@@ -476,28 +494,17 @@ async def push_device_output_brightness(request, id):
zone_brightness=zb,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
ok = await send_json_line_to_ip(ip, msg)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
"Content-Type": "application/json",
}
else:
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
@@ -509,7 +516,7 @@ async def push_device_output_brightness(request, id):
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
"""
Push ``device_config`` to a WiFi LED driver over WebSocket.
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)
@@ -517,13 +524,9 @@ async def push_driver_config(request, id):
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
body = request.json or {}
@@ -551,12 +554,9 @@ async def push_driver_config(request, id):
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
ok = await send_json_line_to_ip(wifi_ip, msg)
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "driver-config sent"}), 200, {
@@ -567,71 +567,13 @@ async def push_driver_config(request, id):
@controller.post("/<id>/patterns/push")
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)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern OTA push is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json",
}
wifi_ip = str(dev.get("address") or "").strip()
if not wifi_ip:
return json.dumps({"error": "Device has no IP address"}), 400, {
"Content-Type": "application/json",
}
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",
}
return json.dumps(
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
), 400, {"Content-Type": "application/json"}

View File

@@ -3,16 +3,16 @@ from microdot.session import with_session
import asyncio
from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from 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
controller = Microdot()
groups = Group()
devices = Device()
_pi_settings = Settings()
_pi_settings = get_settings()
def _group_doc_visible_for_profile(doc, profile_id):
@@ -62,6 +62,12 @@ async def get_group(request, session, id):
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):
@@ -92,6 +98,7 @@ async def create_group(request, session):
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)
if data:
groups.update(group_id, data)
@@ -101,6 +108,9 @@ async def create_group(request, session):
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:
return json.dumps({"error": str(e)}), 400
@@ -116,9 +126,11 @@ async def update_group(request, session, id):
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):
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
except Exception as e:
@@ -135,7 +147,9 @@ async def delete_group(request, session, 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):
await push_groups_for_group_devices({"devices": macs})
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
@@ -184,7 +198,7 @@ def _read_group_for_session(session, id):
@with_session
async def push_group_driver_config(request, session, id):
"""
Push group WiFi defaults to every WiFi device listed in the group (TCP WebSocket).
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)
@@ -211,11 +225,10 @@ async def push_group_driver_config(request, session, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
msg = json.dumps(
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
)
tasks = []
meta_macs = []
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:
@@ -224,23 +237,13 @@ async def push_group_driver_config(request, session, id):
if not dev:
errors.append({"mac": m, "error": "not in registry"})
continue
if (dev.get("transport") or "").lower() != "wifi":
continue
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
errors.append({"mac": m, "error": "no IP"})
continue
tasks.append(send_json_line_to_ip(ip, msg))
meta_macs.append(m)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
for m, r in zip(meta_macs, results):
if r is True:
try:
if await bridge.send(payload, addr=m):
sent += 1
elif isinstance(r, Exception):
errors.append({"mac": m, "error": str(r)})
else:
errors.append({"mac": m, "error": "driver not connected"})
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}
@@ -265,7 +268,7 @@ async def push_group_output_brightness(request, session, id):
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
sender = get_current_sender()
bridge = get_current_bridge()
async def _push_brightness_one(m: str, dev: dict) -> tuple[str, bool, str | None]:
b_val = effective_brightness_for_mac(
@@ -275,19 +278,11 @@ async def push_group_output_brightness(request, session, id):
m,
zone_brightness=None,
)
msg = _brightness_save_message_json(b_val)
transport = (dev.get("transport") or "espnow").strip().lower()
if transport == "wifi":
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
if not ip:
return m, False, "no IP"
ok = await send_json_line_to_ip(ip, msg)
return m, bool(ok), None if ok else "driver not connected"
if not sender:
if not bridge:
return m, False, "transport not configured"
try:
await sender.send(msg, addr=m)
return m, True, None
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)
@@ -351,7 +346,9 @@ async def identify_group_devices(request, session, id):
{"message": "identify group done", "sent": 0, "errors": errors}
), 200, {"Content-Type": "application/json"}
sent, batch_errors = await send_identify_to_group_devices(normalized)
sent, batch_errors = await send_identify_to_group_devices(
normalized, group_ids=[str(id)]
)
errors.extend(batch_errors)
return json.dumps(

View File

@@ -3,20 +3,40 @@ import os
import subprocess
import sys
from microdot import Microdot
from microdot import Microdot, send_file
from serial.tools import list_ports
controller = Microdot()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str:
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:
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):
cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -92,16 +112,40 @@ def _extract_settings_from_stdout(stdout: str):
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")
async def list_serial_ports(request):
ports = []
for info in list_ports.comports():
ports.append(
ports = _filter_host_serial_ports(
[
{
"device": info.device,
"description": info.description,
"hwid": info.hwid,
}
for info in list_ports.comports()
]
)
return (
json.dumps(

View File

@@ -4,8 +4,11 @@ from models.preset import Preset
from models.profile import Profile
from models.pallet import Palette
from models.device import Device, normalize_mac
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from models.transport import get_current_bridge
from util.driver_delivery import (
build_preset_json_chunks,
deliver_json_messages,
)
from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
@@ -221,42 +224,16 @@ async def send_presets(request, session):
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
MAX_BYTES = 240
send_delay_s = 0.1
entries = list(presets_by_name.items())
total_presets = len(entries)
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),
total_presets = len(presets_by_name)
chunk_messages = build_preset_json_chunks(
presets_by_name,
save=save_flag,
default=default_id,
)
default=str(default_id) if default_id is not None else None,
)
target_list = None
@@ -274,20 +251,50 @@ async def send_presets(request, session):
dm = normalize_mac(str(destination_mac))
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:
if target_list:
deliveries = await deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
if unicast and target_list:
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
bridge, [msg],
target_list,
Device(),
str(default_id) if default_id is not None else None,
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:
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(
sender,
chunk_messages,
bridge,
wire_messages,
None,
Device(),
delay_s=send_delay_s,
@@ -335,18 +342,37 @@ async def push_driver_messages(request, session):
if not target_list:
target_list = None
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
messages = []
for item in seq:
if isinstance(item, dict):
messages.append(json.dumps(item))
elif isinstance(item, str):
i = 0
while i < len(seq):
item = seq[i]
if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item)
else:
i += 1
continue
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)
try:
@@ -354,13 +380,16 @@ async def push_driver_messages(request, session):
except (TypeError, ValueError):
delay_s = 0.05
unicast = bool(data.get("unicast"))
try:
deliveries, _chunks = await deliver_json_messages(
sender,
bridge,
messages,
target_list,
Device(),
delay_s=delay_s,
unicast=unicast,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}

View File

@@ -2,7 +2,7 @@ from microdot import Microdot
from microdot.session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_sender
from models.transport import get_current_bridge
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json
@@ -30,6 +30,7 @@ def get_current_profile_id(session=None):
@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"}
@@ -97,6 +98,7 @@ async def import_sequence(request, session):
@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 (
@@ -203,15 +205,46 @@ async def delete_sequence(request, session, id):
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
from util.sequence_playback import stop_playback
stop()
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"}
@@ -221,7 +254,7 @@ async def stop_sequence_playback(request, session):
@with_session
async def play_sequence(request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_sender():
if not get_current_bridge():
return (
json.dumps({"error": "Transport not configured"}),
503,
@@ -251,8 +284,12 @@ async def play_sequence(request, session, id):
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
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:

View File

@@ -3,11 +3,10 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
from settings import get_settings
controller = Microdot()
settings = Settings()
settings = get_settings()
@controller.get('')
async def get_settings(request):
@@ -75,7 +74,28 @@ def _validate_global_brightness(value):
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):
"""Update general settings."""
try:
@@ -87,16 +107,15 @@ async def update_settings(request):
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
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:
settings[key] = value
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'}
except ValueError as e:
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("")
@with_session
async def list_zones(request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
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>")
async def get_zone(request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}

View File

@@ -4,13 +4,10 @@ import json
import os
import secrets
import signal
import socket
import threading
import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -23,234 +20,74 @@ import controllers.pattern as pattern
import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
from models.transport import get_sender, set_sender, get_current_sender
from models.device import Device, normalize_mac
from models import wifi_ws_clients as tcp_client_registry
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
from models.transport import (
get_bridge,
set_bridge,
get_current_bridge,
BridgeSerialTransport,
BridgeWsTransport,
)
from models.device import Device
from models.bridge_serial_client import init_bridge_serial_client
from models.bridge_ws_client import init_bridge_client
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
_tcp_device_lock = threading.Lock()
DISCOVERY_UDP_PORT = 8766
def _live_reload_enabled() -> bool:
v = os.environ.get("LED_CONTROLLER_LIVE_RELOAD", "").strip().lower()
return v not in ("", "0", "false", "no")
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
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):
settings = Settings()
settings = get_settings()
print(settings)
print("Starting")
# Initialize transport (serial to ESP32 bridge)
sender = get_sender(settings)
set_sender(sender)
set_bridge_uplink_handler(handle_bridge_uplink)
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()
audio_detector = AudioBeatDetector()
@@ -265,7 +102,8 @@ async def main(port=80):
persisted = read_audio_run_state()
if persisted.get("enabled"):
dev = coerce_audio_device(persisted.get("device"))
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:
@@ -303,12 +141,10 @@ async def main(port=80):
app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings')
app.mount(wifi_bridge_controller.controller, '/settings/wifi')
app.mount(device_controller.controller, '/devices')
app.mount(led_tool_controller.controller, '/led-tool')
tcp_client_registry.set_settings(settings)
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
live_reload = _live_reload_enabled()
dev_build_id = secrets.token_hex(12) if live_reload else None
if live_reload:
@@ -368,20 +204,45 @@ async def main(port=80):
device = payload.get("device", None)
if device in ("", None):
device = None
else:
try:
device = int(device)
except (TypeError, ValueError):
pass
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)
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
@@ -391,6 +252,24 @@ async def main(port=80):
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
@@ -426,6 +305,19 @@ async def main(port=80):
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
@@ -440,65 +332,56 @@ async def main(port=80):
@app.route('/ws')
@with_websocket
async def ws(request, ws):
await register_device_status_ws(ws)
await broadcast_device_tcp_snapshot_to(ws)
try:
while True:
data = await ws.receive()
print(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:
if not data:
break
finally:
await unregister_device_status_ws(ws)
try:
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()
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
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
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
try:
app.shutdown()
except Exception:
pass
for t in server_tasks:
if not t.done():
t.cancel()
shutdown_handlers_registered = False
try:
@@ -509,13 +392,15 @@ async def main(port=80):
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
pass
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
@@ -540,6 +425,20 @@ async def main(port=80):
app.server = None
except Exception:
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:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
@@ -549,5 +448,9 @@ async def main(port=80):
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
try:
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

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

View File

@@ -54,6 +54,9 @@ class Group(Model):
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=""):
@@ -66,6 +69,7 @@ class Group(Model):
"wifi_color_order": None,
"wifi_startup_mode": None,
"output_brightness": 255,
"bridge_id": None,
"pattern": "on",
"colors": ["000000", "FF0000"],
"brightness": 100,

View File

@@ -1,90 +1,171 @@
import asyncio
"""Transport to LED drivers via ESP-NOW bridge WebSocket or USB serial."""
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.
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
class NullBridge:
"""No bridge configured."""
async def send(self, data, addr=None):
return False
def _encode_payload(data):
if isinstance(data, str):
return data.encode()
class BridgeWsTransport:
"""Send v1 JSON or devices envelope via bridge WebSocket."""
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):
return json.dumps(data).encode()
return data
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
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
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 not packet:
return False
if packet[0] == WIRE_MAGIC:
return await client.send_packet(packet)
async def _to_thread(func, *args):
to_thread = getattr(asyncio, "to_thread", None)
if to_thread:
return await to_thread(func, *args)
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, func, *args)
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)
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:
return SerialSender(raw_port, baudrate, default_addr=default_addr)
except Exception as e:
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_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(
f"[startup] serial open failed ({raw_port!r}): {e}; "
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
"[startup] bridge serial disabled (set bridge_serial_port in settings.json)"
)
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] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_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:
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:
if isinstance(message, bytes):
try:
@@ -201,13 +199,13 @@ async def _recv_forward_loop(ip: str, ws) -> None:
if not text:
continue
print(f"[WS] recv {ip}: {text}")
if not sender:
if not bridge:
continue
try:
parsed = json.loads(text)
except json.JSONDecodeError:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass
continue
@@ -215,12 +213,12 @@ async def _recv_forward_loop(ip: str, ws) -> None:
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else "{}"
try:
await sender.send(payload, addr=addr)
await bridge.send(payload, addr=addr)
except Exception as e:
print(f"[WS] forward to bridge failed: {e}")
else:
try:
await sender.send(text)
await bridge.send(text)
except Exception:
pass
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
if stagger > 0:
await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
boot_attempts = 0
try:
while True:
if not connected_once:
if boot_attempts >= max_boot_attempts:
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
)
break
boot_attempts += 1
for attempt in range(1, max_boot_attempts + 1):
try:
print(f"[WS] connecting to {uri!r}")
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=open_timeout,
) as ws:
connected_once = True
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
return
except Exception as 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(
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:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
if attempt < max_boot_attempts:
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:
unregister_tcp_writer(ip, None)
raise
@@ -329,10 +318,12 @@ async def _driver_connection_loop(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)
if not key:
return
if tcp_client_connected(key):
return
t = _tasks.get(key)
if t is not None and not t.done():
return
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -22,7 +22,7 @@ class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
"""
def __init__(self):
@@ -43,6 +43,12 @@ class Zone(Model):
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()
@@ -53,6 +59,41 @@ class Zone(Model):
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)
@@ -93,8 +134,13 @@ class Zone(Model):
id_str = str(id)
if id_str not in self:
return False
patch = data if isinstance(data, dict) else {}
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()
return True

View File

@@ -2,6 +2,8 @@ import json
import os
import binascii
WIFI_CHANNEL_DEFAULT = 5
def _settings_path():
"""Path to settings.json in project root (writable without root)."""
@@ -12,11 +14,15 @@ def _settings_path():
return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
def __init__(self, *, quiet: bool = False):
super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
@@ -47,44 +53,42 @@ class Settings(dict):
self.save()
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self:
self['wifi_channel'] = 6
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
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
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self:
self['serial_enabled'] = False
self['wifi_channel'] = WIFI_CHANNEL_DEFAULT
# WebSocket URL of ESP-NOW bridge (Pi is client), e.g. ws://192.168.4.1/ws
if 'bridge_ws_url' not in self:
self['bridge_ws_url'] = ''
if 'wifi_interface' not in self:
self['wifi_interface'] = ''
if 'bridges' not in self:
self['bridges'] = []
if 'bridge_transport' not in self:
self['bridge_transport'] = 'serial'
if 'bridge_serial_port' not in self:
self['bridge_serial_port'] = ''
if 'bridge_serial_baudrate' not in self:
self['bridge_serial_baudrate'] = 115200
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
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):
try:
j = json.dumps(self)
j = json.dumps(self, indent=2, sort_keys=True)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
file.write("\n")
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -96,9 +100,11 @@ class Settings(dict):
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
@@ -106,3 +112,18 @@ class Settings(dict):
# Only save if file didn't exist or was invalid
if not loaded_from_file:
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

View File

@@ -1,7 +1,7 @@
(() => {
let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
@@ -9,53 +9,11 @@
* next beat bumps `beat_seq` (avoids the stuck final cumulative value vs sequence readout).
*/
let headerBeatStickyIdleAfterSeq = false;
/** Suppresses duplicate `console.log` when the same `beat_seq` + server `beat_readout` repeats. */
let lastBeatConsoleKey = "";
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
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);
@@ -70,40 +28,11 @@
}
}
/**
* On each new audio `beat_seq`, log server `beat_readout` once (deduped when poll repeats the
* same `beat_seq` + line).
* @param {Record<string, unknown>} status
*/
function logServerBeatConsoleOnPollEdge(status) {
const beatSeq = Number((status && status.beat_seq) || 0);
const line = String((status && status.beat_readout) || "").trim();
const key = `${beatSeq}\t${line}`;
if (key !== lastBeatConsoleKey) {
lastBeatConsoleKey = key;
if (!line) return;
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
const seqBeats = !!seq && !!seq.active;
let out = line;
if (seqBeats) {
const nLanes = Number(seq && seq.num_lanes);
const lanesNote =
Number.isFinite(nLanes) && nLanes > 1
? `lane 1 of ${nLanes} (readout is for this lane only)`
: "lane 1";
out = `${line}${lanesNote}`;
}
console.log(out);
}
}
function updateBpmDisplay(bpm) {
const node = el("audio-bpm-value");
if (!node) return;
node.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
const topNode = el("audio-top-bpm-value");
if (topNode) {
topNode.textContent = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
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;
}
}
@@ -115,38 +44,6 @@
return !!(seq && seq.active);
}
/** Build sequence beat fractions for debug logging (browser console only). */
function formatSequenceBeatFractionsForLog(status) {
const seq = /** @type {Record<string, unknown>|undefined} */ (status && status.sequence);
if (!seq || !seq.active) return null;
const laneBeatAt = Number(seq.lane0_beat_in_step);
const laneBeatsPerStep = Number(seq.lane0_beats_per_step);
if (
!Number.isFinite(laneBeatAt) ||
laneBeatAt <= 0 ||
!Number.isFinite(laneBeatsPerStep) ||
laneBeatsPerStep <= 0
) {
return null;
}
const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`;
const sequenceBeatAt = Number(seq.sequence_beat_at);
const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass);
if (
!Number.isFinite(sequenceBeatAt) ||
sequenceBeatAt <= 0 ||
!Number.isFinite(sequenceBeatsPerPass) ||
sequenceBeatsPerPass <= 0
) {
return null;
}
const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`;
return `${presetFraction} ${sequenceFraction}`;
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
@@ -155,22 +52,139 @@
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 flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
function 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() {
@@ -179,24 +193,38 @@
}
function getBeatPhaseDelayMs() {
const inp = el("audio-beat-phase-ms");
if (inp && String(inp.value).trim() !== "") {
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
return Math.min(500, Math.max(0, cachedBeatPhaseMs));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
function 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}%`);
}
}
function persistBeatPhaseMs() {
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_input_volume: vol }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
console.warn("input volume save failed", e);
}
}
@@ -223,7 +251,9 @@
/** 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);
@@ -232,8 +262,8 @@
lastBeatSeq = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
@@ -241,10 +271,9 @@
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
/** User-initiated stop (run intent cleared on server). */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
@@ -258,30 +287,43 @@
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;
}
setTopBpmVisible(!!status.running);
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 zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
}
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
@@ -291,36 +333,139 @@
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
logServerBeatConsoleOnPollEdge(status);
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
}
const beatFractions = formatSequenceBeatFractionsForLog(status);
if (beatFractions) {
if (beatFractions !== lastLoggedSequenceBeatFractions) {
lastLoggedSequenceBeatFractions = beatFractions;
}
} else {
lastLoggedSequenceBeatFractions = "";
}
updateBeatReadoutDisplays(status);
} catch (e) {
console.warn("audio status poll failed", e);
}
}
async function startAudio() {
/** 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();
const override = (el("audio-device-override")?.value || "").trim();
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
await persistDeviceSelection(selected);
const rawDevice = selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = { device: rawDevice === "" ? null : numeric };
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" },
@@ -330,7 +475,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
cachedAudioRun.device_select = selected;
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -339,36 +485,36 @@
async function refreshDevices() {
const select = el("audio-device-select");
const debug = el("audio-devices-debug");
if (!select) return;
const current = select.value;
const res = await fetch("/api/audio/devices");
const data = await res.json();
// 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() : [];
if (debug) {
debug.value = JSON.stringify(data?.diagnostics || data, null, 2);
}
inputs.sort((a, b) => {
const am = String(a?.name || "").toLowerCase().includes("monitor");
const bm = String(b?.name || "").toLowerCase().includes("monitor");
if (am !== bm) return am ? -1 : 1;
return Number(a?.id || 0) - Number(b?.id || 0);
});
select.innerHTML = '<option value="">System default input</option>';
select.innerHTML = "";
const defaultOpt = document.createElement("option");
defaultOpt.value = "";
defaultOpt.textContent = "System default input";
select.appendChild(defaultOpt);
let defaultId = "";
inputs.forEach((d, idx) => {
const option = document.createElement("option");
option.value = String(d.id);
option.textContent = d.label || d.name || `Input ${idx + 1}`;
if (d.is_default) {
defaultId = String(d.id);
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(option);
select.appendChild(opt);
if (d.is_default) defaultId = String(d.id);
});
if (current) {
select.value = current;
} else if (defaultId) {
select.value = defaultId;
suppressDeviceSelectEvents = true;
try {
restoreDeviceSelectAfterRefresh(select, defaultId, restoreId);
} finally {
suppressDeviceSelectEvents = false;
}
}
@@ -378,6 +524,7 @@
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;
@@ -388,6 +535,8 @@
} catch (e) {
console.warn("audio device refresh failed", e);
}
await loadServerAudioUiFields();
setResetDetectorEnabled(audioDetectorRunning);
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
@@ -396,9 +545,9 @@
}
if (startBtn) {
startBtn.addEventListener("click", async () => {
const picked = getSelectedDeviceId();
try {
await startAudio();
await refreshDevices();
await startAudio(picked);
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
@@ -410,6 +559,9 @@
await stopAudio();
});
}
if (resetBtn) {
resetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
@@ -419,20 +571,47 @@
}
});
}
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();
}
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (btn) {
btn.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
}
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() {
@@ -440,38 +619,85 @@
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 browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
/** 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 {
await refreshDevices();
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 device list refresh failed", e);
console.warn("audio status load failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
}
/** 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();
await applySavedAudioDeviceFormOnly();
});
})();

View File

@@ -18,6 +18,15 @@ const DEVICES_MODAL_POLL_MS = 1000;
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() {
if (devicesModalLiveTimer != null) {
clearInterval(devicesModalLiveTimer);
@@ -53,11 +62,196 @@ function startDevicesModalLiveRefresh() {
}, 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) {
const dot = row.querySelector('.device-status-dot');
if (!dot) 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) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
@@ -243,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) {
const container = document.getElementById('devices-list-modal');
if (!container) return;
@@ -277,17 +534,16 @@ function renderDevicesList(devices) {
dot.setAttribute('role', 'img');
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
if (live === true) {
dot.classList.add('device-status-dot--online');
dot.title = 'Connected (Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
setDeviceStatusDot(dot, 'online', 'Connected (Wi-Fi TCP session)');
} else if (live === false) {
dot.classList.add('device-status-dot--offline');
dot.title = 'Not connected (no Wi-Fi TCP session)';
dot.setAttribute('aria-label', dot.title);
setDeviceStatusDot(dot, 'offline', 'Not connected (no Wi-Fi TCP session)');
} else {
dot.classList.add('device-status-dot--unknown');
dot.title = 'ESP-NOW — TCP status does not apply';
dot.setAttribute('aria-label', dot.title);
const pingCached = espnowPingStatusForMac(devId);
if (pingCached) {
setDeviceStatusDot(dot, pingCached.state, pingCached.title);
} else {
setDeviceStatusDot(dot, 'unknown', 'ESP-NOW — ping or identify to test reachability');
}
}
const label = document.createElement('span');
@@ -564,6 +820,9 @@ document.addEventListener('DOMContentLoaded', () => {
const editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn');
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) {
devicesBtn.addEventListener('click', () => {
@@ -571,6 +830,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (typeof window.getEspnowSocket === 'function') {
window.getEspnowSocket();
}
applyEspnowPingAggregateToDots();
loadDevicesModal();
startDevicesModalLiveRefresh();
});
@@ -581,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');
if (devicesModalEl) {
new MutationObserver(() => {
@@ -658,3 +945,9 @@ document.addEventListener('DOMContentLoaded', () => {
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
}
});
if (typeof window !== 'undefined') {
window.applyEspnowPingToDeviceRows = applyEspnowPingToDeviceRows;
window.runEspnowPing = runEspnowPing;
window.applyEspnowPingAggregateToDots = applyEspnowPingAggregateToDots;
}

View File

@@ -45,7 +45,14 @@ async function fetchDevicesMapForGroups() {
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) => {
@@ -72,7 +79,7 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
listEl.appendChild(div);
});
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
@@ -101,7 +108,11 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
if (panel) {
panel.addSlot.appendChild(addWrap);
} else {
containerEl.appendChild(addWrap);
}
refreshEditGroupDebug();
}
@@ -320,12 +331,6 @@ function renderGroupsList(groups) {
alert(data.error || 'Apply brightness failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent brightness to ${n} driver(s).`
: 'No WiFi drivers received brightness (check connections).',
);
} catch (err) {
console.error(err);
alert('Apply brightness failed');
@@ -350,12 +355,6 @@ function renderGroupsList(groups) {
alert(data.error || 'Apply failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
alert(
n
? `Sent defaults to ${n} driver(s).`
: 'No WiFi drivers received the config (check defaults and connections).',
);
} catch (err) {
console.error(err);
alert('Apply failed');
@@ -421,15 +420,10 @@ async function identifyGroupById(gid) {
alert(data.error || 'Identify failed');
return;
}
const n = typeof data.sent === 'number' ? data.sent : 0;
const errs = Array.isArray(data.errors) ? data.errors : [];
const failed = errs.filter((e) => e && e.error).length;
let msg = n ? `Identify sent to ${n} device(s).` : 'No devices received identify.';
if (failed) {
msg += ` ${failed} failed — see console for details.`;
if (errs.some((e) => e && e.error)) {
console.warn('Group identify errors', errs);
}
alert(msg);
} catch (e) {
console.error(e);
alert('Identify failed');

View File

@@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => {
const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
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') => {
const messageEl = document.getElementById('settings-message');
if (!messageEl) return;
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
setTimeout(() => {
messageEl.classList.remove('show');
}, 5000);
function loadLedToolIframe() {
if (!ledToolIframe) return;
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
if (blank) {
ledToolIframe.src = '/led-tool/editor';
}
}
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() {
try {
const response = await fetch('/settings');
const data = await response.json();
const nameInput = document.getElementById('device-name-input');
if (nameInput && data && typeof data === 'object') {
nameInput.value = data.device_name || 'led-controller';
const bridgeWsStatus = document.getElementById('bridge-ws-status');
const bridgeConnectionDetails = document.getElementById('bridge-connection-details');
const bridgeProfilesList = document.getElementById('bridge-profiles-list');
let lastBridgeSettings = null;
const bridgeSerialPortSelect = document.getElementById('bridge-serial-port');
const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud');
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') {
const ch = data.wifi_channel;
chInput.value =
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
function connLabel(ok) {
return ok ? 'connected' : 'not connected';
}
} 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 {
const response = await fetch('/settings/wifi/ap');
const config = await response.json();
const statusEl = document.getElementById('ap-status');
if (!statusEl) return;
if (config.active) {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-connected">Active</span></h4>
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
`;
const bridgesRes = await fetch('/settings/wifi/bridges');
const bridgesData = await bridgesRes.json().catch(() => ({}));
lastBridgeSettings = bridgesData;
if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) {
bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate);
}
await loadSerialPorts(bridgesData.bridge_serial_port || '');
await loadWifiInterfaces(bridgesData.wifi_interface || '');
renderBridgeConnectionDetails(bridgesData);
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 {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
<p>Access Point is not currently active</p>
`;
label.textContent = `${p.label} — USB ${p.serial_port}`;
}
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
const status = document.createElement('span');
const st = profileStatusFor(p, data);
status.className = 'settings-bridge-profile-status ' + st.className;
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) {
settingsButton.addEventListener('click', () => {
switchSettingsTab('bridge');
settingsModal.classList.add('active');
// Load current WiFi status/config when opening
loadDeviceSettings();
loadAPStatus();
loadBridgeSettings();
});
}
if (settingsCloseButton && settingsModal) {
settingsCloseButton.addEventListener('click', () => {
settingsModal.classList.remove('active');
});
}
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');
}
settingsModal.classList.remove('settings-modal--led-tool');
unloadLedToolIframe();
});
}
});

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

@@ -98,24 +98,8 @@ document.addEventListener('DOMContentLoaded', () => {
: [];
};
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
const body = {
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 postDriverSequence = (sequence, targetMacs, delayS, pushOptions) =>
window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') {
@@ -586,26 +570,28 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = zonePresetIds.slice();
}
});
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
const groupIds =
typeof window.zonesManager !== 'undefined' &&
typeof window.zonesManager.effectiveGroupIdsForZonePreset === 'function'
? window.zonesManager.effectiveGroupIdsForZonePreset(zoneData)
: Array.isArray(zoneData.group_ids)
? zoneData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
: [];
const sequence = [
{ v: '1', clear_presets: true, save: true },
{ v: '1', presets: wirePresets, save: true },
];
if (Object.keys(select).length) {
sequence.push({ v: '1', select });
if (groupIds.length) {
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) {
console.error('Send all patterns failed:', error);
alert('Failed to send all patterns.');

View File

@@ -176,6 +176,17 @@ function tabDeviceNamesFromSection(section) {
: [];
}
/** Group ids for preset broadcast targeting on a zone tab. */
function zoneGroupIdsFromTabData(tabData) {
const zm = window.zonesManager;
if (zm && typeof zm.effectiveGroupIdsForZonePreset === 'function') {
return zm.effectiveGroupIdsForZonePreset(tabData || {});
}
return Array.isArray(tabData && tabData.group_ids)
? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
: [];
}
/** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */
async function deviceNamesForPresetOnCurrentZone(presetId) {
const section = document.querySelector('.presets-section[data-zone-id]');
@@ -216,8 +227,13 @@ function formatPresetTargetGroupsLine(zoneDoc, groupsMap) {
async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) {
const body = {
sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
};
if (pushOptions && pushOptions.unicast === true) {
body.unicast = true;
if (Array.isArray(targetMacs) && targetMacs.length) {
body.targets = [...new Set(targetMacs)];
}
}
if (delayS != null && delayS >= 0) {
body.delay_s = delayS;
}
@@ -264,6 +280,8 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
const presetModeInput = document.getElementById('preset-mode-input');
const presetModeGroup = document.getElementById('preset-mode-group');
const presetReverseInput = document.getElementById('preset-reverse-input');
const presetReverseGroup = document.getElementById('preset-reverse-group');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
@@ -350,6 +368,22 @@ document.addEventListener('DOMContentLoaded', () => {
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
const patternSupportsReverse = (patternName) => {
const cfg = resolvePatternConfig(patternName);
return !!(cfg && cfg.supports_reverse);
};
const setPresetReverseFieldVisible = (show) => {
if (!presetReverseGroup) {
return;
}
presetReverseGroup.hidden = !show;
presetReverseGroup.style.display = show ? '' : 'none';
if (!show && presetReverseInput) {
presetReverseInput.checked = false;
}
};
const setPresetModeFieldVisible = (show) => {
if (!presetModeGroup) {
return;
@@ -773,6 +807,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (presetReverseInput) {
const n5raw = preset.n5;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
// Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
@@ -828,6 +868,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeInput) {
presetManualModeInput.checked = false;
}
if (presetReverseInput) {
presetReverseInput.checked = false;
}
setPresetReverseFieldVisible(false);
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
@@ -872,7 +916,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData)
? window.zoneAllowsPresets(tabData, currentEditTabId)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
@@ -951,13 +995,20 @@ document.addEventListener('DOMContentLoaded', () => {
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
const reverseField = patternSupportsReverse(payload.pattern);
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
if (reverseField && nKey === 'n5') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
}
if (reverseField) {
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
}
if (modeEntries && presetModeInput) {
payload.mode = parseInt(presetModeInput.value, 10) || 0;
}
@@ -1065,9 +1116,19 @@ document.addEventListener('DOMContentLoaded', () => {
}
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
const reverseField = patternSupportsReverse(patternName);
if (modeEntries) {
visibleNKeys.delete('n6');
}
if (reverseField) {
visibleNKeys.delete('n5');
}
setPresetReverseFieldVisible(reverseField);
if (reverseField && presetReverseInput) {
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
if (presetModeInput) {
if (modeEntries) {
setPresetModeFieldVisible(true);
@@ -1316,12 +1377,17 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
try {
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: [];
await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs);
const zoneId = section && section.dataset.zoneId;
let groupIds = [];
if (zoneId) {
const zr = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (zr.ok) {
groupIds = zoneGroupIdsFromTabData(await zr.json());
}
}
const clearMsg = { v: '1', clear_presets: true, save: true };
if (groupIds.length) clearMsg.groups = groupIds;
await postDriverSequence([clearMsg], [], 0.05, { groupIds });
} catch (error) {
console.error('Clear device presets failed:', error);
alert('Failed to clear presets on devices.');
@@ -1355,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc)
!window.zoneAllowsPresets(zoneDoc, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
@@ -1495,7 +1561,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
@@ -1995,29 +2061,23 @@ const sendPresetViaEspNow = async (
presetMessage.default = wirePresetId;
}
const names = Array.isArray(deviceNames) ? deviceNames : [];
const targetMacs =
names.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(names)
const forceSelect = pushOptions && pushOptions.select === true;
const shouldSelect =
forceSelect || (pushOptions && pushOptions.select === false ? false : presetAuto);
// Apply on driver in the same message as presets (split on bridge keeps presets before select).
if (shouldSelect) {
presetMessage.select = [wirePresetId];
}
const groupIds =
pushOptions && Array.isArray(pushOptions.groupIds)
? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0)
: [];
const sequence = [presetMessage];
// Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat.
if (names.length > 0 && presetAuto) {
const select = {};
names.forEach((name) => {
if (name) {
select[name] = [wirePresetId];
}
});
if (Object.keys(select).length > 0) {
sequence.push({ v: '1', select });
}
if (groupIds.length > 0) {
presetMessage.groups = groupIds;
}
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
await postDriverSequence([presetMessage], [], 0.05, pushOptions);
} catch (error) {
console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.');
@@ -2061,24 +2121,20 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
if (!nameTargets.length) {
return;
}
const select = {};
nameTargets.forEach((name) => {
select[name] = [String(presetId)];
});
const macTargets =
nameTargets.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
: [];
await postDriverSequence([{ v: '1', select }], macTargets);
await postDriverSequence([{ v: '1', select: [String(presetId)] }], macTargets);
};
// Expose for other scripts (zones.js) so they can reuse the shared WebSocket.
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
window.postDriverSequence = postDriverSequence;
// Expose a generic ESPNow sender so other scripts (zones.js) can send
// Expose a generic ESP-NOW bridge helper so other scripts (zones.js) can send
// non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage;
window.getEspnowSocket = getEspnowSocket;
@@ -2123,11 +2179,16 @@ async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPre
const pid = String(presetId);
const body = (allPresets && allPresets[pid]) || preset;
if (!body) return;
const zm = window.zonesManager;
const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
zm && typeof zm.resolveDeviceNamesForZonePreset === 'function'
? await zm.resolveDeviceNamesForZonePreset(tabData, pid)
: [];
await sendPresetViaEspNow(pid, body, names, false, false, '2');
const groupIds = zoneGroupIdsFromTabData(tabData);
await sendPresetViaEspNow(pid, body, names, false, false, '2', {
select: true,
groupIds,
});
}
// Store selected preset per zone
@@ -2214,7 +2275,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
throw new Error('This zone is for sequences only.');
}
@@ -2312,9 +2373,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.normalizeZoneContentKind === 'function'
typeof window.effectiveZoneContentKind === 'function'
? window.effectiveZoneContentKind(tabData)
: typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
: 'presets';
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
@@ -2454,7 +2517,8 @@ const renderTabPresets = async (zoneId, options = {}) => {
if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
(typeof window.zoneAllowsSequences !== 'function' ||
window.zoneAllowsSequences(tabData, zoneId))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
@@ -2698,7 +2762,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only.');
return;

View File

@@ -1,14 +1,98 @@
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
/** @type {'beat'|'downbeat'} */
let sequenceSwitchWaitFor = 'beat';
let sequenceDebugEnabled = false;
let sequenceSwitchSaveInFlight = false;
async function loadSequenceSwitchWaitForFromServer() {
try {
const res = await fetch('/settings', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!res.ok) return;
const data = await res.json();
const raw = data && data.sequence_switch_wait;
if (raw === 'downbeat' || raw === 'beat') {
sequenceSwitchWaitFor = raw;
} else if (raw === 'phrase') {
sequenceSwitchWaitFor = 'beat';
}
} catch {
/* keep default */
}
}
async function persistSequenceSwitchWaitFor() {
sequenceSwitchSaveInFlight = true;
try {
const res = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
});
if (!res.ok) {
console.warn('[sequence] could not save switch wait to server', res.status);
}
} catch (e) {
console.warn('[sequence] could not save switch wait to server', e);
} finally {
sequenceSwitchSaveInFlight = false;
}
}
function getSequenceSwitchWaitFor() {
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
}
async function setSequenceSwitchWaitFor(waitFor) {
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
updateSequenceSwitchToggleUI();
await persistSequenceSwitchWaitFor();
}
function updateSequenceSwitchToggleUI() {
const mode = getSequenceSwitchWaitFor();
const ariaLabels = {
beat: 'Switch sequence on beat',
downbeat: 'Switch sequence on downbeat',
};
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
});
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
});
}
async function initSequenceSwitchToggle() {
await loadSequenceSwitchWaitForFromServer();
updateSequenceSwitchToggleUI();
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
});
});
}
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
function applySequenceSwitchWaitFromServer(raw) {
if (sequenceSwitchSaveInFlight) return;
let mode = 'beat';
if (raw === 'downbeat') mode = 'downbeat';
else if (raw !== 'beat' && raw !== 'phrase') return;
if (mode === getSequenceSwitchWaitFor()) return;
sequenceSwitchWaitFor = mode;
updateSequenceSwitchToggleUI();
}
function seqDebugEnabled() {
try {
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
} catch {
return false;
}
return sequenceDebugEnabled;
}
/** @type {ReturnType<typeof setInterval> | null} */
@@ -132,6 +216,12 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
});
}
function zoneGroupIdsFromDoc(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter(Boolean)
: [];
}
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) {
@@ -155,7 +245,6 @@ function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : [];
const shared = Array.isArray(doc && doc.group_ids) ? doc.group_ids.map(String) : [];
if (laneIndex < raw.length) {
const row = raw[laneIndex];
if (Array.isArray(row)) {
@@ -165,17 +254,10 @@ function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
if (numLanes > 1 && laneIndex >= raw.length) {
return [];
}
if (numLanes === 1) {
const lanes = normalizeSequenceLanes(doc);
const first = lanes[0] && lanes[0][0];
const sg =
first && Array.isArray(first.group_ids) ? first.group_ids.map(String).filter(Boolean) : [];
return sg.length ? sg : shared.slice();
}
return shared.slice();
return [];
}
function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;';
@@ -183,15 +265,17 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
hint.className = 'muted-text';
hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem';
hint.textContent = 'Groups for this lane (none = whole zone)';
hint.textContent = 'Only checked groups are used on this lane';
wrap.appendChild(hint);
const row = document.createElement('div');
row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap)
.sort((a, b) => a.localeCompare(b))
.forEach((gid) => {
const zg = Array.isArray(zoneGroupIds) ? zoneGroupIds.map(String).filter(Boolean) : [];
const gidsToShow = zg.length
? zg
: Object.keys(groupsMap).sort((a, b) => a.localeCompare(b));
gidsToShow.forEach((gid) => {
const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`;
@@ -249,13 +333,6 @@ function presetsSectionElForZone(zoneId) {
/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : [];
if (!gids.length) {
const section = presetsSectionElForZone(zoneId);
if (typeof window.parseTabDeviceNames === 'function' && section) {
const fromDom = window.parseTabDeviceNames(section);
if (Array.isArray(fromDom) && fromDom.length) return fromDom;
}
}
if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids);
}
@@ -323,33 +400,12 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
button.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`;
const badge = document.createElement('span');
badge.textContent = 'SEQ';
badge.className = 'sequence-tile-badge';
badge.style.cssText =
'position:absolute;left:4px;top:4px;font-size:10px;font-weight:700;color:#fff;background:rgba(0,100,180,0.9);padding:2px 5px;border-radius:3px;pointer-events:none;z-index:2;';
button.style.position = 'relative';
button.appendChild(badge);
const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold';
label.className = 'pattern-button-label';
button.appendChild(label);
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.style.cssText = 'display:block;font-size:0.8em;margin-top:0.2rem;';
const lanes = normalizeSequenceLanes(sequenceDoc);
const nLanes = lanes.filter((l) => l.length > 0).length || 1;
const nSteps = lanes.reduce((a, l) => a + l.length, 0);
const simRaw = sequenceDoc.simulated_bpm;
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
if (!Number.isFinite(sim)) sim = 120;
sim = Math.min(300, Math.max(30, sim));
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
button.appendChild(sub);
button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => {
@@ -456,7 +512,7 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData)
!window.zoneAllowsSequences(tabData, zoneId)
) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
@@ -525,7 +581,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const zone = await zoneRes.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone)
!window.zoneAllowsSequences(zone, zoneId)
) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
@@ -781,7 +837,7 @@ function renderSequenceStepRow(presetsMap, step) {
return row;
}
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) {
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex);
@@ -820,7 +876,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
head.appendChild(headBtns);
wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds));
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds, zoneGroupIds));
const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps';
@@ -893,6 +949,24 @@ async function openSequenceEditor(sequenceId, existing) {
const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq();
let zoneDoc = {};
const zoneIdForEditor = resolveZoneIdForPresetStripRefresh();
if (zoneIdForEditor) {
try {
const zr = await fetch(`/zones/${encodeURIComponent(zoneIdForEditor)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (zr.ok) {
const zj = await zr.json();
if (zj && typeof zj === 'object' && !zj.error) zoneDoc = zj;
}
} catch (_) {
/* no zone context */
}
}
const zoneGroupIds = zoneGroupIdsFromDoc(zoneDoc);
let doc = existing;
if (sequenceEditorId) {
try {
@@ -926,11 +1000,11 @@ async function openSequenceEditor(sequenceId, existing) {
lanesHost.innerHTML = '';
if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap, zoneGroupIds));
} else {
lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length);
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap, zoneGroupIds));
});
}
refreshSequenceEditorLaneTitles();
@@ -1120,15 +1194,11 @@ async function loadSequencesModalList() {
});
}
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) {
try {
if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1');
else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY);
} catch (e) {
console.warn('[sequence] could not persist debug flag', e);
}
sequenceDebugEnabled = !!on;
console.log(seqDebugEnabled() ? 1 : 0);
};
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
@@ -1137,6 +1207,7 @@ window.addSequenceToTab = addSequenceToTab;
window.removeSequenceFromTab = removeSequenceFromTab;
document.addEventListener('DOMContentLoaded', () => {
void initSequenceSwitchToggle();
const btn = document.getElementById('sequences-btn');
const modal = document.getElementById('sequences-modal');
const closeBtn = document.getElementById('sequences-close-btn');

View File

@@ -106,7 +106,7 @@ header h1 {
font-weight: 600;
}
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
.header-end {
display: flex;
align-items: center;
@@ -199,20 +199,46 @@ header h1 {
.audio-top-indicator {
display: none;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
align-items: center;
gap: 0.35rem;
min-width: 9rem;
}
.audio-top-indicator-main {
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator .audio-top-beat-sync {
flex: 1;
min-width: 0;
}
.audio-beat-sync-btn,
.audio-top-beat-sync {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
min-height: 2.25rem;
padding: 0.3rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
cursor: pointer;
font-family: inherit;
text-align: left;
}
.audio-beat-sync-btn:disabled,
.audio-top-beat-sync:disabled {
cursor: default;
opacity: 0.85;
}
.audio-beat-sync-btn:not(:disabled):hover,
.audio-top-beat-sync:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #2a2a2a;
}
.audio-top-indicator-extra {
@@ -226,10 +252,6 @@ header h1 {
text-overflow: ellipsis;
}
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
@@ -245,16 +267,46 @@ header h1 {
}
.audio-top-beat-readout {
font-size: 0.62rem;
font-size: 0.75rem;
color: #b0bec5;
line-height: 1.25;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
text-align: left;
min-width: 2rem;
text-align: right;
}
.audio-top-beat-readout:empty {
display: none;
}
.audio-top-beat-readout:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase {
font-size: 0.7rem;
color: #90a4ae;
line-height: 1.25;
white-space: nowrap;
}
.audio-top-bar-phase:empty {
display: none;
}
.audio-top-bar-phase:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase.is-downbeat {
color: #ffab91;
}
.audio-top-indicator-subvalue {
@@ -264,16 +316,24 @@ header h1 {
text-align: right;
}
.audio-top-indicator.flash {
.audio-beat-sync-btn.flash,
.audio-top-beat-sync.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue,
.audio-top-indicator.flash .audio-top-indicator-extra,
.audio-top-indicator.flash .audio-top-beat-readout {
.audio-beat-sync-btn.flash .audio-top-indicator-value,
.audio-beat-sync-btn.flash .audio-top-indicator-label,
.audio-beat-sync-btn.flash .audio-top-beat-readout,
.audio-beat-sync-btn.flash .audio-top-beat-readout::before,
.audio-beat-sync-btn.flash .audio-top-bar-phase,
.audio-beat-sync-btn.flash .audio-top-bar-phase::before,
.audio-top-beat-sync.flash .audio-top-indicator-value,
.audio-top-beat-sync.flash .audio-top-indicator-label,
.audio-top-beat-sync.flash .audio-top-beat-readout,
.audio-top-beat-sync.flash .audio-top-beat-readout::before,
.audio-top-beat-sync.flash .audio-top-bar-phase,
.audio-top-beat-sync.flash .audio-top-bar-phase::before {
color: #fff;
}
@@ -333,7 +393,7 @@ body.preset-ui-run .edit-mode-only {
.zones-container {
background-color: transparent;
padding: 0.35rem 0 0;
padding: 0;
flex: 0 0 auto;
width: 100%;
min-width: 0;
@@ -806,6 +866,11 @@ body.preset-ui-run .edit-mode-only {
border: 1px solid #757575;
}
.device-status-dot--pinging {
background: #ffb300;
box-shadow: 0 0 6px rgba(255, 179, 0, 0.45);
}
.btn-group {
display: flex;
gap: 0.5rem;
@@ -863,12 +928,111 @@ body.preset-ui-run .edit-mode-only {
min-width: 5rem;
}
.audio-modal-beat-readout {
#audio-modal .audio-settings-section {
margin-top: 1rem;
}
#audio-modal .audio-modal-beat-sync {
width: 100%;
}
#audio-modal .audio-modal-content {
max-width: 640px;
}
.audio-device-block {
margin-bottom: 1rem;
}
.audio-device-select-row {
align-items: stretch;
gap: 0.5rem;
}
.audio-device-select-row select {
flex: 1;
min-width: 10rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: left;
min-width: 0;
}
#audio-modal .audio-modal-beat-sync {
width: 100%;
}
.audio-volume-block {
margin-top: 0.5rem;
}
.audio-volume-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.audio-volume-header label {
margin: 0;
}
.audio-volume-readout {
font-size: 0.9rem;
color: #e0e0e0;
white-space: nowrap;
}
.audio-volume-slider-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.audio-volume-slider {
--audio-volume-pct: 50%;
--audio-volume-unity: 50%;
flex: 1;
width: 100%;
height: 0.45rem;
margin: 0.35rem 0;
accent-color: #ff9800;
background: transparent;
}
.audio-volume-scale {
position: relative;
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: #9e9e9e;
margin: 0.15rem 0 0.45rem;
min-height: 1rem;
}
.audio-volume-scale-unity {
position: absolute;
left: var(--audio-volume-unity, 50%);
transform: translateX(-50%);
white-space: nowrap;
}
#audio-modal .audio-volume-block {
--audio-volume-unity: 50%;
}
.audio-input-level-meter {
width: 100%;
height: 0.35rem;
border: none;
border-radius: 2px;
background: #2a2a2a;
overflow: hidden;
}
.audio-input-level-bar {
height: 100%;
width: 0%;
background: #ff9800;
transition: width 60ms linear;
border-radius: 2px;
}
.audio-hit-type-readout {
@@ -1003,13 +1167,98 @@ body.preset-ui-run .edit-mode-only {
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
.nav-slide-toggle-wrap {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.ui-mode-toggle--edit:hover {
.nav-slide-toggle-side-label {
font-size: 0.82rem;
color: #888;
user-select: none;
line-height: 1;
}
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
color: #e8e8e8;
font-weight: 500;
}
.nav-slide-toggle-switch {
position: relative;
width: 2.75rem;
height: 1.4rem;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
appearance: none;
border: 1px solid #4a4a4a;
border-radius: 999px;
background-color: #2a2a2a;
cursor: pointer;
flex-shrink: 0;
}
.nav-slide-toggle-switch:hover {
border-color: #666;
}
.nav-slide-toggle-switch:focus-visible {
outline: 2px solid #7b6fd6;
outline-offset: 2px;
}
.nav-slide-toggle-track {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
}
.nav-slide-toggle-thumb {
position: absolute;
top: 50%;
left: 2px;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: #bdbdbd;
transform: translateY(-50%);
transition: left 0.2s ease, background-color 0.2s ease;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
background-color: #4a3f8f;
border-color: #7b6fd6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
background-color: #5a4f9f;
border-color: #8b7fe6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 1rem - 2px);
transform: translateY(-50%);
background-color: #e8e4ff;
}
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
display: flex;
justify-content: center;
align-items: center;
gap: 0.35rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #333;
flex-shrink: 1;
min-width: 0;
}
/* Preset select buttons inside the zone grid */
@@ -1173,8 +1422,7 @@ body.preset-ui-run .edit-mode-only {
/* Header / global dialogs */
#help-modal.active,
#audio-modal.active,
#settings-modal.active,
#led-tool-modal.active {
#settings-modal.active {
z-index: 1080;
}
@@ -1261,13 +1509,43 @@ body.preset-ui-run .edit-mode-only {
display: none;
}
/* Beat/downbeat toggle lives in the mobile menu only */
#seq-switch-toggle-wrap {
display: none !important;
}
.main-menu-dropdown {
max-width: min(16rem, calc(100vw - 1rem));
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
font-size: 0.7rem;
flex-shrink: 1;
min-width: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
width: 3.6rem;
height: 1.25rem;
flex-shrink: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
width: 0.9rem;
height: 0.9rem;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 0.9rem - 2px);
transform: translateY(-50%);
}
.header-menu-mobile {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0;
margin-left: 0;
}
.header-end {
@@ -1277,10 +1555,15 @@ body.preset-ui-run .edit-mode-only {
.header-end .audio-top-indicator {
min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0;
}
.header-end .audio-top-beat-sync {
padding: 0.2rem 0.4rem;
min-height: 2rem;
gap: 0.3rem;
}
.btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;
@@ -1439,7 +1722,35 @@ body.preset-ui-run .edit-mode-only {
font-weight: 600;
}
.zone-devices-editor {
.zone-devices-editor.zone-devices-panel {
display: flex;
flex-direction: column;
gap: 0;
margin-bottom: 0.5rem;
max-height: 14rem;
overflow: hidden;
min-height: 0;
}
.zone-devices-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.15rem;
}
.zone-devices-add-slot {
flex: 0 0 auto;
padding-top: 0.5rem;
margin-top: 0.35rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Legacy: single container without panel split (prefer zone-devices-panel.js). */
.zone-devices-editor:not(.zone-devices-panel) {
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -1682,8 +1993,211 @@ body.preset-ui-run .edit-mode-only {
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
}#settings-modal .modal-content > p.muted-text {
margin-bottom: 1rem;
}#settings-modal .settings-section.ap-settings-section {
}
#settings-modal.settings-modal--led-tool .modal-content {
max-width: 960px;
width: 95vw;
}
#settings-modal .settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0.75rem 0 1rem;
border-bottom: 1px solid #4a4a4a;
padding-bottom: 0.5rem;
}
#settings-modal .settings-tab-btn {
background: #3a3a3a;
color: #ccc;
border: 1px solid #4a4a4a;
border-radius: 4px 4px 0 0;
padding: 0.45rem 0.85rem;
font-size: 0.95rem;
cursor: pointer;
}
#settings-modal .settings-tab-btn:hover {
color: #fff;
border-color: #6a5acd;
}
#settings-modal .settings-tab-btn.active {
background: #1a1a1a;
color: #fff;
border-color: #6a5acd;
border-bottom-color: #1a1a1a;
margin-bottom: -1px;
}
#settings-modal .settings-tab-panel:not(.active) {
display: none;
}
#settings-modal .settings-led-tool-intro {
margin: 0 0 0.75rem;
}
#settings-modal .settings-led-tool-iframe {
width: 100%;
height: min(75vh, 720px);
border: 1px solid #4a4a4a;
border-radius: 4px;
background: #0b1020;
}
#settings-modal .settings-section.ap-settings-section {
margin-top: 1.5rem;
}
.settings-ping-results {
margin-top: 0.75rem;
min-height: 1.25rem;
}
.settings-ping-list {
list-style: none;
margin: 0;
padding: 0;
font-family: monospace;
font-size: 0.9rem;
}
.settings-ping-list li {
padding: 0.35rem 0;
border-bottom: 1px solid #333;
}
.settings-ping-list li:last-child {
border-bottom: none;
}
.settings-wifi-scan-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
}
.settings-wifi-scan-list li {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.35rem;
}
.settings-subheading {
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 600;
}
.settings-bridge-connection-details {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
}
.settings-bridge-connection-details li {
margin: 0.2rem 0;
}
.settings-bridge-profiles {
list-style: none;
padding: 0;
margin: 1rem 0 0;
}
.settings-bridge-profile-item {
margin-bottom: 0.65rem;
padding-bottom: 0.65rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.settings-bridge-profile-item:last-child {
border-bottom: none;
}
.settings-bridge-profile-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.settings-bridge-groups-panel {
margin: 0.5rem 0 0 0.25rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.settings-bridge-groups-hint {
margin: 0 0 0.5rem;
font-size: 0.85rem;
}
.settings-bridge-groups-checkboxes {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 14rem;
overflow-y: auto;
}
.settings-bridge-group-check {
display: flex;
align-items: flex-start;
gap: 0.45rem;
font-size: 0.9rem;
cursor: pointer;
}
.settings-bridge-group-check input:disabled + span {
opacity: 0.55;
}
.settings-bridge-profile-main {
display: flex;
align-items: center;
gap: 0.65rem;
flex: 1;
min-width: 10rem;
flex-wrap: wrap;
}
.settings-bridge-profile-label {
flex: 1 1 auto;
min-width: 8rem;
}
.settings-bridge-profile-status {
font-size: 0.85rem;
white-space: nowrap;
}
.settings-bridge-profile-status--connected {
color: #81c784;
}
.settings-bridge-profile-status--idle {
color: rgba(255, 255, 255, 0.45);
}
.settings-bridge-profile-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.settings-bridge-profile-delete {
opacity: 0.85;
}
.settings-ping-empty {
margin: 0;
font-style: italic;
}

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

@@ -107,6 +107,7 @@ function sendZoneBrightness(zoneId, value) {
[{ v: '1', b: bv, save: true }],
[mac],
0,
{ unicast: true },
);
}
return;
@@ -114,7 +115,7 @@ function sendZoneBrightness(zoneId, value) {
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
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') {
window.sendEspnowRaw({ v: '1', b: val, save: true });
}
@@ -304,14 +305,25 @@ async function resolveDeviceNamesForZonePreset(zoneDoc, presetId) {
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. Empty stepGroupIds => all zone tab devices (``names`` only).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
* 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);
@@ -321,7 +333,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return names.slice();
return [];
}
const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -402,7 +414,14 @@ function rowsToNames(rows) {
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
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(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
rows.forEach((row, idx) => {
@@ -429,7 +448,7 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
});
div.appendChild(label);
div.appendChild(rm);
containerEl.appendChild(div);
listEl.appendChild(div);
});
const idsInRows = new Set(rows.map((r) => String(r.id)));
@@ -458,7 +477,11 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
if (panel) {
panel.addSlot.appendChild(addWrap);
} else {
containerEl.appendChild(addWrap);
}
}
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
@@ -509,28 +532,16 @@ function effectiveZoneContentKind(zoneDoc) {
return 'presets';
}
/** @returns {'presets' | 'sequences'} */
function editModalContentKindSelected() {
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
}
function activeZoneContentKind(zoneDoc) {
const modal = document.getElementById('edit-zone-modal');
if (modal && modal.classList.contains('active')) {
return editModalContentKindSelected();
}
return effectiveZoneContentKind(zoneDoc);
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'sequences';
function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'sequences';
}
function applyZoneContentKindEditModal(kind) {
@@ -607,7 +618,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) {
const zone = tabs[zoneId];
if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : '';
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
const disp = zone.name || `Zone ${zoneId}`;
html += `
<button class="zone-button ${activeClass}"
@@ -898,114 +909,6 @@ 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 targets = await resolveZoneDeviceMacsFromZoneData(tabData);
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) {
return tabPresetIdsInZoneDoc(tabData);
}
@@ -1028,7 +931,7 @@ async function refreshEditTabPresetsUi(zoneId) {
return;
}
const tabData = await tabRes.json();
if (!zoneAllowsPresets(tabData)) {
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>';
@@ -1167,9 +1070,13 @@ async function openEditZoneModal(zoneId, zone) {
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.checked = radio.value === kind;
});
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");
applyZoneContentKindEditModal(kind);
@@ -1180,24 +1087,39 @@ async function openEditZoneModal(zoneId, zone) {
}
// Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows, contentKind) {
async function updateZone(zoneId, name, groupRows) {
try {
const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
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}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...existing,
name: name,
names: [],
group_ids: gids,
preset_group_ids: {},
content_kind: ck,
preset_group_ids:
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids
: {},
content_kind: lockedKind,
})
});
@@ -1206,6 +1128,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
// Reload tabs list
await loadZonesModal();
await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal
document.getElementById('edit-zone-modal').classList.remove('active');
return true;
@@ -1337,18 +1262,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) {
@@ -1362,20 +1275,12 @@ document.addEventListener('DOMContentLoaded', () => {
const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) {
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
await updateZone(zoneId, name, groupRows);
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 headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => {
@@ -1409,6 +1314,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
window.selectZone = selectZone;
// Export for use in other scripts
window.zonesManager = {
loadZones,
@@ -1426,6 +1333,7 @@ window.zonesManager = {
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveMacsForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,

View File

@@ -9,18 +9,21 @@
<body>
<div class="app-container">
<header>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<div class="audio-top-indicator-main">
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
<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 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>
</div>
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
</div>
<div class="header-actions">
<div class="header-brightness-control">
@@ -35,15 +38,21 @@
<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="color-palette-btn">Colour Palette</button>
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</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 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 class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<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>
<div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label>
@@ -57,13 +66,17 @@
<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="color-palette-btn">Colour Palette</button>
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" class="edit-mode-only" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
</div>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
</header>
<div class="main-content">
@@ -102,10 +115,7 @@
<input type="hidden" id="edit-zone-id">
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<div class="zone-content-kind-row muted-text">
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<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>
@@ -156,8 +166,26 @@
<div id="devices-modal" class="modal">
<div class="modal-content">
<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 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>
</div>
</div>
@@ -348,6 +376,10 @@
<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">
@@ -405,6 +437,12 @@
<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>
@@ -583,11 +621,11 @@
<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>
<h3>What led-tool does</h3>
<h3>LED Tool (Settings tab)</h3>
<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>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>
<div class="modal-actions">
@@ -598,115 +636,146 @@
<!-- Audio Modal -->
<div id="audio-modal" class="modal">
<div class="modal-content">
<div class="modal-content audio-modal-content">
<h2>Audio Beat Detection</h2>
<p class="muted-text">Select an input device and start beat detection.</p>
<div class="form-group">
<div class="form-group audio-device-block">
<label for="audio-device-select">Input device</label>
<div class="profiles-actions">
<select id="audio-device-select" style="flex: 1;">
<option value="">Default input</option>
<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" id="audio-refresh-btn">Refresh</button>
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
</div>
<small>Tip: for Pulse/pipewire playback capture, use a source containing <code>monitor</code>.</small>
<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 for="audio-device-override">Manual device override (optional)</label>
<input type="text" id="audio-device-override" placeholder='e.g. 3 or "alsa_output....monitor"'>
</div>
<div class="form-group">
<label>Current BPM</label>
<div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
</div>
<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">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></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="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
<div 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 class="form-group" style="margin-top: 0.75rem;">
<label for="audio-devices-debug">Detected devices (Python)</label>
<textarea id="audio-devices-debug" rows="8" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<h2>Device Settings</h2>
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
<div class="modal-content settings-modal-content">
<h2>Settings</h2>
<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>
<!-- Device Name -->
<div id="settings-panel-bridge" class="settings-tab-panel active" data-settings-panel="bridge" role="tabpanel" aria-labelledby="settings-tab-bridge">
<div class="settings-section">
<h3>Device</h3>
<form id="device-form">
<div class="profiles-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
<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">
<label for="device-name-input">Device Name</label>
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
<label for="bridge-serial-label">Profile label</label>
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
</div>
<div class="form-group">
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
<small>STA channel (111) for LED drivers and the serial bridge. Use the same value everywhere.</small>
<label for="bridge-serial-port">USB serial port</label>
<select id="bridge-serial-port">
<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 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">
<label for="ap-ssid">AP SSID (Network Name)</label>
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
<small>The name of the WiFi access point this device creates</small>
<label for="bridge-serial-baud">Baud rate</label>
<input type="number" id="bridge-serial-baud" value="115200" min="9600" max="3000000" step="1">
</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>
<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">
<label for="ap-password">AP Password</label>
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
<small>Leave empty for open network (min 8 characters if set)</small>
<label for="bridge-wifi-interface">WiFi adapter</label>
<select id="bridge-wifi-interface">
<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 class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<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>
<label for="bridge-wifi-ssid">Bridge SSID</label>
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
<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 class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
<h3 class="settings-subheading">Saved profiles</h3>
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
</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 class="modal-actions">
@@ -715,87 +784,11 @@
</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 -->
<script src="/static/zone-devices-panel.js"></script>
<script src="/static/groups.js"></script>
<script src="/static/zones.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/bundle_io.js"></script>
<script src="/static/profiles.js"></script>
@@ -805,5 +798,6 @@
<script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
<script src="/static/numpad.js"></script>
</body>
</html>

View File

@@ -182,7 +182,7 @@
<div class="form-group">
<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>
<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 class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
@@ -215,7 +215,7 @@
<div class="form-group">
<label for="ap-channel">Channel (1-11)</label>
<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 class="btn-group">
@@ -261,7 +261,7 @@
return;
}
try {
const response = await fetch('/settings/settings', {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wifi_channel: wifiChannel }),

View File

@@ -1,15 +1,15 @@
# 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
### Basic Message Building
```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 = {
"red_blink": build_preset_dict({
"pattern": "blink",
@@ -20,27 +20,17 @@ presets = {
})
}
select = build_select_dict({
"device1": "red_blink"
})
message = build_message(presets=presets, select=select)
# Result: {"v": "1", "presets": {...}, "select": {...}}
message = build_message(presets=presets, select=["red_blink"])
# Result: {"v": "1", "presets": {...}, "select": ["red_blink"]}
```
### Building Select Messages with Step Synchronization
### Select with step
```python
from util.espnow_message import build_message, build_select_dict
from util.espnow_message import build_message
# Select with step for synchronization
select = build_select_dict(
{"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]}}
message = build_message(select=["rainbow_preset", 10])
# Result: {"v": "1", "select": ["rainbow_preset", 10]}
```
### Converting Presets

View File

@@ -4,6 +4,15 @@ 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:
@@ -13,6 +22,13 @@ class AudioBeatDetector:
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,
@@ -20,11 +36,43 @@ class AudioBeatDetector:
"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()
@@ -39,15 +87,17 @@ class AudioBeatDetector:
name = str(dev.get("name", f"Input {idx}"))
chans = int(dev.get("max_input_channels", 0))
is_monitor_named = "monitor" in name.lower()
if chans <= 0 and not is_monitor_named:
continue
sr = int(dev.get("default_samplerate", 44100))
hostapi_idx = int(dev.get("hostapi", -1))
hostapi_name = (
str(hostapis[hostapi_idx].get("name", "unknown"))
if 0 <= hostapi_idx < len(hostapis)
else "unknown"
)
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})"
@@ -55,10 +105,14 @@ class AudioBeatDetector:
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,
@@ -85,6 +139,13 @@ class AudioBeatDetector:
}
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
@@ -92,6 +153,8 @@ class AudioBeatDetector:
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,
@@ -100,6 +163,11 @@ class AudioBeatDetector:
"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,
}
@@ -111,6 +179,7 @@ class AudioBeatDetector:
self._thread.start()
def stop(self):
self._stop_bpm_holdover()
with self._lock:
self._stop_event.set()
t = self._thread
@@ -139,11 +208,194 @@ class AudioBeatDetector:
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:
return dict(self._status)
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}")
@@ -152,14 +404,82 @@ class AudioBeatDetector:
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
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
@@ -185,6 +505,9 @@ class AudioBeatDetector:
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])
@@ -194,6 +517,10 @@ class AudioBeatDetector:
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"])
@@ -210,15 +537,17 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
min_ioi_ms=100.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
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)
@@ -243,23 +572,37 @@ class AudioBeatDetector:
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:
@@ -280,6 +623,7 @@ class AudioBeatDetector:
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.
@@ -299,3 +643,25 @@ def shared_beat_detector_running():
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

@@ -30,20 +30,62 @@ def read_audio_run_state() -> Dict[str, Any]:
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
return {
"enabled": False,
"device": None,
"device_override": "",
"device_select": "",
}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
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) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
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}
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")}
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:

View File

@@ -232,10 +232,12 @@ 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
if not device_names:
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()
@@ -265,6 +267,7 @@ def _apply_manual_beat_route(
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
"group_ids": gids,
}
_sync_public_beat_route_from_lane_table()
@@ -273,10 +276,12 @@ 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
if not device_names:
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()
@@ -299,12 +304,17 @@ def _apply_manual_beat_route_standalone_overlay(
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()
@@ -314,11 +324,13 @@ def set_sequence_manual_lane_route(
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()]
if not names or not isinstance(preset_body, dict) or _coerce_auto_from_body(preset_body):
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]
@@ -349,7 +361,13 @@ def set_sequence_manual_lane_route(
"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()
@@ -362,6 +380,59 @@ def clear_sequence_manual_lane_route(lane_index: int) -> None:
_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,
@@ -371,7 +442,8 @@ def sync_beat_route_from_push_sequence(
"""
Update beat routing from a ``/presets/push`` body ``sequence`` (list of v1 dicts).
With a ``select`` map: use its keys as device names (existing behaviour).
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
@@ -383,7 +455,9 @@ def sync_beat_route_from_push_sequence(
sequence lanes ``0..n`` keep their stride counters and wire ids.
"""
merged_presets: Dict[str, Any] = {}
last_select: Optional[Dict[str, Any]] = None
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:
@@ -396,11 +470,27 @@ def sync_beat_route_from_push_sequence(
if isinstance(pr, dict):
merged_presets.update(pr)
sel = item.get("select")
if isinstance(sel, dict) and sel:
last_select = sel
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:
device_names = [str(k).strip() for k in last_select.keys() if str(k).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})
@@ -408,7 +498,7 @@ def sync_beat_route_from_push_sequence(
wire_ids: Set[str] = set()
for name in device_names:
val = last_select.get(name)
val = last_select_map.get(name)
if isinstance(val, list) and val:
wire_ids.add(str(val[0]).strip())
elif val is not None:
@@ -418,6 +508,10 @@ def sync_beat_route_from_push_sequence(
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():
@@ -434,19 +528,24 @@ def sync_beat_route_from_push_sequence(
return
if preserve_parallel_lane_routes:
_apply_manual_beat_route_standalone_overlay(
device_names, wire_preset_id, preset_body
device_names, wire_preset_id, preset_body, group_ids=last_group_ids
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
_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)
_apply_manual_beat_route_standalone_overlay(
names, wire_id, body, group_ids=last_group_ids
)
else:
_apply_manual_beat_route(names, wire_id, body)
_apply_manual_beat_route(names, wire_id, body, group_ids=last_group_ids)
return
if not preserve_parallel_lane_routes:
@@ -500,61 +599,65 @@ def remap_beat_route_device_name(old_name: str, new_name: str) -> None:
_sync_public_beat_route_from_lane_table()
async def _deliver_select(device_names: List[str], wire_preset_id: str) -> None:
async def _deliver_select(
wire_preset_id: str,
group_ids: Optional[List[str]] = None,
) -> None:
from models.device import Device
from models.device import resolve_device_mac_for_select_routing
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
devices = Device()
seen_macs: List[str] = []
seen_set: Set[str] = set()
for n in device_names:
mac = resolve_device_mac_for_select_routing(devices, n)
if mac and mac not in seen_set:
seen_set.add(mac)
seen_macs.append(mac)
if not seen_macs:
return
select: Dict[str, Any] = {}
for mac in seen_macs:
doc = devices.read(mac) or {}
nm = str(doc.get("name") or "").strip()
if nm:
select[nm] = [wire_preset_id]
if not select:
return
msg = json.dumps({"v": "1", "select": select}, separators=(",", ":"))
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(sender, [msg], seen_macs, devices, delay_s=0.05)
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[List[str], str]]) -> None:
for names, pid in pairs:
await _deliver_select(names, pid)
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."""
"""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[List[str], str]] = []
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) or not names:
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):
@@ -564,7 +667,14 @@ def notify_beat_detected() -> None:
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
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:

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]

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)

View File

@@ -0,0 +1,38 @@
"""Length-prefixed serial frames between Pi and ESP-NOW bridge (same payload as WebSocket)."""
from __future__ import annotations
import struct
_MAX_SERIAL_FRAME = 4096
_MAX_SERIAL_BUF = 8192
def pack_serial_frame(payload: bytes) -> bytes:
if len(payload) > _MAX_SERIAL_FRAME:
raise ValueError(f"serial frame too large ({len(payload)} B)")
return struct.pack(">H", len(payload)) + payload
def feed_serial_buffer(buf: bytearray, chunk: bytes) -> list[bytes]:
"""Append ``chunk`` to ``buf`` and return any complete frames."""
if chunk:
buf.extend(chunk)
if len(buf) > _MAX_SERIAL_BUF:
del buf[:]
frames: list[bytes] = []
while True:
if len(buf) < 2:
break
(length,) = struct.unpack(">H", buf[0:2])
if length > _MAX_SERIAL_FRAME:
del buf[:1]
continue
total = 2 + length
if len(buf) < total:
if len(buf) > _MAX_SERIAL_BUF:
del buf[:]
break
frames.append(bytes(buf[2:total]))
del buf[:total]
return frames

View File

@@ -1,52 +1,22 @@
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
import asyncio
import json
import threading
from typing import Any, Set
# Threading lock: safe across asyncio tasks and avoids binding asyncio.Lock to the wrong loop.
_clients_lock = threading.Lock()
_clients: Set[Any] = set()
_ws_clients: set = set()
async def register_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.add(ws)
async def register_device_status_ws(ws):
_ws_clients.add(ws)
async def unregister_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.discard(ws)
async def unregister_device_status_ws(ws):
_ws_clients.discard(ws)
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
return
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
with _clients_lock:
targets = list(_clients)
dead = []
for ws in targets:
try:
await ws.send(msg)
except Exception as exc:
dead.append(ws)
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
if dead:
with _clients_lock:
for ws in dead:
_clients.discard(ws)
async def broadcast_device_tcp_snapshot_to(ws):
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
try:
await ws.send(msg)
except Exception as exc:
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")
async def broadcast_device_tcp_status(mac: str, connected: bool):
pass

View File

@@ -1,224 +1,148 @@
"""Deliver driver JSON messages over serial (ESP-NOW) and/or WebSocket (Wi-Fi drivers)."""
"""Deliver v1 JSON to drivers via bridge devices envelope."""
import asyncio
import json
from typing import Any, Dict, List, Optional, Set, Union
from models.device import normalize_mac
from models.wifi_ws_clients import send_json_line_to_ip
# Serial bridge (ESP32): broadcast MAC + this envelope → firmware unicasts ``body`` to each peer.
_SPLIT_MODE = "split"
_BROADCAST_MAC_HEX = "ffffffffffff"
from util.bridge_envelope import (
BROADCAST_MAC,
build_devices_envelope,
format_mac_key,
normalize_mac_key,
split_v1_body_for_espnow,
)
from util.espnow_message import build_message
_MAX_JSON_ESPNOW = 240
def _split_serial_envelope(inner_json_str, peer_hex_list):
"""One UART frame: broadcast dest + JSON {m:split, peers:[hex,...], body:<object>}."""
body = json.loads(inner_json_str)
env = {"m": _SPLIT_MODE, "peers": list(peer_hex_list), "body": body}
return json.dumps(env, separators=(",", ":"))
def _wifi_message_for_device(msg, device_name):
"""
For Wi-Fi WebSocket fanout, narrow a v1 select map to a single device name.
Returns the original message when no narrowing applies.
"""
if not device_name:
return msg
def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if isinstance(msg, dict):
if msg.get("v") == "1" and "devices" not in msg:
return dict(msg)
return None
if isinstance(msg, str):
try:
body = json.loads(msg)
except Exception:
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict):
return msg
if device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
def _combine_preset_chunks_for_wifi(chunk_messages):
"""Merge chunked v1 preset messages into one v1 JSON string for Wi-Fi."""
merged_presets = {}
save_flag = False
default_id = None
for msg in chunk_messages:
data = json.loads(msg)
except (ValueError, TypeError):
return None
return data if isinstance(data, dict) else None
if isinstance(msg, (bytes, bytearray)):
raw = bytes(msg)
if not raw or raw[0] != ord("{"):
return None
try:
body = json.loads(msg)
except Exception:
continue
if not isinstance(body, dict):
continue
presets = body.get("presets")
if isinstance(presets, dict):
merged_presets.update(presets)
if body.get("save"):
save_flag = True
if body.get("default") is not None:
default_id = body.get("default")
out = {"v": "1", "presets": merged_presets}
if save_flag:
out["save"] = True
if default_id is not None:
out["default"] = default_id
return json.dumps(out, separators=(",", ":"))
data = json.loads(raw.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return None
return data if isinstance(data, dict) else None
return None
async def deliver_preset_broadcast_then_per_device(
sender,
chunk_messages,
target_macs,
devices_model,
default_id,
delay_s=0.1,
):
"""
Send preset definition chunks: ESP-NOW broadcast once per chunk; same chunk to each
Wi-Fi driver over WebSocket. If default_id is set, send a per-target default message
(unicast serial or WebSocket) with targets=[device name] for each registry entry.
"""
if not chunk_messages:
return 0
seen = set()
ordered = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered.append(m)
wifi_ips = []
for mac in ordered:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi" and doc.get("address"):
wifi_ips.append(str(doc["address"]).strip())
async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
deliveries = 0
wifi_combined_msg = _combine_preset_chunks_for_wifi(chunk_messages)
for msg in chunk_messages:
tasks = [sender.send(msg, addr=_BROADCAST_MAC_HEX)]
results = await asyncio.gather(*tasks, return_exceptions=True)
if results and results[0] is True:
deliveries += 1
await asyncio.sleep(delay_s)
for ip in wifi_ips:
if not ip:
continue
try:
if await send_json_line_to_ip(ip, wifi_combined_msg):
chunks = split_v1_body_for_espnow(body)
except ValueError:
return 0
for chunk in chunks:
env = build_devices_envelope({mac_key: chunk})
if await bridge.send(env):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
if delay_s > 0:
await asyncio.sleep(delay_s)
if default_id:
did = str(default_id)
for mac in ordered:
doc = devices_model.read(mac) or {}
name = str(doc.get("name") or "").strip() or mac
body = {"v": "1", "default": did, "save": True, "targets": [name]}
out = json.dumps(body, separators=(",", ":"))
if doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
try:
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
try:
await sender.send(out, addr=mac)
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default serial failed: {e!r}")
await asyncio.sleep(delay_s)
return deliveries
async def deliver_json_messages(sender, messages, target_macs, devices_model, delay_s=0.1):
"""
Send each message string to the bridge and/or Wi-Fi WebSocket clients.
def build_preset_json_chunks(
presets_by_name: Dict[str, Any],
*,
save: bool = False,
default: Optional[str] = None,
max_payload: int = _MAX_JSON_ESPNOW,
) -> List[str]:
entries = list(presets_by_name.items())
chunks: List[str] = []
batch: Dict[str, Any] = {}
If target_macs is None or empty: one serial send per message (default/broadcast address).
Otherwise: Wi-Fi uses WebSocket in parallel. Multiple ESP-NOW peers are sent in **one** serial
write to the ESP32 (broadcast + split envelope); the bridge unicasts ``body`` to each
peer. A single ESP-NOW peer still uses one unicast serial frame. Wi-Fi and serial
tasks run together in one asyncio.gather.
def _msg_for(presets_map: Dict[str, Any], *, final_save: bool, def_id: Optional[str]) -> str:
return build_message(
presets=presets_map,
save=final_save,
default=def_id,
)
Returns (delivery_count, chunk_count) where chunk_count is len(messages).
"""
if not messages:
return 0, 0
for name, preset_obj in entries:
trial = dict(batch)
trial[name] = preset_obj
try:
msg = _msg_for(trial, final_save=False, def_id=None)
except (TypeError, ValueError):
msg = ""
if len(msg.encode("utf-8")) <= max_payload or not batch:
batch = trial
else:
chunks.append(_msg_for(batch, final_save=False, def_id=None))
batch = {name: preset_obj}
if batch:
chunks.append(
_msg_for(
batch,
final_save=save,
def_id=str(default) if default else None,
)
)
return [c for c in chunks if c]
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
"""One formatted MAC per target; empty list means broadcast."""
if not target_macs:
deliveries = 0
for msg in messages:
await sender.send(msg)
deliveries += 1
await asyncio.sleep(delay_s)
return deliveries, len(messages)
seen = set()
ordered_macs = []
return [BROADCAST_MAC]
keys: List[str] = []
seen: set = set()
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered_macs.append(m)
h = normalize_mac_key(raw)
if h and h not in seen:
seen.add(h)
keys.append(format_mac_key(h))
return keys if keys else [BROADCAST_MAC]
async def deliver_json_messages(
bridge,
messages,
target_macs,
devices_model,
delay_s=0.1,
*,
unicast: bool = False,
):
"""
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
or single-device identify.
Uses the current bridge connection only (per-group bridge assignment is disabled).
"""
del devices_model
from models.transport import get_current_bridge
active = get_current_bridge() or bridge
if active is None:
raise RuntimeError("Transport not configured")
if unicast and target_macs:
mac_keys = _unicast_mac_keys(target_macs)
else:
mac_keys = [BROADCAST_MAC]
deliveries = 0
for mac_key in mac_keys:
for msg in messages:
wifi_tasks = []
espnow_hex = []
for mac in ordered_macs:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi":
ip = doc.get("address")
if ip:
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(msg, name)
wifi_tasks.append(send_json_line_to_ip(ip, wifi_msg))
else:
espnow_hex.append(mac)
body = _body_from_message(msg)
if not body:
continue
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s)
tasks = []
espnow_peer_count = 0
if len(espnow_hex) > 1:
tasks.append(
sender.send(
_split_serial_envelope(msg, espnow_hex),
addr=_BROADCAST_MAC_HEX,
)
)
espnow_peer_count = len(espnow_hex)
elif len(espnow_hex) == 1:
tasks.append(sender.send(msg, addr=espnow_hex[0]))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if r is True:
deliveries += espnow_peer_count
elif isinstance(r, Exception):
print(f"[driver_delivery] serial delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
await asyncio.sleep(delay_s)
return deliveries, len(messages)

View File

@@ -55,29 +55,6 @@ def build_message(presets=None, select=None, save=False, default=None):
return json.dumps(message)
def build_select_message(device_name, preset_name, step=None):
"""
Build a select message for a single device.
Args:
device_name: Name of the device
preset_name: Name of the preset to select
step: Optional step value for synchronization
Returns:
Dictionary with select field ready to use in build_message
Example:
select = build_select_message("device1", "rainbow_preset", step=10)
message = build_message(select=select)
"""
select_list = [preset_name]
if step is not None:
select_list.append(step)
return {device_name: select_list}
def _hex_from_background_raw(bg_raw):
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
if isinstance(bg_raw, str):
@@ -238,30 +215,3 @@ def build_presets_dict(presets_data, palette_colors=None):
for preset_name, preset_data in presets_data.items():
result[preset_name] = build_preset_dict(preset_data, palette_colors)
return result
def build_select_dict(device_preset_mapping, step_mapping=None):
"""
Build a select dictionary mapping device names to select lists.
Args:
device_preset_mapping: Dictionary mapping device names to preset names
step_mapping: Optional dictionary mapping device names to step values
Returns:
Dictionary with select field ready to use in build_message
Example:
select = build_select_dict(
{"device1": "rainbow_preset", "device2": "pulse_preset"},
step_mapping={"device1": 10}
)
message = build_message(select=select)
"""
select = {}
for device_name, preset_name in device_preset_mapping.items():
select_list = [preset_name]
if step_mapping and device_name in step_mapping:
select_list.append(step_mapping[device_name])
select[device_name] = select_list
return select

86
src/util/espnow_ping.py Normal file
View File

@@ -0,0 +1,86 @@
"""ESP-NOW broadcast ping: collect PING_RSP from drivers."""
from __future__ import annotations
import asyncio
import secrets
import time
from typing import Any, Dict, Optional
from models.device import Device
from models.transport import get_current_bridge
from util.espnow_wire import pack_ping_req, parse_ping_rsp
_active: Dict[int, Dict[str, Any]] = {}
def register_device_from_ping(peer_mac: bytes, name: str) -> bool:
"""Add or update registry entry from a PING_RSP (drivers may not have sent ANNOUNCE yet)."""
if not peer_mac or len(peer_mac) != 6:
return False
mac_hex = peer_mac.hex()
label = (name or "").strip() or f"led-{mac_hex}"
did, persisted = Device().upsert_espnow_announced(mac_hex, label)
if did and persisted:
print(f"[espnow] registered mac={did} name={label!r} (ping)")
return bool(persisted)
def record_ping_rsp(peer_mac: bytes, packet: bytes) -> None:
info = parse_ping_rsp(packet)
if info is None:
return
session = _active.get(info["ping_id"])
if session is None:
return
mac_hex = peer_mac.hex()
session["responses"][mac_hex] = {
"mac": mac_hex,
"name": info["name"],
"rtt_ms": int((time.monotonic() - session["sent_at"]) * 1000),
}
if register_device_from_ping(peer_mac, info["name"]):
session["registered"] = int(session.get("registered", 0)) + 1
async def run_ping(*, timeout_s: float = 3.0) -> Dict[str, Any]:
"""
Broadcast PING_REQ and collect PING_RSP until ``timeout_s``.
Returns ``{ok, ping_id, timeout_s, responses}``; ``responses`` maps MAC hex to
``{mac, name, rtt_ms}``.
"""
bridge = get_current_bridge()
if bridge is None:
return {"ok": False, "error": "Transport not configured", "responses": {}}
ping_id = secrets.randbits(32) or 1
session: Dict[str, Any] = {
"responses": {},
"sent_at": time.monotonic(),
"registered": 0,
}
_active[ping_id] = session
pkt = pack_ping_req(ping_id)
ok = await bridge.send(pkt)
if not ok:
_active.pop(ping_id, None)
return {
"ok": False,
"error": "Send failed",
"ping_id": ping_id,
"responses": {},
}
await asyncio.sleep(timeout_s)
responses = dict(session["responses"])
registered = int(session.get("registered", 0))
_active.pop(ping_id, None)
return {
"ok": True,
"ping_id": ping_id,
"timeout_s": timeout_s,
"responses": responses,
"registered": registered,
}

179
src/util/espnow_registry.py Normal file
View File

@@ -0,0 +1,179 @@
"""Handle ESP-NOW uplink from bridge and push group membership."""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from models.device import Device, normalize_mac # noqa: F401 — re-export for callers
from models.group import Group
from models.transport import get_current_bridge
from util.bridge_envelope import build_groups_envelope
from util.espnow_ping import record_ping_rsp
from util.espnow_wire import (
MSG_ANNOUNCE,
MSG_PING_RSP,
WIRE_MAGIC,
mac_bytes_to_hex,
parse_announce,
wire_msg_type,
)
from util.groups_for_device import groups_for_mac
async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None:
"""Dispatch binary wire or JSON v1 hello from bridge uplink."""
if not payload:
return
if payload[0] == WIRE_MAGIC:
mt = wire_msg_type(payload)
if mt == MSG_ANNOUNCE:
await handle_espnow_announce(peer_mac, payload)
elif mt == MSG_PING_RSP:
record_ping_rsp(peer_mac, payload)
return
if payload[:1] == b"{":
try:
data = json.loads(payload.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return
if isinstance(data, dict):
await handle_json_hello(peer_mac, data)
async def _after_device_registered(mac_hex: str) -> None:
await push_groups_to_mac(mac_hex)
async def handle_json_hello(peer_mac: bytes, data: Dict[str, Any]) -> None:
"""Register device from driver JSON boot hello."""
if data.get("v") != "1":
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
name = data.get("name")
nested = data.get("settings")
if not name and isinstance(nested, dict):
name = nested.get("name")
name = str(name or mac_hex).strip() or mac_hex
num_leds = None
color_order = None
startup_mode = None
brightness = None
if isinstance(nested, dict):
try:
num_leds = int(nested.get("num_leds")) if nested.get("num_leds") is not None else None
except (TypeError, ValueError):
pass
color_order = nested.get("color_order")
startup_mode = nested.get("startup_mode")
try:
brightness = int(nested.get("brightness")) if nested.get("brightness") is not None else None
except (TypeError, ValueError):
pass
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
name,
device_type=data.get("type", "led"),
num_leds=num_leds,
color_order=color_order,
startup_mode=startup_mode,
brightness=brightness,
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={name!r} (json hello)")
await _after_device_registered(mac_hex)
async def handle_espnow_announce(peer_mac: bytes, packet: bytes) -> None:
info = parse_announce(packet)
if not info:
return
mac_hex = mac_bytes_to_hex(peer_mac)
if not mac_hex:
return
devices = Device()
did, persisted = devices.upsert_espnow_announced(
mac_hex,
info["name"],
device_type=info.get("device_type", "led"),
num_leds=info.get("num_leds"),
color_order=info.get("color_order"),
startup_mode=info.get("startup_mode"),
brightness=info.get("brightness"),
)
if not did:
return
if persisted:
print(f"[espnow] registered mac={did} name={info['name']!r}")
await _after_device_registered(mac_hex)
async def push_groups_for_group_devices(gdoc: dict) -> None:
"""Push group membership to each device MAC listed on the group."""
if not isinstance(gdoc, dict):
return
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
for mac in mac_list:
m = normalize_mac(str(mac))
if m:
await push_groups_to_mac(m)
async def push_groups_broadcast() -> bool:
"""No aggregate broadcast for group assignment; use per-device push."""
return False
async def push_groups_all_espnow_devices() -> Dict[str, Any]:
"""Push ``set_groups`` envelopes to every ESP-NOW device in the registry."""
devices_model = Device()
macs: list[str] = []
skipped = 0
for did, doc in devices_model.items():
if str(doc.get("transport") or "espnow").strip().lower() != "espnow":
continue
mac = normalize_mac(str(did)) or normalize_mac(str(doc.get("address") or ""))
if not mac:
skipped += 1
continue
macs.append(mac)
sent = 0
failed = 0
for mac in macs:
if await push_groups_to_mac(mac):
sent += 1
else:
failed += 1
ok = bool(macs) and failed == 0
return {
"ok": ok,
"sent": sent,
"failed": failed,
"skipped": skipped,
"total": len(macs),
}
async def push_groups_to_mac(mac_hex: str) -> bool:
"""Unicast groups envelope to one driver (set_groups true)."""
mac = normalize_mac(mac_hex)
if not mac:
return False
gids = groups_for_mac(mac, Group())
bridge = get_current_bridge()
if bridge is None:
return False
envelope = build_groups_envelope(mac, gids)
ok = await bridge.send(envelope)
if ok:
print(f"[espnow] groups sent mac={mac} groups={gids!r}")
return bool(ok)

336
src/util/espnow_wire.py Normal file
View File

@@ -0,0 +1,336 @@
"""
ESP-NOW wire format: magic header + message types, Pi↔bridge WebSocket framing.
See docs/espnow-binary-protocol.md.
"""
from __future__ import annotations
import struct
from typing import Any, Dict, List, Optional, Tuple
from util.binary_envelope import (
BINARY_ENVELOPE_VERSION_2,
pack_binary_envelope_v2,
parse_binary_envelope_v2,
)
WIRE_MAGIC = 0x4C
MAX_ESPNOW_PAYLOAD = 250
MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_PING_REQ = 0x05
MSG_PING_RSP = 0x06
MSG_BRIDGE_CH = 0x10
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
WS_FLAG_BROADCAST = 0x01
COLOR_ORDER_TO_ENUM = {
"rgb": 0,
"rbg": 1,
"grb": 2,
"gbr": 3,
"brg": 4,
"bgr": 5,
}
ENUM_TO_COLOR_ORDER = {v: k for k, v in COLOR_ORDER_TO_ENUM.items()}
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
ENUM_TO_STARTUP_MODE = {v: k for k, v in STARTUP_MODE_TO_ENUM.items()}
def normalize_mac_bytes(mac: Any) -> Optional[bytes]:
if mac is None:
return None
if isinstance(mac, (bytes, bytearray)) and len(mac) == 6:
return bytes(mac)
s = str(mac).strip().lower().replace(":", "").replace("-", "")
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return bytes.fromhex(s)
return None
def mac_bytes_to_hex(mac: bytes) -> str:
return mac.hex()
def _pack_header(msg_type: int, body: bytes) -> bytes:
pkt = bytes([WIRE_MAGIC, msg_type]) + body
if len(pkt) > MAX_ESPNOW_PAYLOAD:
raise ValueError(f"ESP-NOW packet {len(pkt)} exceeds {MAX_ESPNOW_PAYLOAD}")
return pkt
def pack_announce(
*,
name: str,
num_leds: int,
color_order: str = "rgb",
startup_mode: str = "default",
brightness: int = 32,
device_type: int = 0,
) -> bytes:
name_b = name.encode("utf-8")
if len(name_b) > 250:
raise ValueError("name too long")
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
body = (
bytes([len(name_b)])
+ name_b
+ struct.pack("<H", max(0, min(65535, int(num_leds))))
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
)
return _pack_header(MSG_ANNOUNCE, body)
def parse_announce(payload: bytes) -> Optional[Dict[str, Any]]:
"""Parse full ESP-NOW packet or body-only after type byte."""
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_ANNOUNCE:
return None
body = payload[2:]
else:
body = payload
off = 0
if off + 1 > len(body):
return None
nl = body[off]
off += 1
if off + nl + 5 > len(body):
return None
name = body[off : off + nl].decode("utf-8")
off += nl
num_leds = struct.unpack_from("<H", body, off)[0]
off += 2
co, sm, br, dt = body[off], body[off + 1], body[off + 2], body[off + 3]
return {
"name": name,
"num_leds": num_leds,
"color_order": ENUM_TO_COLOR_ORDER.get(co, "rgb"),
"startup_mode": ENUM_TO_STARTUP_MODE.get(sm, "default"),
"brightness": br,
"device_type": "led" if dt == 0 else str(dt),
}
def pack_groups(group_ids: List[str]) -> bytes:
parts = [bytes([min(255, len(group_ids))])]
for gid in group_ids[:255]:
gb = str(gid).encode("utf-8")
if len(gb) > 250:
raise ValueError("group id too long")
parts.append(bytes([len(gb)]))
parts.append(gb)
return _pack_header(MSG_GROUPS, b"".join(parts))
def parse_groups(payload: bytes) -> Optional[List[str]]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_GROUPS:
return None
body = payload[2:]
else:
body = payload
if not body:
return []
off = 0
if off + 1 > len(body):
return None
count = body[off]
off += 1
out: List[str] = []
for _ in range(count):
if off + 1 > len(body):
return None
gl = body[off]
off += 1
if off + gl > len(body):
return None
out.append(body[off : off + gl].decode("utf-8"))
off += gl
return out
def cmd_envelope_size(envelope: bytes) -> int:
from util.binary_envelope import HEADER_LEN
if len(envelope) < HEADER_LEN:
return len(envelope)
lp, ls, ld = envelope[2], envelope[3], envelope[4]
return HEADER_LEN + lp + ls + ld
def pack_cmd(envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("CMD envelope must be v2 binary")
need = cmd_envelope_size(envelope)
body = envelope[:need]
if save:
body = body + bytes([1])
if len(body) + 2 > MAX_ESPNOW_PAYLOAD:
raise ValueError("CMD envelope too large for ESP-NOW")
return _pack_header(MSG_CMD, body)
def pack_cmd_from_kwargs(*, save: bool = False, **kwargs: Any) -> bytes:
return pack_cmd(pack_binary_envelope_v2(**kwargs), save=save)
def parse_cmd(payload: bytes) -> Tuple[Optional[bytes], bool]:
"""Return (v2 envelope bytes, save_flag) inside CMD packet."""
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
return None, False
env = payload[2:]
if not env:
return None, False
need = cmd_envelope_size(env)
if need > len(env):
return None, False
save = len(env) > need and env[need] == 1
return bytes(env[:need]), save
def parse_cmd_as_v1_dict(payload: bytes) -> Optional[Dict[str, Any]]:
env, save = parse_cmd(payload)
if env is None:
return None
data = parse_binary_envelope_v2(env)
if data is None:
return None
if save:
data["save"] = True
return data
def pack_group_cmd(group_id: str, envelope: bytes, *, save: bool = False) -> bytes:
if envelope and envelope[0] != BINARY_ENVELOPE_VERSION_2:
raise ValueError("GROUP_CMD envelope must be v2 binary")
gid_b = str(group_id).encode("utf-8")
if len(gid_b) > 250:
raise ValueError("group id too long")
need = cmd_envelope_size(envelope)
env = envelope[:need]
if save:
env = env + bytes([1])
body = bytes([len(gid_b)]) + gid_b + env
return _pack_header(MSG_GROUP_CMD, body)
def pack_group_cmd_from_kwargs(group_id: str, **kwargs: Any) -> bytes:
return pack_group_cmd(group_id, pack_binary_envelope_v2(**kwargs))
def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
return None
body = payload[2:]
if not body:
return None
gl = body[0]
if 1 + gl > len(body):
return None
gid = body[1 : 1 + gl].decode("utf-8")
env = body[1 + gl :]
return gid, bytes(env)
def pack_ping_req(ping_id: int) -> bytes:
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF)
return _pack_header(MSG_PING_REQ, body)
def parse_ping_req(payload: bytes) -> Optional[int]:
"""Return ping_id from a PING_REQ packet or body."""
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_REQ:
return None
body = payload[2:]
else:
body = payload
if len(body) < 4:
return None
return int(struct.unpack_from("<I", body, 0)[0])
def pack_ping_rsp(ping_id: int, name: str) -> bytes:
name_b = name.encode("utf-8")
if len(name_b) > 250:
raise ValueError("name too long")
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF) + bytes([len(name_b)]) + name_b
return _pack_header(MSG_PING_RSP, body)
def parse_ping_rsp(payload: bytes) -> Optional[Dict[str, Any]]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_RSP:
return None
body = payload[2:]
else:
body = payload
if len(body) < 5:
return None
ping_id = int(struct.unpack_from("<I", body, 0)[0])
nl = body[4]
if len(body) < 5 + nl:
return None
name = body[5 : 5 + nl].decode("utf-8")
return {"ping_id": ping_id, "name": name}
def pack_bridge_channel(channel: int) -> bytes:
ch = max(1, min(11, int(channel)))
return _pack_header(MSG_BRIDGE_CH, bytes([ch]))
def parse_bridge_channel(payload: bytes) -> Optional[int]:
if len(payload) < 3 or payload[0] != WIRE_MAGIC or payload[1] != MSG_BRIDGE_CH:
return None
return int(payload[2])
def wire_msg_type(payload: bytes) -> Optional[int]:
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
return int(payload[1])
return None
def pack_ws_downlink(
espnow_packet: bytes,
*,
peer_mac: Optional[Any] = None,
broadcast: bool = False,
) -> bytes:
flags = WS_FLAG_BROADCAST if broadcast else 0
if broadcast:
peer = BROADCAST_MAC
else:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("peer MAC required for unicast downlink")
return bytes([flags]) + peer + espnow_packet
def pack_ws_uplink(peer_mac: bytes, espnow_packet: bytes) -> bytes:
peer = normalize_mac_bytes(peer_mac)
if peer is None:
raise ValueError("invalid peer MAC")
return bytes([0]) + peer + espnow_packet
def parse_ws_frame(frame: bytes) -> Tuple[bytes, bytes, bool]:
"""
Returns (peer_mac_6bytes, espnow_packet, is_broadcast_dest).
"""
if len(frame) < 8:
raise ValueError("WS frame too short")
flags = frame[0]
peer = frame[1:7]
pkt = frame[7:]
broadcast = bool(flags & WS_FLAG_BROADCAST) or peer == BROADCAST_MAC
return peer, pkt, broadcast

View File

@@ -0,0 +1,23 @@
"""Resolve group membership for a device MAC."""
from models.device import normalize_mac
def groups_for_mac(mac_hex: str, groups_model) -> list[str]:
"""Return group ids (string keys) that list this device MAC."""
mac = normalize_mac(mac_hex)
if not mac:
return []
out: list[str] = []
for gid in groups_model.list():
doc = groups_model.read(gid)
if not isinstance(doc, dict):
continue
devs = doc.get("devices")
if not isinstance(devs, list):
continue
for d in devs:
if normalize_mac(str(d)) == mac:
out.append(str(gid))
break
return out

229
src/util/pi_wifi.py Normal file
View File

@@ -0,0 +1,229 @@
"""Pi WiFi helpers via NetworkManager (nmcli)."""
from __future__ import annotations
import asyncio
import re
import shutil
from typing import Any, Dict, List, Optional
def nmcli_available() -> bool:
return shutil.which("nmcli") is not None
async def _run_nmcli(*args: str, timeout_s: float = 30.0) -> tuple[int, str, str]:
proc = await asyncio.create_subprocess_exec(
"nmcli",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError("nmcli timed out")
stdout = (stdout_b or b"").decode("utf-8", errors="replace")
stderr = (stderr_b or b"").decode("utf-8", errors="replace")
return proc.returncode or 0, stdout, stderr
def _unescape_nmcli(value: str) -> str:
return str(value or "").replace("\\:", ":").replace("\\\\", "\\")
def _interface_display_name(device: str) -> str:
"""Human-readable label for a network interface (USB model, path name, etc.)."""
dev = str(device or "").strip()
if not dev:
return ""
sysfs = f"/sys/class/net/{dev}"
try:
import subprocess
result = subprocess.run(
["udevadm", "info", "-q", "property", "-p", sysfs],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except Exception:
return dev
props: Dict[str, str] = {}
for line in (result.stdout or "").splitlines():
if "=" not in line:
continue
key, value = line.split("=", 1)
props[key] = value.strip()
for key in (
"ID_MODEL_FROM_DATABASE",
"ID_MODEL_ENC",
"ID_USB_MODEL_ENC",
"ID_NET_NAME_PATH",
):
value = props.get(key, "")
if value and value.lower() not in ("n/a", "na"):
return value
vendor = props.get("ID_VENDOR_FROM_DATABASE") or props.get("ID_VENDOR_ENC") or ""
model = props.get("ID_MODEL_ENC") or props.get("ID_USB_MODEL_ENC") or ""
label = f"{vendor} {model}".strip()
return label or dev
def list_wifi_interfaces() -> List[Dict[str, str]]:
if not nmcli_available():
return []
import subprocess
result = subprocess.run(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"],
capture_output=True,
text=True,
timeout=15,
check=False,
)
out: List[Dict[str, str]] = []
for line in (result.stdout or "").splitlines():
parts = line.split(":")
if len(parts) < 3:
continue
device, dtype = parts[0], parts[1]
if dtype != "wifi":
continue
connection = _unescape_nmcli(parts[-1]) if len(parts) >= 4 else ""
state = _unescape_nmcli(":".join(parts[2:-1] if len(parts) >= 4 else parts[2:]))
label = _interface_display_name(device)
out.append(
{
"device": device,
"type": dtype,
"state": state,
"connection": connection,
"label": label,
}
)
return out
async def scan_wifi(device: str) -> List[Dict[str, Any]]:
if not device:
raise ValueError("device is required")
code, stdout, stderr = await _run_nmcli(
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
device,
timeout_s=45.0,
)
if code != 0:
raise RuntimeError(stderr.strip() or stdout.strip() or "wifi scan failed")
networks: List[Dict[str, Any]] = []
seen: set[str] = set()
for line in stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(":")
if len(parts) < 2:
continue
security = _unescape_nmcli(parts[-1])
try:
signal = int(parts[-2])
except (TypeError, ValueError):
continue
ssid = _unescape_nmcli(":".join(parts[:-2]))
if not ssid or ssid in seen:
continue
seen.add(ssid)
networks.append({"ssid": ssid, "signal": signal, "security": security})
networks.sort(key=lambda n: n.get("signal", 0), reverse=True)
return networks
async def rescan_wifi(device: str) -> None:
if not device:
raise ValueError("device is required")
code, stdout, stderr = await _run_nmcli(
"device",
"wifi",
"rescan",
"ifname",
device,
timeout_s=30.0,
)
if code != 0:
msg = stderr.strip() or stdout.strip() or "wifi rescan failed"
raise RuntimeError(msg)
async def ssid_visible(device: str, ssid: str) -> bool:
target = str(ssid or "").strip()
if not target:
return False
for net in await scan_wifi(device):
if str(net.get("ssid") or "") == target:
return True
return False
async def connect_wifi(
*,
device: str,
ssid: str,
password: Optional[str] = None,
rescan: bool = True,
) -> None:
ssid = str(ssid or "").strip()
if not ssid:
raise ValueError("ssid is required")
if not device:
raise ValueError("device is required")
if rescan:
await rescan_wifi(device)
args = ["device", "wifi", "connect", ssid, "ifname", device]
pw = str(password or "").strip()
if pw:
args.extend(["password", pw])
code, stdout, stderr = await _run_nmcli(*args, timeout_s=60.0)
if code != 0:
msg = stderr.strip() or stdout.strip() or "connect failed"
raise RuntimeError(msg)
async def wait_for_device(device: str, *, timeout_s: float = 25.0) -> str:
"""Return connection state string for ``device``."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
code, stdout, _stderr = await _run_nmcli(
"-t",
"-f",
"DEVICE,STATE",
"device",
timeout_s=10.0,
)
if code == 0:
for line in stdout.splitlines():
parts = line.split(":")
if len(parts) >= 2 and parts[0] == device:
state = parts[1]
if state in ("connected", "connected (local)"):
return state
await asyncio.sleep(1.0)
raise RuntimeError(f"{device} did not connect within {int(timeout_s)}s")
def build_bridge_ws_url(ap_ip: str, ws_port: int = 80) -> str:
ip = str(ap_ip or "192.168.4.1").strip()
if not re.match(r"^\d{1,3}(\.\d{1,3}){3}$", ip):
raise ValueError("invalid ap_ip")
port = int(ws_port)
if port < 1 or port > 65535:
raise ValueError("invalid ws_port")
return f"ws://{ip}:{port}/ws"

View File

@@ -0,0 +1,331 @@
"""Enumerate capture sources the way PulseAudio / PipeWire presents them (pavucontrol)."""
from __future__ import annotations
import os
import re
import subprocess
from typing import Any, Dict, List, Optional
# Pulse virtual / null sources — not shown in pavucontrol's input list.
_SKIP_PULSE_NAMES = frozenset(
{
"auto_null",
"null",
"echo-cancel-source",
}
)
def _pactl_bin() -> str:
for path in ("/usr/bin/pactl", "/bin/pactl"):
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return "pactl"
def _pactl_ok(args: List[str]) -> bool:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return False
return proc.returncode == 0
def _run_pactl(args: List[str]) -> Optional[str]:
try:
proc = subprocess.run(
[_pactl_bin(), *args],
capture_output=True,
text=True,
timeout=5,
check=False,
env=os.environ.copy(),
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
return proc.stdout
def _parse_pactl_sources_short(text: str) -> List[Dict[str, str]]:
"""``pactl list sources short`` — tab-separated name per line."""
sources: List[Dict[str, str]] = []
for line in text.splitlines():
line = line.strip()
if not line or line.lower().startswith("source"):
continue
parts = re.split(r"\s+", line, maxsplit=4)
if len(parts) < 2:
continue
name = parts[1].strip()
if not name or name in _SKIP_PULSE_NAMES:
continue
sources.append({"name": name, "description": name})
return sources
def _parse_pactl_sources(text: str) -> List[Dict[str, str]]:
sources: List[Dict[str, str]] = []
block: Dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if line.startswith("Source #"):
if block.get("name"):
sources.append(block)
block = {}
continue
if not line or ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip().lower()
val = val.strip()
if key == "name":
block["name"] = val
elif key == "description":
block["description"] = val
elif key == "state":
block["state"] = val
if block.get("name"):
sources.append(block)
return sources
def _default_pulse_source_name() -> Optional[str]:
out = _run_pactl(["get-default-source"])
if not out:
return None
name = out.strip()
return name or None
def _name_tokens(*parts: str) -> set:
stop = frozenset(
{
"alsa",
"input",
"output",
"monitor",
"usb",
"device",
"mono",
"stereo",
"analog",
"digital",
"audio",
"source",
"sink",
"pipewire",
"pulse",
"default",
"hw",
"facade",
"capture",
"playback",
}
)
tokens: set = set()
for part in parts:
raw = part.lower().replace(".", " ").replace("-", " ").replace("_", " ")
for tok in re.findall(r"[a-z0-9]+", raw):
if len(tok) >= 2 and tok not in stop:
tokens.add(tok)
return tokens
def _match_sounddevice_index(description: str, pulse_name: str) -> Optional[int]:
try:
import sounddevice as sd
except ImportError:
return None
devices = sd.query_devices()
desc_l = description.lower()
pulse_l = pulse_name.lower()
pulse_tokens = _name_tokens(pulse_name, description)
best: Optional[int] = None
best_score = 0
for idx, dev in enumerate(devices):
chans = int(dev.get("max_input_channels", 0))
sd_name = str(dev.get("name", ""))
sd_l = sd_name.lower()
if chans <= 0 and "monitor" not in sd_l:
continue
score = 0
if sd_name == description:
score = 100
elif desc_l == sd_l:
score = 95
elif desc_l in sd_l or sd_l in desc_l:
score = 80
elif pulse_l in sd_l or sd_l in pulse_l:
score = 60
else:
desc_tokens = _name_tokens(description, sd_name)
overlap = pulse_tokens & desc_tokens
if len(overlap) >= 1:
score = 35 + 15 * len(overlap)
if score < 50 and "monitor" in desc_l and "monitor" in sd_l:
desc_tail = desc_l.replace("monitor of", "").strip()
if desc_tail and desc_tail in sd_l:
score = max(score, 55)
if score > best_score:
best_score = score
best = idx
return best if best_score >= 35 else None
def _looks_like_pulse_source_name(text: str) -> bool:
t = text.strip().lower()
return (
t.startswith("alsa_")
or t.startswith("pulse_")
or ".monitor" in t
or "monitor_of" in t.replace("-", "_")
)
def _sounddevice_index_via_pulse_default(pulse_name: str) -> Optional[int]:
"""Set Pulse default source, then open sounddevice's default input index."""
if not pulse_name or not _pactl_ok(["set-default-source", pulse_name]):
return None
try:
import sounddevice as sd
idx = int(sd.default.device[0])
if idx >= 0:
return idx
except (TypeError, ValueError, ImportError):
pass
return None
def resolve_capture_device(device: Any) -> Any:
"""
Return a sounddevice input index (int) or None for host default.
Accepts int index, numeric string, Pulse source name, or friendly description.
"""
if device is None or device == "":
return None
if isinstance(device, int):
return device
text = str(device).strip()
if not text:
return None
try:
return int(text)
except (TypeError, ValueError):
pass
if _looks_like_pulse_source_name(text):
# Prefer pactl default — works when PortAudio names do not match Pulse ids.
idx = _sounddevice_index_via_pulse_default(text)
if idx is not None:
return idx
idx = _match_sounddevice_index(text, text)
if idx is None:
for src in list_pulse_matched_input_devices():
pn = str(src.get("pulse_name") or "")
if pn == text or pn.startswith(text) or text.startswith(pn):
pid = src.get("id")
if isinstance(pid, int):
return pid
desc = str(src.get("name") or src.get("display_name") or "")
idx = _match_sounddevice_index(desc, pn or text)
if idx is not None:
return idx
if idx is not None:
return idx
raise RuntimeError(
f"No PortAudio capture device for Pulse source {text!r}. "
"Try System default input, or set this source as default in PulseAudio first."
)
idx = _match_sounddevice_index(text, text)
if idx is not None:
return idx
raise RuntimeError(f"No input device matching {text!r}")
def _enrich_pulse_descriptions(sources: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Merge Description fields from ``pactl list sources`` when available."""
text = _run_pactl(["list", "sources"])
if not text:
return sources
by_name = {s.get("name", ""): s for s in _parse_pactl_sources(text)}
out: List[Dict[str, str]] = []
for src in sources:
name = src.get("name", "")
full = by_name.get(name) or {}
desc = full.get("description") or src.get("description") or name
merged = dict(src)
merged["description"] = desc
out.append(merged)
return out
def list_pulse_matched_input_devices() -> List[Dict[str, Any]]:
"""
Sources from ``pactl list sources``, matched to sounddevice indices when possible.
Returns [] if pactl is unavailable or yields no usable sources.
"""
short = _run_pactl(["list", "sources", "short"])
raw_sources: List[Dict[str, str]] = []
if short:
raw_sources = _parse_pactl_sources_short(short)
if not raw_sources:
text = _run_pactl(["list", "sources"])
if text:
raw_sources = _parse_pactl_sources(text)
if not raw_sources:
return []
raw_sources = _enrich_pulse_descriptions(raw_sources)
default_name = _default_pulse_source_name()
out: List[Dict[str, Any]] = []
for src in raw_sources:
pulse_name = src.get("name", "")
description = src.get("description") or pulse_name
if not pulse_name or pulse_name in _SKIP_PULSE_NAMES:
continue
if description.lower() in ("null", "auto_null"):
continue
desc_l = description.lower()
is_monitor = desc_l.startswith("monitor of") or ".monitor" in pulse_name.lower()
# Pulse source name is stable across refreshes; sounddevice index is not.
device_id: Any = pulse_name
sd_idx = _match_sounddevice_index(description, pulse_name)
is_default = default_name is not None and pulse_name == default_name
display_name = description
if is_default and "(default)" not in display_name.lower():
display_name = f"{display_name} (default)"
label = f"{description} [{pulse_name}]"
if sd_idx is not None:
label = f"[{sd_idx}] {label}"
out.append(
{
"id": device_id,
"name": description,
"display_name": display_name,
"label": label,
"pulse_name": pulse_name,
"sounddevice_index": sd_idx,
"is_monitor": is_monitor,
"is_default": is_default,
"hostapi": "PulseAudio",
}
)
if not out:
return []
out.sort(
key=lambda d: (
0 if d.get("is_monitor") else 1,
str(d.get("display_name") or "").lower(),
)
)
return out

File diff suppressed because it is too large Load Diff

115
src/util/v1_wire.py Normal file
View File

@@ -0,0 +1,115 @@
"""Short v1 field names for ESP-NOW JSON (≤250 B). Long names still accepted on receive."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
# Envelope: devices map
ENV_DEVICES = "dv"
# Device body
K_PRESETS = "p"
K_SELECT = "s"
K_GROUPS = "g"
K_SET_GROUPS = "sg"
K_SAVE = "sv"
K_DEFAULT = "df"
K_DEVICE_CONFIG = "dc"
K_CLEAR_PRESETS = "cp"
K_MANIFEST = "mf"
_BODY_LONG_TO_SHORT = {
"presets": K_PRESETS,
"select": K_SELECT,
"groups": K_GROUPS,
"set_groups": K_SET_GROUPS,
"save": K_SAVE,
"default": K_DEFAULT,
"device_config": K_DEVICE_CONFIG,
"clear_presets": K_CLEAR_PRESETS,
"manifest": K_MANIFEST,
}
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
def normalize_select_for_wire(select: Any) -> Any:
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
if isinstance(select, list):
return select
if isinstance(select, str) and select.strip():
return [select.strip()]
if not isinstance(select, dict):
return select
if "preset" in select:
out: List[Any] = [str(select["preset"])]
if "step" in select:
out.append(select["step"])
return out
# Legacy {device_name: [preset, step?]} — unicast only; keep dict for expand on driver
if len(select) == 1:
val = next(iter(select.values()))
if isinstance(val, list) and val:
return list(val)
return select
def compact_body(body: Dict[str, Any]) -> Dict[str, Any]:
"""Long-key device body → short keys for the wire."""
out: Dict[str, Any] = {}
for long_key, short_key in _BODY_LONG_TO_SHORT.items():
if long_key in body:
val = body[long_key]
if long_key == "select":
val = normalize_select_for_wire(val)
out[short_key] = val
for short_key in _BODY_SHORT_TO_LONG:
if short_key in body and short_key not in out:
val = body[short_key]
if short_key == K_SELECT:
val = normalize_select_for_wire(val)
out[short_key] = val
if "b" in body:
out["b"] = body["b"]
return out
def expand_body(body: Dict[str, Any]) -> Dict[str, Any]:
"""Short or long device body → long keys for driver logic."""
out: Dict[str, Any] = dict(body)
for short_key, long_key in _BODY_SHORT_TO_LONG.items():
if short_key in body and long_key not in out:
out[long_key] = body[short_key]
if short_key in out:
del out[short_key]
return out
def compact_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
if envelope.get("v") != "1":
return envelope
devices = envelope.get("devices")
if devices is None:
devices = envelope.get(ENV_DEVICES)
if not isinstance(devices, dict):
return envelope
compact_devices = {mac: compact_body(body) for mac, body in devices.items() if isinstance(body, dict)}
return {"v": "1", ENV_DEVICES: compact_devices}
def expand_envelope(envelope: Dict[str, Any]) -> Dict[str, Any]:
if envelope.get("v") != "1":
return envelope
devices = envelope.get("devices")
if devices is None:
devices = envelope.get(ENV_DEVICES)
if not isinstance(devices, dict):
return envelope
expanded = {mac: expand_body(body) for mac, body in devices.items() if isinstance(body, dict)}
return {"v": "1", "devices": expanded}
def wire_json_size(obj: Dict[str, Any]) -> int:
import json
return len(json.dumps(obj, separators=(",", ":")).encode("utf-8"))

View File

@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
default=0.12,
help="Aubio detection threshold",
)
parser.add_argument(
"--silence-gate-db",
type=float,
default=-58.0,
help="Ignore beat triggers when frame RMS is below this dB level",
)
return parser.parse_args()
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
return 60.0 / float(np.median(valid))
def _is_plausible_ioi(
last_trigger_s: float,
beat_times: Deque[float],
now_s: float,
*,
min_ratio: float = 0.42,
max_ratio: float = 2.5,
) -> bool:
"""Reject double-time / half-time false triggers vs recent median interval."""
if last_trigger_s <= 0 or len(beat_times) < 2:
return True
ioi = now_s - last_trigger_s
if ioi <= 0:
return False
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
if intervals.size == 0:
return True
med = float(np.median(intervals))
if med < 0.05:
return True
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
class BarPhaseTracker:
"""Track beat-in-bar from downbeat counting (kick hints)."""
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
self.beats_per_bar = max(1, int(beats_per_bar))
self.kick_conf_min = float(kick_conf_min)
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def reset(self) -> None:
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def anchor_downbeat(self, now_s: float) -> None:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self.confidence = max(self.confidence, 0.85)
def _bar_duration_s(
self, bpm: float | None, median_ioi: float | None
) -> float | None:
if bpm is not None and bpm > 0:
return (60.0 / float(bpm)) * self.beats_per_bar
if median_ioi is not None and median_ioi > 0:
return float(median_ioi) * self.beats_per_bar
return None
@staticmethod
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
if bar_dur <= 0 or elapsed <= 0:
return False
n = elapsed / bar_dur
nearest = max(1, round(n))
return abs(n - nearest) <= tol
def on_beat(
self,
now_s: float,
beat_type: str,
beat_type_conf: float,
*,
bpm: float | None = None,
median_ioi: float | None = None,
) -> dict[str, int | float | bool | str]:
self._total_beats += 1
bar_dur = self._bar_duration_s(bpm, median_ioi)
is_kick = (
str(beat_type or "").lower() == "kick"
and float(beat_type_conf or 0.0) >= self.kick_conf_min
)
downbeat_locked = False
if is_kick:
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
downbeat_locked = True
elif bar_dur and self._near_whole_bars(
now_s - self._last_downbeat_s, bar_dur
):
downbeat_locked = True
elif is_kick and self.bar_beat >= max(2, self.beats_per_bar - 1):
downbeat_locked = True
prev_bar_beat = int(self.bar_beat)
if downbeat_locked:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self._aligned_kicks += 1
elif self._total_beats <= 1:
self.bar_beat = 1
self.is_downbeat = True
else:
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
self.is_downbeat = self.bar_beat == 1
if self._total_beats >= self.beats_per_bar:
bars_seen = max(1, self._total_beats // self.beats_per_bar)
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
return {
"bar_beat": int(self.bar_beat),
"beats_per_bar": int(self.beats_per_bar),
"is_downbeat": bool(self.is_downbeat),
"phase_confidence": round(float(self.confidence), 3),
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
}
def _resolve_bpm(
beat_times: Deque[float],
aubio_bpm: float | None,
) -> float | None:
estimated = _estimate_bpm(beat_times)
if estimated is None:
return aubio_bpm
if aubio_bpm is None or aubio_bpm <= 0:
return estimated
ratio = float(aubio_bpm) / estimated
if ratio > 1.75 or ratio < 0.57:
return estimated
return estimated
def _load_aubio_if_needed(mode: str):
if mode == "custom":
return None
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
)
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
@@ -192,6 +323,9 @@ class BeatDetectRuntime:
self.beat_times.clear()
self.tempo = None
if self.aubio is not None:
self._init_aubio_tempo(win_size)
def _init_aubio_tempo(self, win_size: int):
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
@@ -200,6 +334,27 @@ class BeatDetectRuntime:
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def reset_tempo_state(self) -> None:
"""Clear tempo/aubio history without losing bar phase."""
self.baseline = 1e-6
if self.prev_mag is not None:
self.prev_mag[:] = 0.0
self.beat_times.clear()
self.last_trigger_s = 0.0
if self.aubio is not None and self.sample_rate > 0:
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
self._init_aubio_tempo(win_size)
def reset_state(self):
"""Full reset (manual): tempo history and bar phase."""
self.reset_tempo_state()
self.bar_phase.reset()
def anchor_bar_phase(self, now_s: float | None = None) -> None:
if now_s is None:
now_s = time.time()
self.bar_phase.anchor_downbeat(now_s)
def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
if db < float(self.args.silence_gate_db):
return None
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
should_trigger = aubio_hit
else:
should_trigger = custom_hit or aubio_hit
if should_trigger and not _is_plausible_ioi(
self.last_trigger_s, self.beat_times, now_s
):
should_trigger = False
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
bpm = _resolve_bpm(self.beat_times, aubio_bpm)
strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag)
median_ioi = None
if len(self.beat_times) >= 2:
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
if intervals.size > 0:
median_ioi = float(np.median(intervals))
phase = self.bar_phase.on_beat(
now_s,
beat_type,
beat_type_conf,
bpm=bpm,
median_ioi=median_ioi,
)
if self.args.mode == "custom":
src = "custom"
elif self.args.mode == "aubio":
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": db,
**phase,
}

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Send v1 JSON to drivers via the bridge (broadcast passthrough).
The simplified ``espnow-sender`` forwards each WebSocket message unchanged to
ESP-NOW ``ff:ff:ff:ff:ff:ff``. Drivers accept JSON when the payload starts with
``{`` (see ``led-driver/src/main.py``).
Examples::
pipenv run python tests/bridge_broadcast_test.py
pipenv run python tests/bridge_broadcast_test.py --url ws://192.168.4.1/ws
pipenv run python tests/bridge_broadcast_test.py --brightness 200
pipenv run python tests/bridge_broadcast_test.py --select led-abc --state on
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util.espnow_message import build_message # noqa: E402
def _load_bridge_url(explicit: str | None) -> str:
if explicit and explicit.strip():
return explicit.strip()
path = PROJECT_ROOT / "settings.json"
if path.is_file():
try:
data = json.loads(path.read_text(encoding="utf-8"))
url = str(data.get("bridge_ws_url") or "").strip()
if url:
return url
except (OSError, json.JSONDecodeError, TypeError):
pass
return "ws://192.168.4.1/ws"
async def _send_messages(url: str, messages: list[bytes], delay_s: float) -> None:
import websockets
print(f"connecting to {url}")
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
print("connected (broadcast JSON passthrough)")
for i, pkt in enumerate(messages):
preview = pkt[:80].decode("utf-8", errors="replace")
if len(pkt) > 80:
preview += ""
print(f" send [{i + 1}/{len(messages)}] {len(pkt)} B {preview!r}")
await ws.send(pkt)
if delay_s > 0 and i + 1 < len(messages):
await asyncio.sleep(delay_s)
print("done")
def _build_messages(args: argparse.Namespace) -> list[bytes]:
messages: list[bytes] = []
if args.brightness is not None:
body: dict = {
"v": "1",
"b": max(0, min(255, int(args.brightness))),
}
if args.save:
body["save"] = True
messages.append(json.dumps(body, separators=(",", ":")).encode("utf-8"))
if args.select:
messages.append(
build_message(
select={args.select: [args.state]},
save=args.save,
).encode("utf-8")
)
if args.off:
if args.select:
messages.append(
build_message(select={args.select: ["off"]}, save=args.save).encode("utf-8")
)
else:
messages.append(
build_message(select={"all": ["off"]}, save=args.save).encode("utf-8")
)
if not messages:
messages.append(
json.dumps({"v": "1", "b": 128}, separators=(",", ":")).encode("utf-8")
)
messages.append(
build_message(select={"all": ["on"]}).encode("utf-8")
)
messages.append(
build_message(select={"all": ["off"]}).encode("utf-8")
)
for pkt in messages:
if not pkt or pkt[0:1] != b"{":
raise ValueError("built message is not v1 JSON")
return messages
def main() -> int:
parser = argparse.ArgumentParser(
description="Broadcast v1 JSON to LED drivers through the bridge WebSocket.",
)
parser.add_argument(
"--url",
default=None,
help="Bridge WebSocket URL (default: settings.json bridge_ws_url or ws://192.168.4.1/ws)",
)
parser.add_argument(
"--delay",
type=float,
default=0.5,
help="Seconds between messages (default: 0.5)",
)
parser.add_argument(
"--brightness",
"-b",
type=int,
default=None,
metavar="0-255",
help="Global brightness (b field)",
)
parser.add_argument(
"--select",
metavar="DEVICE_NAME",
help="Device name in select map (must match driver settings name)",
)
parser.add_argument(
"--state",
default="on",
help="Pattern/state for --select (default: on)",
)
parser.add_argument(
"--off",
action="store_true",
help="Send select off (all devices if --select omitted)",
)
parser.add_argument(
"--save",
action="store_true",
help="Set save flag on messages",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print messages only; do not connect",
)
args = parser.parse_args()
url = _load_bridge_url(args.url)
try:
messages = _build_messages(args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"url={url!r} messages={len(messages)}")
for pkt in messages:
print(f" {pkt.decode('utf-8')}")
if args.dry_run:
return 0
try:
asyncio.run(_send_messages(url, messages, args.delay))
except KeyboardInterrupt:
print("interrupted")
return 130
except Exception as e:
print(f"failed: {e!r}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show More