10 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
106 changed files with 10815 additions and 2213 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

File diff suppressed because one or more lines are too long

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)

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 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
@@ -81,17 +76,8 @@ _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":
return None
ip = normalize_tcp_peer_ip(dev_dict.get("address") or "")
if not ip:
return False
return tcp_client_connected(ip)
"""ESP-NOW has no live session flag on the Pi."""
return None
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)
if not ok:
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,30 +494,19 @@ 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)
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
ok = await bridge.send({"v": "1", "b": b_val, "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",
}
else:
sender = get_current_sender()
if not sender:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
try:
await sender.send(msg, addr=id)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"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,8 +3,8 @@ 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 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
@@ -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

@@ -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,43 +224,17 @@ 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),
save=save_flag,
default=default_id,
)
)
total_presets = len(presets_by_name)
chunk_messages = build_preset_json_chunks(
presets_by_name,
save=save_flag,
default=str(default_id) if default_id is not None else None,
)
target_list = None
raw_targets = data.get("targets")
@@ -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,
target_list,
Device(),
str(default_id) if default_id is not None else None,
delay_s=send_delay_s,
)
if unicast and target_list:
deliveries = 0
for msg in chunk_messages:
d, _chunks = await deliver_json_messages(
bridge, [msg],
target_list,
Device(),
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):
messages.append(item)
else:
i = 0
while i < len(seq):
item = seq[i]
if not isinstance(item, dict):
if isinstance(item, str):
messages.append(item)
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
@@ -254,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,

View File

@@ -3,7 +3,6 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import get_settings
controller = Microdot()
@@ -89,6 +88,13 @@ def _validate_audio_beat_phase_ms(value):
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."""
@@ -105,16 +111,11 @@ async def update_settings(request):
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

@@ -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 get_settings
from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -23,171 +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:
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
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 _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 = 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()
@@ -202,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:
@@ -240,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:
@@ -305,12 +204,13 @@ 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
@@ -318,12 +218,31 @@ async def main(port=80):
enabled=True,
device=device,
device_override=str(payload.get("device_override") or ""),
device_select=str(payload.get("device_select") 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
@@ -389,6 +308,11 @@ async def main(port=80):
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"
@@ -408,56 +332,34 @@ 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, "shutting_down": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
@@ -472,13 +374,6 @@ async def main(port=80):
t.cancel()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
u.close()
except OSError:
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
try:
app.shutdown()
@@ -497,15 +392,11 @@ async def main(port=80):
except (NotImplementedError, RuntimeError):
pass
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
@@ -534,7 +425,6 @@ async def main(port=80):
app.server = None
except Exception:
pass
udp_holder["closing"] = True
for t in list(server_tasks):
if not t.done():
t.cancel()

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()
if isinstance(data, dict):
return json.dumps(data).encode()
return data
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):
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_client()
if client is None:
return False
return await client.send_packet(envelope)
def _parse_mac(addr):
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
if addr is None or addr == b"":
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
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
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
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)
_current_bridge = None
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
def set_bridge(bridge):
global _current_bridge
_current_bridge = bridge
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
def get_current_bridge():
return _current_bridge
_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:
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

@@ -183,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:
@@ -199,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
@@ -213,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

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)."""
@@ -51,32 +53,20 @@ 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'
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 0
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 WebSocket dial attempts per driver UDP hello (then wait for next hello).
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
@@ -88,12 +78,16 @@ class Settings(dict):
# 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:

View File

@@ -2,7 +2,6 @@
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;
/**
@@ -10,10 +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();
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);
@@ -28,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;
}
}
@@ -73,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;
@@ -136,11 +75,9 @@
top.classList.toggle("audio-running", !!on);
}
function setNavResetVisible(on) {
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
const node = el(id);
if (node) node.hidden = !on;
}
function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on;
}
async function resetAudioTracking() {
@@ -160,20 +97,21 @@
}
}
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 topSync = el("audio-top-beat-sync");
if (topSync) {
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
topSync.title = !audioDetectorRunning
? "Start beat detection"
: zoneSeqActive
? "Sync step to music (S)"
: "Beat detection running";
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;
}
const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive;
}
async function handleTopBpmButtonClick() {
@@ -212,17 +150,41 @@
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 node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const syncBtn = el("audio-top-beat-sync");
const top = el("audio-top-indicator");
if (syncBtn && top && top.classList.contains("audio-running")) {
syncBtn.classList.add("flash");
setTimeout(() => syncBtn.classList.remove("flash"), 90);
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() {
@@ -231,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 0;
return Math.min(500, Math.max(0, cachedBeatPhaseMs));
}
async function persistBeatPhaseMs() {
const ms = getBeatPhaseDelayMs();
function getInputVolumePercent() {
const inp = el("audio-input-volume");
if (!inp) return 100;
const n = parseInt(String(inp.value).trim(), 10);
if (!Number.isFinite(n)) return 100;
return Math.min(200, Math.max(0, n));
}
function updateInputVolumeReadout() {
const readout = el("audio-input-volume-readout");
const slider = el("audio-input-volume");
const pct = getInputVolumePercent();
if (readout) readout.textContent = formatGainReadout(pct);
if (slider) {
slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`);
}
}
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_beat_phase_ms: ms }),
body: JSON.stringify({ audio_input_volume: vol }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
console.warn("input volume save failed", e);
}
}
@@ -277,7 +253,7 @@
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setNavResetVisible(false);
setResetDetectorEnabled(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
@@ -286,8 +262,8 @@
lastBeatSeq = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
lastBeatConsoleKey = "";
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
@@ -313,8 +289,9 @@
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
updateInputLevelDisplay(0);
setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
setResetDetectorEnabled(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
@@ -324,11 +301,14 @@
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
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);
@@ -344,7 +324,6 @@
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
lastLoggedSequenceBeatFractions = "";
}
if (endedSeq) {
headerBeatStickyIdleAfterSeq = true;
@@ -354,38 +333,137 @@
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,
device_override: override,
device_override: "",
device_select: selected,
};
const res = await fetch("/api/audio/start", {
@@ -397,6 +475,8 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
cachedAudioRun.device_select = selected;
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -405,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;
}
}
@@ -444,7 +524,7 @@
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const navResetBtn = el("audio-nav-reset-btn");
const resetBtn = el("audio-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
@@ -455,6 +535,8 @@
} catch (e) {
console.warn("audio device refresh failed", e);
}
await loadServerAudioUiFields();
setResetDetectorEnabled(audioDetectorRunning);
});
if (closeBtn) {
closeBtn.addEventListener("click", () => {
@@ -463,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.");
@@ -477,8 +559,8 @@
await stopAudio();
});
}
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
if (resetBtn) {
resetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
@@ -489,35 +571,38 @@
}
});
}
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
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 bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try {
await syncSequenceBeatPhase(mode);
} catch (e) {
console.warn("sequence beat sync failed", e);
}
const volInp = el("audio-input-volume");
if (volInp) {
volInp.addEventListener("input", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
};
const topBpm = el("audio-top-beat-sync");
if (topBpm) {
topBpm.addEventListener("click", () => {
void handleTopBpmButtonClick();
volInp.addEventListener("change", () => {
updateInputVolumeReadout();
void persistInputVolume();
});
updateInputVolumeReadout();
}
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
if (btn) {
btn.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
}
bindSync(el("audio-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass");
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
@@ -548,39 +633,50 @@
}
}
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
/** 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") {
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov && run.device_override != null) ov.value = String(run.device_override);
if (sel && run.device_select) sel.value = String(run.device_select);
cachedAudioRun = {
device: run.device ?? null,
device_override: run.device_override != null ? String(run.device_override) : "",
device_select: run.device_select ? String(run.device_select) : "",
};
}
const phaseInp = el("audio-beat-phase-ms");
if (
phaseInp &&
status.beat_phase_ms != null &&
document.activeElement !== phaseInp
) {
if (status.beat_phase_ms != null) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
phaseInp.value = String(Math.min(500, Math.max(0, 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();
} catch (e) {
console.warn("audio device list refresh failed", e);
}
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
applyServerAudioUiFields(data?.status || {});
const status = data?.status || {};
applyServerAudioUiFields(status);
const select = el("audio-device-select");
const saved = audioRunPreferredDeviceId(status.audio_run || {});
if (select && saved && selectHasDeviceOptionId(select, saved)) {
uiDeviceSelectId = saved;
setSelectedDeviceId(saved);
}
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
} catch (e) {
console.warn("audio status load failed", e);
}

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;
containerEl.innerHTML = '';
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);
containerEl.appendChild(addWrap);
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);
};
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 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';
}
} catch (error) {
console.error('Error loading device settings:', error);
function loadLedToolIframe() {
if (!ledToolIframe) return;
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
if (blank) {
ledToolIframe.src = '/led-tool/editor';
}
}
async function loadAPStatus() {
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>
`;
} else {
statusEl.innerHTML = `
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
<p>Access Point is not currently active</p>
`;
}
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
} catch (error) {
console.error('Error loading AP status:', error);
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();
}
};
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' : '';
}
function connLabel(ok) {
return ok ? 'connected' : 'not connected';
}
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);
}
}
function resolvedBridgeSsid() {
const manual = bridgeWifiSsidManual?.value?.trim();
if (manual) return manual;
return bridgeWifiSsidSelect?.value?.trim() || '';
}
async function loadBridgeSettings() {
try {
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 {
label.textContent = `${p.label} — USB ${p.serial_port}`;
}
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', {
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,22 +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 iframe = document.getElementById('led-tool-iframe');
if (!openBtn || !modal || !iframe) {
return;
}
openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active');
});
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.remove('active');
iframe.src = 'about:blank';
});
}
});

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;
}
@@ -1361,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.');
@@ -2040,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 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 });
}
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];
}
await postDriverSequence(sequence, targetMacs, 0.05, pushOptions);
const groupIds =
pushOptions && Array.isArray(pushOptions.groupIds)
? pushOptions.groupIds.map((g) => String(g).trim()).filter((g) => g.length > 0)
: [];
if (groupIds.length > 0) {
presetMessage.groups = groupIds;
}
await postDriverSequence([presetMessage], [], 0.05, pushOptions);
} catch (error) {
console.error('Failed to send preset to devices:', error);
alert('Failed to send preset to devices.');
@@ -2106,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;
@@ -2168,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

View File

@@ -213,6 +213,7 @@ header h1 {
min-width: 0;
}
.audio-beat-sync-btn,
.audio-top-beat-sync {
display: inline-flex;
align-items: center;
@@ -228,11 +229,13 @@ header h1 {
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;
@@ -313,15 +316,24 @@ header h1 {
text-align: right;
}
.audio-beat-sync-btn.flash,
.audio-top-beat-sync.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.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-beat-readout::before,
.audio-top-beat-sync.flash .audio-top-bar-phase,
.audio-top-beat-sync.flash .audio-top-bar-phase::before {
color: #fff;
}
@@ -854,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;
@@ -915,37 +932,107 @@ body.preset-ui-run .edit-mode-only {
margin-top: 1rem;
}
#audio-modal .audio-settings-section .audio-modal-beat-readout {
display: block;
#audio-modal .audio-modal-beat-sync {
width: 100%;
max-width: none;
}
.audio-modal-beat-readout {
#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;
min-height: 2.25rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: center;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #252525;
padding: 0.35rem 0.65rem;
cursor: pointer;
font-family: inherit;
color: #b0bec5;
min-width: 0;
}
.audio-modal-beat-readout:disabled {
cursor: default;
opacity: 0.55;
#audio-modal .audio-modal-beat-sync {
width: 100%;
}
.audio-modal-beat-readout:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #333;
.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 {
@@ -1335,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;
}
@@ -1636,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;
@@ -1879,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,6 +305,18 @@ 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);
@@ -401,7 +414,14 @@ function rowsToNames(rows) {
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
if (!containerEl) return;
containerEl.innerHTML = "";
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) => {
@@ -428,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)));
@@ -457,7 +477,11 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
});
addWrap.appendChild(sel);
addWrap.appendChild(addBtn);
containerEl.appendChild(addWrap);
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). */
@@ -885,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);
}
@@ -1365,14 +1281,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// 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 () => {
@@ -1425,6 +1333,7 @@ window.zonesManager = {
computeZonePresetUnionTargets,
effectiveGroupIdsForZonePreset,
resolveDeviceNamesForZonePreset,
resolveMacsForZonePreset,
resolveSequenceStepDeviceNames,
fetchGroupsMap,
renderZoneGroupsEditor,

View File

@@ -18,7 +18,7 @@
<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-top-beat-sync" disabled title="Sync step to music (S)">
<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>
@@ -38,10 +38,8 @@
<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 type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</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>
@@ -68,10 +66,8 @@
<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" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</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>
@@ -170,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>
@@ -607,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">
@@ -622,138 +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>
<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>Bar phase</label>
<div class="audio-bpm-row">
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</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>
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</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="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="settings-section audio-settings-section">
<h3>Audio settings</h3>
<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 so they line up with what you hear (saved on the controller).</small>
</div>
<div class="form-group">
<label>Beat sync</label>
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
</div>
<div class="form-group">
<label>Sequence alignment</label>
<div class="profiles-actions" style="flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
</div>
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
</div>
</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 id="settings-message" class="message"></div>
<!-- Device Name -->
<div class="settings-section">
<h3>Device</h3>
<form id="device-form">
<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>
</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>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
</div>
</form>
<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>
<!-- WiFi Access Point Settings -->
<div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3>
<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">
<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>
<div id="ap-status" class="status-info">
<h4>AP Status</h4>
<p>Loading...</p>
<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="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="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="form-group">
<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>
<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>
<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="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="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="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>
</div>
<h3 class="settings-subheading">Saved profiles</h3>
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
</div>
</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>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
</div>
</form>
<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">
@@ -762,22 +784,11 @@
</div>
</div>
<!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal">
<div class="modal-content" style="max-width: 960px; width: 95vw;">
<div class="modal-actions" style="margin-bottom: 0.5rem;">
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
</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>

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

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

@@ -10,6 +10,9 @@ 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:
@@ -24,6 +27,8 @@ class AudioBeatDetector:
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,
@@ -38,9 +43,36 @@ class AudioBeatDetector:
"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()
@@ -55,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})"
@@ -71,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,
@@ -101,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
@@ -108,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,
@@ -162,7 +209,42 @@ class AudioBeatDetector:
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:
@@ -342,10 +424,47 @@ class AudioBeatDetector:
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
@@ -386,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])
@@ -395,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"])
@@ -450,6 +576,8 @@ class AudioBeatDetector:
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:
@@ -457,8 +585,13 @@ class AudioBeatDetector:
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(

View File

@@ -71,8 +71,6 @@ def write_audio_run_state(
else str(prev.get("device_select") or "")
),
}
if device_select is None and device is not None:
data["device_select"] = str(device)
else:
data = {
"enabled": False,

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()
@@ -309,6 +314,7 @@ def _apply_manual_beat_route_standalone_overlay(
"pattern": pattern,
"manual_beat_n": _coerce_manual_beat_n(preset_body),
"beat_counter": 0,
"group_ids": gids,
}
_sync_public_beat_route_from_lane_table()
@@ -318,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]
@@ -353,6 +361,7 @@ 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(
@@ -414,6 +423,16 @@ def mark_sequence_manual_lane_select_sent(lane_index: int) -> 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,
@@ -423,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
@@ -435,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:
@@ -448,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})
@@ -460,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:
@@ -470,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():
@@ -486,10 +528,12 @@ 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
@@ -497,9 +541,11 @@ def sync_beat_route_from_push_sequence(
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:
@@ -553,49 +599,42 @@ 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
@@ -604,7 +643,15 @@ def notify_beat_detected() -> None:
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):
@@ -621,11 +668,13 @@ def notify_beat_detected() -> None:
if (c - 1) % n != 0:
continue
wire = str(e.get("wire_preset_id") or "2")
target_key = _lane_route_targets_key(names, wire)
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((list(names), wire))
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
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:
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:
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(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:
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:
try:
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
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):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
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}")
if delay_s > 0:
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 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)
for mac_key in mac_keys:
for msg in messages:
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

View File

@@ -422,73 +422,228 @@ def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[s
return inner_by_wire
async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
def _build_lane_step0_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Step-0 preset wire body only (one entry in ``presets``)."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return {}
step0 = lane[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return {}
disp = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not disp:
return {}
return {preset_id: _preset_inner_from_display_preset(disp)}
def _build_lane_rest_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""Preset wire bodies for steps 1..n (unique ids, excluding step-0 preset)."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return {}
step0_pid = str(lane[0].get("preset_id") or "").strip()
full = _build_lane_wire_presets_map(lane_index, ctx)
if not step0_pid:
return full
return {k: v for k, v in full.items() if k != step0_pid}
def _prime_lane_step0_context(
lane_index: int, ctx: Dict[str, Any]
) -> Optional[Tuple[Any, List[str], List[str], str, bool]]:
"""Shared step-0 data for priming phases; None when lane has nothing to send."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane_steps:
return
inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
if not inner_by_wire:
return
return None
step0 = lane_steps[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return
return None
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
return None
device_names = _resolve_lane_device_names(lane_index, ctx)
macs = _device_names_to_macs(device_names, ctx["devices"])
if not macs:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
if not device_names:
return None
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
devices_model = ctx["devices"]
num_lanes = int(ctx["num_lanes"])
sequence_doc = ctx["sequence_doc"]
gids = _group_ids_for_lane_step(
sequence_doc, step0, lane_index, num_lanes, zone_doc=zone_doc
)
if not gids and isinstance(zone_doc, dict):
zg = zone_doc.get("group_ids")
if isinstance(zg, list):
gids = [str(g).strip() for g in zg if str(g).strip()]
wire = str(preset_id)
auto = _coerce_auto(display_preset)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
return display_preset, device_names, gids, wire, auto
delay_s = 0.05
for mac in macs:
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
if sel:
body["select"] = sel
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
_SEQUENCE_PRIME_DELAY_S = 0.0
def _gids_key(gids: List[str]) -> Tuple[str, ...]:
return tuple(sorted(str(g).strip() for g in gids if str(g).strip()))
async def _deliver_presets_body(
ctx: Dict[str, Any],
inner_by_wire: Dict[str, Any],
gids: List[str],
) -> None:
"""Broadcast preset bodies (no select); drivers filter on ``groups`` when set."""
if not inner_by_wire:
return
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
gids_key = _gids_key(gids)
if gids_key:
body["groups"] = list(gids_key)
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(
bridge, [msg], None, ctx["devices"], delay_s=_SEQUENCE_PRIME_DELAY_S
)
def _merge_lane_wire_presets_by_gids(
ctx: Dict[str, Any],
build_map,
) -> Dict[Tuple[str, ...], Dict[str, Any]]:
"""Merge per-lane preset maps that share the same group-id set (one broadcast each)."""
merged: Dict[Tuple[str, ...], Dict[str, Any]] = {}
for i in range(int(ctx["num_lanes"])):
inner = build_map(i, ctx)
if not inner:
continue
primed = _prime_lane_step0_context(i, ctx)
if not primed:
continue
_, _, gids, _, _ = primed
key = _gids_key(gids)
merged.setdefault(key, {}).update(inner)
return merged
async def _deliver_merged_presets_by_gids(
ctx: Dict[str, Any],
merged: Dict[Tuple[str, ...], Dict[str, Any]],
) -> None:
for key, inner in merged.items():
await _deliver_presets_body(ctx, inner, list(key))
async def _deliver_lane_presets_map(
lane_index: int,
ctx: Dict[str, Any],
inner_by_wire: Dict[str, Any],
) -> None:
"""Upload a ``presets`` map for one lane (no select in the same message)."""
if not inner_by_wire:
return
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
_display_preset, _device_names, gids, _wire, _auto = primed
await _deliver_presets_body(ctx, inner_by_wire, gids)
async def _prime_lane_step0_presets(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 1: step-0 preset body only for one lane."""
inner = _build_lane_step0_wire_presets_map(lane_index, ctx)
await _deliver_lane_presets_map(lane_index, ctx, inner)
async def _prime_lane_rest_presets(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 4: remaining lane preset bodies (steps 1..n, not step 0)."""
inner = _build_lane_rest_wire_presets_map(lane_index, ctx)
await _deliver_lane_presets_map(lane_index, ctx, inner)
async def _prime_lane_select(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Phase 2: select step 0 for one lane (separate message from presets)."""
from models.transport import get_current_bridge
from util.driver_delivery import deliver_json_messages
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
_display_preset, _device_names, gids, wire, _auto = primed
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
body: Dict[str, Any] = {"v": "1", "select": [wire]}
gids_key = _gids_key(gids)
if gids_key:
body["groups"] = list(gids_key)
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(
bridge, [msg], None, ctx["devices"], delay_s=_SEQUENCE_PRIME_DELAY_S
)
def _prime_lane_after_select(lane_index: int, ctx: Dict[str, Any]) -> None:
"""After select: manual beat-route registration for one lane."""
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
primed = _prime_lane_step0_context(lane_index, ctx)
if not primed:
return
display_preset, device_names, gids, wire, auto = primed
if auto:
clear_sequence_manual_lane_route(lane_index)
else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
set_sequence_manual_lane_route(
lane_index, device_names, wire, inner, group_ids=gids or None
)
mark_sequence_manual_lane_select_sent(lane_index)
def _reset_after_sequence_change() -> None:
"""After sequence priming: zero beat-route strides and reset live audio tracking."""
from util.beat_driver_route import reset_manual_lane_strides
reset_manual_lane_strides()
try:
from util import audio_detector as ad_mod
det = getattr(ad_mod, "_shared_beat_detector", None)
if det is not None:
det.reset_tracking()
except Exception:
pass
async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
"""One-shot preset upload + first-step select per lane (to each lane's groups)."""
for i in range(int(ctx["num_lanes"])):
await _prime_lane(i, ctx)
"""Sequence start: step-0 presets, select, rest presets, then beat/route reset."""
num_lanes = int(ctx["num_lanes"])
step0 = _merge_lane_wire_presets_by_gids(ctx, _build_lane_step0_wire_presets_map)
await _deliver_merged_presets_by_gids(ctx, step0)
for i in range(num_lanes):
await _prime_lane_select(i, ctx)
for i in range(num_lanes):
_prime_lane_after_select(i, ctx)
rest = _merge_lane_wire_presets_by_gids(ctx, _build_lane_rest_wire_presets_map)
await _deliver_merged_presets_by_gids(ctx, rest)
_reset_after_sequence_change()
ctx["_presets_delivered"] = True
ctx["_sequence_primed"] = True
@@ -510,12 +665,12 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.brightness_combine import effective_brightness_for_mac
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
macs = _union_macs_for_sequence(ctx)
if not macs:
@@ -534,7 +689,9 @@ async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
zone_brightness=zb,
)
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
await deliver_json_messages(
bridge, [msg], [mac], devices_model, delay_s=0.05, unicast=True
)
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
@@ -688,7 +845,7 @@ async def _send_lane(
if gids and not device_names:
return
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
@@ -696,37 +853,41 @@ async def _send_lane(
)
from util.driver_delivery import deliver_json_messages
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
raise RuntimeError("Transport not configured")
macs = _device_names_to_macs(device_names, devices)
if not macs:
if not device_names and not gids:
return
wire = str(preset_id)
auto = _coerce_auto(display_preset)
# On sequence step changes, push only the preset we are switching to.
prev_wire = str(st.get("_last_wire") or "")
if wire != prev_wire:
preset_body: Dict[str, Any] = {
"v": "1",
"presets": {wire: _preset_inner_from_display_preset(display_preset)},
}
if gids:
preset_body["groups"] = [str(g) for g in gids]
preset_msg = json.dumps(preset_body, separators=(",", ":"))
await deliver_json_messages(bridge, [preset_msg], None, devices, delay_s=0.05)
st["_last_wire"] = wire
body: Dict[str, Any] = {"v": "1", "select": [wire]}
if gids:
body["groups"] = [str(g) for g in gids]
msg = json.dumps(body, separators=(",", ":"))
if auto:
clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if not sel:
return
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if sel:
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
mark_sequence_manual_lane_select_sent(lane_index)
set_sequence_manual_lane_route(
lane_index, device_names, wire, inner, group_ids=gids or None
)
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
mark_sequence_manual_lane_select_sent(lane_index)
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
@@ -885,6 +1046,8 @@ async def process_active_beat_advance() -> None:
if loop:
if i == 0:
lane0_looped = True
# Force step-0 preset re-upload on loop wrap, even if wire id matches.
st["_last_wire"] = ""
st["stepIdx"] = 0
await _send_lane(i, st, ctx)
else:
@@ -904,7 +1067,7 @@ async def process_active_beat_advance() -> None:
async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
"""Stop beat routing and clear driver presets for devices used by this sequence run."""
from models.transport import get_current_sender
from models.transport import get_current_bridge
from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
from util.driver_delivery import deliver_json_messages
@@ -913,15 +1076,24 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
clear_sequence_manual_lane_route(i)
update_beat_route({"enabled": False})
sender = get_current_sender()
if not sender:
bridge = get_current_bridge()
if not bridge:
return
devices = ctx.get("devices")
macs = _union_macs_for_sequence(ctx)
if not macs:
return
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
gids: List[str] = []
zg = zone_doc.get("group_ids") if isinstance(zone_doc, dict) else None
if isinstance(zg, list):
gids = [str(g).strip() for g in zg if str(g).strip()]
if not gids:
macs = _union_macs_for_sequence(ctx)
if not macs:
return
body: Dict[str, Any] = {"v": "1", "clear_presets": True, "save": True}
if gids:
body["groups"] = gids
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
def _halt_playback_state() -> Optional[Dict[str, Any]]:

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

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

View File

@@ -0,0 +1,16 @@
from machine import UART, Pin
import time
#set baudrate closest to 1000000
baudrate = 921600
uart = UART(1, baudrate=baudrate, tx=Pin(2), rx=Pin(3))
print("Sending 'Hello, World!'")
uart.write(b'Hello, World!')
while True:
if uart.any():
data = uart.read()
print(data)
uart.write(data)
else:
time.sleep(0.01)

View File

@@ -150,6 +150,27 @@ def test_device_duplicate_names_allowed():
assert devices.read(a1)["name"] == devices.read(a2)["name"] == "alpha"
def test_upsert_espnow_announced():
devices = _fresh_device()
m = "e8f60a16dad0"
i1, p1 = devices.upsert_espnow_announced(
m,
"led-test",
num_leds=120,
color_order="grb",
startup_mode="last",
brightness=70,
)
assert i1 == m and p1 is True
d = devices.read(m)
assert d["transport"] == "espnow"
assert d["address"] == m
assert d["name"] == "led-test"
assert d["num_leds"] == 120
i2, p2 = devices.upsert_espnow_announced(m, "led-test")
assert i2 == m and p2 is False
def test_device_duplicate_mac_rejected():
devices = _fresh_device()
devices.create("one", address="aa:bb:cc:dd:ee:ff")
@@ -163,6 +184,7 @@ def test_device_duplicate_mac_rejected():
if __name__ == "__main__":
test_device()
test_upsert_wifi_tcp_client()
test_upsert_espnow_announced()
test_device_can_change_address()
test_device_duplicate_names_allowed()
test_device_duplicate_mac_rejected()

View File

@@ -0,0 +1,176 @@
"""Audio input device_select persistence (Pulse name must survive start)."""
import asyncio
import json
import os
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock
import pytest
import requests
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH))
from microdot import Microdot # noqa: E402
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
SNOWBALL = (
"alsa_input.usb-BLUE_MICROPHONE_Blue_Snowball_SUGA_2020_10_09_41646-00.mono-fallback"
)
def _start_app(app: Microdot, port: int = 0):
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
finally:
loop.close()
thread = threading.Thread(target=runner, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
return thread, sockets[0].getsockname()[1]
time.sleep(0.05)
raise RuntimeError("server failed to start")
@pytest.fixture
def audio_run_path(tmp_path, monkeypatch):
path = tmp_path / "audio_run.json"
monkeypatch.setattr(
"util.audio_run_persist._db_path",
lambda: str(path),
)
return path
def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_path):
write_audio_run_state(
enabled=True,
device=1,
device_select=SNOWBALL,
)
st = read_audio_run_state()
assert st["device_select"] == SNOWBALL
assert st["device"] == 1
def test_put_device_saves_pulse_name(audio_run_path):
app = Microdot()
@app.route("/api/audio/device", methods=["PUT"])
async def audio_set_device(request):
payload = request.json if isinstance(request.json, dict) else {}
device_select = str(payload.get("device_select") or "").strip()
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_select if device_select else None,
device_override="",
device_select=device_select,
)
return {"ok": True, "audio_run": read_audio_run_state()}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
resp = requests.put(
f"{base}/api/audio/device",
json={"device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
assert resp.status_code == 200
body = resp.json()
assert body["audio_run"]["device_select"] == SNOWBALL
assert read_audio_run_state()["device_select"] == SNOWBALL
def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
detector = MagicMock()
detector.status.return_value = {"running": True, "device": 2}
def fake_resolve(device):
assert device == SNOWBALL
return 2
monkeypatch.setattr(
"util.pulse_audio_devices.resolve_capture_device",
fake_resolve,
)
app = Microdot()
@app.route("/api/audio/start", methods=["POST"])
async def audio_start(request):
payload = request.json if isinstance(request.json, dict) else {}
device = payload.get("device", None)
if device in ("", None):
device = None
device_select = str(payload.get("device_select") or "").strip()
if not device_select and device not in ("", None):
device_select = str(device).strip()
from util.pulse_audio_devices import resolve_capture_device
from util.audio_run_persist import read_audio_run_state, write_audio_run_state
device = resolve_capture_device(device)
detector.start(device=device)
write_audio_run_state(
enabled=True,
device=device,
device_override="",
device_select=device_select,
)
st = detector.status()
st["audio_run"] = read_audio_run_state()
return {"ok": True, "status": st}
@app.route("/api/audio/status")
async def audio_status(request):
_ = request
from util.audio_run_persist import read_audio_run_state
st = detector.status()
st["audio_run"] = read_audio_run_state()
return {"status": st}
_, port = _start_app(app)
base = f"http://127.0.0.1:{port}"
start = requests.post(
f"{base}/api/audio/start",
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
timeout=5,
)
assert start.status_code == 200, start.text
run = start.json()["status"]["audio_run"]
assert run["device_select"] == SNOWBALL
assert run["device"] == 2
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"]
assert status["audio_run"]["device_select"] == SNOWBALL
def test_pulse_device_list_uses_stable_pulse_ids():
from util.pulse_audio_devices import list_pulse_matched_input_devices
devs = list_pulse_matched_input_devices()
if not devs:
pytest.skip("pactl not available")
snow = next((d for d in devs if "Snowball" in d.get("display_name", "")), None)
if snow is None:
pytest.skip("Blue Snowball not connected")
assert snow["id"] == snow["pulse_name"]
assert str(snow["id"]).startswith("alsa_input.")

View File

@@ -59,3 +59,47 @@ def test_status_keeps_bpm_during_holdover():
det._status["bpm"] = 128.0
det._status["last_beat_ts"] = time.time() - 10.0
assert det.status()["bpm"] == 128.0
class _FakeRuntimeGap:
def __init__(self):
self.reset_tempo_calls = 0
def reset_tempo_state(self):
self.reset_tempo_calls += 1
def test_silence_gap_starts_holdover_and_resets_tempo_once():
det = AudioBeatDetector()
rt = _FakeRuntimeGap()
with det._lock:
det._running = True
det._status["running"] = True
det._status["bpm"] = 120.0
det._last_real_beat_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
assert det._holdover_active is True
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
det._record_beat(120.0)
assert det._holdover_active is False
def test_holdover_last_beat_does_not_block_tempo_retry():
"""Holdover refreshes last_beat_ts but recovery uses last real beat only."""
det = AudioBeatDetector()
rt = _FakeRuntimeGap()
with det._lock:
det._running = True
det._status["running"] = True
det._status["bpm"] = 120.0
det._last_real_beat_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1
with det._lock:
det._status["last_beat_ts"] = time.time()
det._last_gap_tempo_reset_ts = time.time() - 10.0
det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 2
assert det._holdover_active is True

View File

@@ -28,6 +28,22 @@ def _patch_delivery(monkeypatch):
return delivered
def test_reset_manual_lane_strides_zeros_counters():
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
with bdr._route_lock:
bdr._lane_manual[0]["beat_counter"] = 4
bdr._preset_session_beats = 3
bdr.reset_manual_lane_strides()
with bdr._route_lock:
assert bdr._lane_manual[0]["beat_counter"] == 0
assert bdr._preset_session_beats == 0
def test_suppress_next_notify_skips_one_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
@@ -43,7 +59,7 @@ def test_suppress_next_notify_skips_one_select(monkeypatch):
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "5")]
assert delivered == [("5", None)]
def test_suppress_does_not_advance_beat_counter(monkeypatch):
@@ -52,8 +68,8 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch):
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 2},
"5",
{"p": "chase", "a": False, "manual_beat_n": 2},
)
bdr.mark_sequence_manual_lane_select_sent(0)
@@ -61,14 +77,14 @@ def test_suppress_does_not_advance_beat_counter(monkeypatch):
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]
delivered.clear()
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
@@ -87,19 +103,57 @@ def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
bdr._lane_manual[0] = dict(entry)
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("42", None)]
def test_sequence_lane_manual_delivers_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 1},
)
bdr.notify_beat_detected()
assert delivered == [("42", None)]
def test_sequence_auto_lane_skips_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"3",
{"p": "colour_cycle", "a": True, "manual_beat_n": 1},
)
with bdr._route_lock:
assert 0 not in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == []
def test_sequence_lane_chase_delivers_per_beat_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
bdr.notify_beat_detected()
assert delivered == [("5", None)]
def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
body = {"p": "chase", "a": False, "manual_beat_n": 1}
bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body)
bdr.set_sequence_manual_lane_route(1, ["desk"], "5", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "5", body)
with bdr._route_lock:
assert -1 not in bdr._lane_manual
assert 1 in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
assert delivered == [("5", None)]

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Tests for bridge devices envelope (Pi + espnow-sender downlink)."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from util.bridge_envelope import ( # noqa: E402
BROADCAST_MAC,
build_devices_envelope,
build_groups_envelope,
build_v1_body,
envelope_payload_size,
format_mac_key,
is_broadcast_mac,
split_v1_body_for_espnow,
v1_body_size,
)
def test_unicast_mac_keys_per_device():
from util.driver_delivery import _unicast_mac_keys
keys = _unicast_mac_keys(["188b0e1560a8", "e8f60a16ea10"])
assert len(keys) == 2
assert keys[0] == "18:8b:0e:15:60:a8"
assert keys[1] == "e8:f6:0a:16:ea:10"
assert _unicast_mac_keys(["188b0e1560a8"]) == ["18:8b:0e:15:60:a8"]
assert _unicast_mac_keys(None) == [BROADCAST_MAC]
def test_deliver_json_messages_defaults_broadcast():
from util.driver_delivery import deliver_json_messages
class _Bridge:
def __init__(self):
self.keys = []
async def send(self, envelope):
devs = envelope.get("dv") or envelope.get("devices") or {}
self.keys.extend(devs.keys())
return True
async def _run():
bridge = _Bridge()
await deliver_json_messages(
bridge,
[json.dumps({"v": "1", "select": ["2"]})],
["188b0e1560a8", "e8f60a16ea10"],
None,
)
return bridge.keys
keys = __import__("asyncio").run(_run())
assert keys == [BROADCAST_MAC]
def is_devices_envelope(raw: bytes) -> bool:
if not raw or raw[0:1] != b"{":
return False
try:
data = json.loads(raw)
except (ValueError, TypeError):
return False
devs = data.get("devices") if isinstance(data, dict) else None
if devs is None and isinstance(data, dict):
devs = data.get("dv")
return isinstance(data, dict) and data.get("v") == "1" and isinstance(devs, dict)
def build_driver_payload(body: dict) -> bytes:
out = {"v": "1", **{k: body[k] for k in body if k != "v"}}
raw = json.dumps(out)
if len(raw) > 250:
raise ValueError("too large")
return raw.encode("utf-8")
def test_build_groups_envelope():
env = build_groups_envelope("e8f60a16ea10", ["5", "18"])
assert env["v"] == "1"
key = format_mac_key("e8f60a16ea10")
devs = env.get("dv") or env.get("devices")
body = devs[key]
assert body["sg"] is True
assert body["g"] == ["5", "18"]
def test_is_broadcast_mac():
assert is_broadcast_mac("ff:ff:ff:ff:ff:ff")
assert is_broadcast_mac("ffffffffffff")
assert not is_broadcast_mac("e8f60a16ea10")
def test_is_devices_envelope():
env = build_devices_envelope(
{
BROADCAST_MAC: build_v1_body(
presets={"1": {"p": "on", "c": ["#FFFFFF"], "a": True}},
groups=["5"],
set_groups=False,
)
}
)
raw = json.dumps(env).encode("utf-8")
assert is_devices_envelope(raw)
assert not is_devices_envelope(b'{"v":"1","s":{}}')
def test_build_driver_payload_size():
body = build_v1_body(
presets={"x": {"pattern": "on", "colors": ["#FF0000"], "auto": True}},
select=["x", 0],
save=True,
)
payload = build_driver_payload(body)
assert len(payload) <= 250
data = json.loads(payload)
assert data["v"] == "1"
assert data["s"] == ["x", 0]
def test_split_preset_and_select():
body = build_v1_body(
presets={
"2": {
"p": "on",
"c": ["#FFFFFF"],
"bg": "#000000",
"d": 100,
"b": 255,
"a": True,
"n1": 0,
"n2": 0,
}
},
select=["2", 0],
save=True,
)
if v1_body_size(body) <= 250:
chunks = split_v1_body_for_espnow(body)
assert len(chunks) == 1
else:
chunks = split_v1_body_for_espnow(body)
assert len(chunks) >= 2
assert all(v1_body_size(c) <= 250 for c in chunks)
assert "p" in chunks[0]
assert any("s" in c for c in chunks)
def test_envelope_fits_espnow_limit():
env = build_devices_envelope(
{
BROADCAST_MAC: build_v1_body(
presets={
"2": {
"pattern": "on",
"colors": ["#FFFFFF"],
"auto": True,
}
},
select=["2"],
)
}
)
assert envelope_payload_size(env) <= 250

View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Tests for Pi↔bridge USB serial framing."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame # noqa: E402
def test_pack_and_parse_single_frame():
payload = b'{"v":"1","select":["1"]}'
frame = pack_serial_frame(payload)
buf = bytearray()
out = feed_serial_buffer(buf, frame)
assert out == [payload]
assert len(buf) == 0
def test_rejects_oversized_length_and_caps_buffer():
buf = bytearray(b"\x31\x23") # length 12579 — invalid on bridge
buf.extend(b"x" * 100)
assert feed_serial_buffer(buf, b"") == []
assert len(buf) < 100 # resync shifted past bad header
def test_serial_frame_carries_ws_uplink():
from util.espnow_wire import pack_ws_uplink, parse_ws_frame
peer = bytes.fromhex("e8f60a16ea10")
pkt = b'{"v":"1","name":"test"}'
inner = pack_ws_uplink(peer, pkt)
framed = pack_serial_frame(inner)
out = feed_serial_buffer(bytearray(), framed)
assert len(out) == 1
p2, pkt2, _ = parse_ws_frame(out[0])
assert p2 == peer
assert pkt2 == pkt
payload = b"\x4c\x03abc"
frame = pack_serial_frame(payload)
buf = bytearray()
assert feed_serial_buffer(buf, frame[:1]) == []
assert feed_serial_buffer(buf, frame[1:3]) == []
assert feed_serial_buffer(buf, frame[3:]) == [payload]

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Tests for bridge serial connect profile handling."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def test_connect_bridge_profile_serial(monkeypatch):
from util import bridge_runtime
calls = {}
async def fake_connect_serial(profile, settings):
calls["profile"] = profile
calls["settings"] = settings
return True, ""
monkeypatch.setattr(bridge_runtime, "connect_bridge_serial", fake_connect_serial)
class _Settings(dict):
def save(self):
pass
settings = _Settings({"bridge_serial_port": ""})
profile = {
"transport": "serial",
"serial_port": "/dev/ttyUSB0",
"serial_baudrate": 921600,
}
ok, err = asyncio.run(bridge_runtime.connect_bridge_profile(profile, settings))
assert ok is True
assert err == ""
assert calls["profile"]["serial_port"] == "/dev/ttyUSB0"

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Tests for bridge WebSocket client reconnect behaviour."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from models.bridge_ws_client import BridgeWsClient # noqa: E402
def test_send_returns_false_when_not_connected():
async def _run():
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
async def _no_wait(_timeout=30.0):
return False
client.wait_connected = _no_wait # type: ignore[method-assign]
return await client.send_packet({"v": "1", "devices": {}})
assert asyncio.run(_run()) is False
def test_disconnect_clears_connected_event():
client = BridgeWsClient("ws://127.0.0.1/ws", reconnect_delay_s=0.01)
client._connected.set()
client._signal_disconnect()
assert not client._connected.is_set()

View File

@@ -27,7 +27,7 @@ from microdot.session import Session # noqa: E402
from microdot.websocket import with_websocket # noqa: E402
class DummySender:
class DummyBridge:
def __init__(self):
self.sent: list[tuple[str, Optional[str]]] = []
@@ -166,7 +166,7 @@ def server(monkeypatch, tmp_path_factory):
old_cwd = os.getcwd()
os.chdir(str(SRC_PATH))
dummy_sender = DummySender()
dummy_bridge = DummyBridge()
try:
# Ensure controllers are imported fresh after our patching.
@@ -196,10 +196,10 @@ def server(monkeypatch, tmp_path_factory):
import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402
# Configure transport bridge used by /presets/send.
from models.transport import set_bridge # noqa: E402
set_sender(dummy_sender)
set_bridge(dummy_bridge)
app = Microdot()
@@ -244,7 +244,7 @@ def server(monkeypatch, tmp_path_factory):
@app.route("/ws")
@with_websocket
async def ws(request, ws):
# Minimal websocket handler: forward raw JSON/text payloads to dummy sender.
# Minimal websocket handler: forward raw JSON/text payloads to dummy bridge.
while True:
data = await ws.receive()
if not data:
@@ -253,9 +253,9 @@ def server(monkeypatch, tmp_path_factory):
parsed = json.loads(data)
addr = parsed.pop("to", None)
payload = json.dumps(parsed) if parsed else data
await dummy_sender.send(payload, addr=addr)
await dummy_bridge.send(payload, addr=addr)
except Exception:
await dummy_sender.send(data)
await dummy_bridge.send(data)
thread, chosen_port = _start_microdot_server(app, host="127.0.0.1", port=0)
base_url = f"http://127.0.0.1:{chosen_port}"
@@ -271,7 +271,7 @@ def server(monkeypatch, tmp_path_factory):
yield {
"base_url": base_url,
"client": client,
"sender": dummy_sender,
"bridge": dummy_bridge,
"thread": thread,
"app": app,
}
@@ -379,7 +379,7 @@ def test_settings_controller(server):
def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
import controllers.device as device_ctl
@@ -436,7 +436,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
assert resp.json()["brightness"] == 77
sender.sent.clear()
bridge.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
@@ -444,7 +444,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert resp.status_code == 200
sent_result = resp.json()
assert sent_result["presets_sent"] >= 1
assert len(sender.sent) >= 1
assert len(bridge.sent) >= 1
resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200
@@ -555,7 +555,7 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
c: requests.Session = server["client"]
base_url: str = server["base_url"]
sender: DummySender = server["sender"]
bridge: DummyBridge = server["bridge"]
_create_and_apply_profile(c, base_url)
@@ -667,22 +667,22 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200
assert resp.json()[dev_id].get("connected") is None
sender.sent.clear()
bridge.sent.clear()
resp = c.post(f"{base_url}/devices/{dev_id}/identify")
assert resp.status_code == 200
assert resp.json().get("message")
assert len(sender.sent) >= 1
first = json.loads(sender.sent[0][0])
assert len(bridge.sent) >= 1
first = json.loads(bridge.sent[0][0])
assert "presets" in first and "select" in first
assert first["presets"]["__identify"]["p"] == "blink"
assert first["presets"]["__identify"]["d"] == 50
assert first["select"]["pytest-dev"] == ["__identify"]
assert first["select"] == ["__identify"]
deadline = time.monotonic() + 2.0
while len(sender.sent) < 2 and time.monotonic() < deadline:
while len(bridge.sent) < 2 and time.monotonic() < deadline:
time.sleep(0.02)
assert len(sender.sent) >= 2
second = json.loads(sender.sent[1][0])
assert second.get("select") == {"pytest-dev": ["off"]}
assert len(bridge.sent) >= 2
second = json.loads(bridge.sent[1][0])
assert second.get("select") == ["off"]
resp = c.post(
f"{base_url}/devices",

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