feat(bridge): add wifi/serial bridge runtime and UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
Pipfile
1
Pipfile
@@ -6,6 +6,7 @@ name = "pypi"
|
||||
[packages]
|
||||
mpremote = "*"
|
||||
pyserial = "*"
|
||||
pyserial-asyncio = "*"
|
||||
esptool = "*"
|
||||
pyjwt = "*"
|
||||
watchfiles = "*"
|
||||
|
||||
435
Pipfile.lock
generated
435
Pipfile.lock
generated
@@ -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": [
|
||||
|
||||
19
bridge-serial/README.md
Normal file
19
bridge-serial/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# bridge-serial
|
||||
|
||||
ESP32 ESP-NOW bridge with **USB/serial** uplink to the Pi (GPIO UART). Sync loop only — no asyncio, no Microdot.
|
||||
|
||||
```
|
||||
bridge-serial/
|
||||
src/
|
||||
main.py # entry
|
||||
settings.py # /settings.json on device
|
||||
```
|
||||
|
||||
Deploy:
|
||||
|
||||
```bash
|
||||
cd bridge-serial
|
||||
python ../led-tool/cli.py -p /dev/ttyUSB0 --src -r -f
|
||||
```
|
||||
|
||||
No `--lib` required. Match `serial_baudrate` on the ESP and Pi (e.g. `921600`).
|
||||
166
bridge-serial/src/main.py
Normal file
166
bridge-serial/src/main.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""ESP-NOW bridge: Pi USB-serial downlink, ESP-NOW to drivers (sync loop)."""
|
||||
|
||||
import gc, json, struct, time
|
||||
import espnow, machine, network
|
||||
from machine import Pin, UART
|
||||
from settings import Settings
|
||||
|
||||
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||
WIRE = 0x4C
|
||||
MAX_SERIAL = 4096
|
||||
MAX_ESPNOW = 250
|
||||
ESPNOW_EXIST = -12395
|
||||
ESPNOW_FULL = -12392
|
||||
|
||||
|
||||
def add_peer_if_needed(esp, dest, ch):
|
||||
try:
|
||||
esp.add_peer(dest, channel=ch)
|
||||
except TypeError:
|
||||
try:
|
||||
esp.add_peer(dest)
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
|
||||
|
||||
def del_peer_if_present(esp, dest):
|
||||
try:
|
||||
esp.del_peer(dest)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def send_unicast_temp_peer(esp, dest, ch, pkt):
|
||||
try:
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
except OSError as e:
|
||||
if e.args and e.args[0] == ESPNOW_FULL:
|
||||
del_peer_if_present(esp, dest)
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
else:
|
||||
raise
|
||||
try:
|
||||
esp.send(dest, pkt, True)
|
||||
finally:
|
||||
del_peer_if_present(esp, dest)
|
||||
|
||||
|
||||
def init_radio(ch, name, password):
|
||||
network.WLAN(network.STA_IF).active(False)
|
||||
network.WLAN(network.AP_IF).active(False)
|
||||
time.sleep_ms(100)
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
ap.active(True)
|
||||
time.sleep_ms(50)
|
||||
if password:
|
||||
try:
|
||||
ap.config(essid=name or "bridge", password=password, channel=ch, hidden=True)
|
||||
except TypeError:
|
||||
ap.config(essid=name or "bridge", channel=ch)
|
||||
else:
|
||||
try:
|
||||
ap.config(essid=name or "bridge", channel=ch, hidden=True)
|
||||
except TypeError:
|
||||
ap.config(essid=name or "bridge", channel=ch)
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
try:
|
||||
sta.config(channel=ch)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mac_bytes(addr):
|
||||
h = str(addr).replace(":", "").replace("-", "").strip().lower()
|
||||
return bytes.fromhex(h)
|
||||
|
||||
|
||||
def read_serial(uart, buf):
|
||||
if uart.any():
|
||||
buf.extend(uart.read(min(uart.any(), 256)))
|
||||
out = []
|
||||
while len(buf) >= 2:
|
||||
n = (buf[0] << 8) | buf[1]
|
||||
if n > MAX_SERIAL:
|
||||
buf[:] = buf[1:]
|
||||
continue
|
||||
need = 2 + n
|
||||
if len(buf) < need:
|
||||
break
|
||||
out.append(bytes(buf[2:need]))
|
||||
buf[:] = buf[need:]
|
||||
return out
|
||||
|
||||
|
||||
def downlink(esp, ch, raw):
|
||||
if not raw:
|
||||
return
|
||||
if raw[0] == WIRE:
|
||||
if len(raw) < 2:
|
||||
return
|
||||
esp.send(BROADCAST, raw, True)
|
||||
return
|
||||
if len(raw) < 8 or raw[0] != ord("{"):
|
||||
return
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
return
|
||||
devs = data.get("dv") or data.get("devices")
|
||||
if data.get("v") != "1" or not isinstance(devs, dict):
|
||||
return
|
||||
for mac_s, body in devs.items():
|
||||
if not isinstance(body, dict):
|
||||
continue
|
||||
try:
|
||||
msg = {"v": "1"}
|
||||
msg.update(body)
|
||||
pkt = json.dumps(msg, separators=(",", ":")).encode()
|
||||
if len(pkt) > MAX_ESPNOW:
|
||||
continue
|
||||
dest = mac_bytes(mac_s)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if dest == BROADCAST:
|
||||
esp.send(BROADCAST, pkt, True)
|
||||
else:
|
||||
send_unicast_temp_peer(esp, dest, ch, pkt)
|
||||
time.sleep_ms(5)
|
||||
|
||||
|
||||
gc.collect()
|
||||
s = Settings()
|
||||
ch = max(1, min(11, int(s.get("wifi_channel", 5))))
|
||||
init_radio(ch, s.get("name"), s.get("ap_password") or "")
|
||||
baud = int(s.get("serial_baudrate", 921600))
|
||||
uart = UART(
|
||||
int(s.get("serial_uart_id", 1)),
|
||||
baud,
|
||||
tx=Pin(int(s.get("serial_tx_pin", 2))),
|
||||
rx=Pin(int(s.get("serial_rx_pin", 3))),
|
||||
)
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
add_peer_if_needed(esp, BROADCAST, ch)
|
||||
print("bridge ch", ch, "baud", baud, "heap", gc.mem_free())
|
||||
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
rx_buf = bytearray()
|
||||
while True:
|
||||
wdt.feed()
|
||||
for frame in read_serial(uart, rx_buf):
|
||||
try:
|
||||
downlink(esp, ch, frame)
|
||||
except OSError as e:
|
||||
print("dl", e)
|
||||
host, msg = esp.recv(0)
|
||||
if host:
|
||||
up = bytes([0]) + host + msg
|
||||
uart.write(struct.pack(">H", len(up)) + up)
|
||||
else:
|
||||
time.sleep_ms(1)
|
||||
62
bridge-serial/src/settings.py
Normal file
62
bridge-serial/src/settings.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import json
|
||||
import time
|
||||
import ubinascii
|
||||
import network
|
||||
|
||||
WIFI_CHANNEL_DEFAULT = 5
|
||||
|
||||
|
||||
def _sta_mac_hex():
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
was_on = sta.active()
|
||||
if not was_on:
|
||||
sta.active(True)
|
||||
time.sleep_ms(50)
|
||||
try:
|
||||
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||
except Exception:
|
||||
mac = "000000000000"
|
||||
if not was_on:
|
||||
sta.active(False)
|
||||
return mac
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
SETTINGS_FILE = "/settings.json"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.load()
|
||||
|
||||
def set_defaults(self):
|
||||
self["name"] = "bridge-" + _sta_mac_hex()
|
||||
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
|
||||
self["ap_password"] = ""
|
||||
self["serial_baudrate"] = 921600
|
||||
self["serial_uart_id"] = 1
|
||||
self["serial_tx_pin"] = 2
|
||||
self["serial_rx_pin"] = 3
|
||||
self["serial_usb"] = False
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "w") as f:
|
||||
f.write(json.dumps(self))
|
||||
except Exception as e:
|
||||
print("save settings:", e)
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "r") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("not object")
|
||||
except Exception:
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
return
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
for k, v in loaded.items():
|
||||
self[k] = v
|
||||
22
bridge-wifi/README.md
Normal file
22
bridge-wifi/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# bridge-wifi
|
||||
|
||||
ESP32 ESP-NOW bridge with **Wi‑Fi AP + WebSocket** (`/ws`). Same ESP-NOW downlink as bridge-serial.
|
||||
|
||||
```
|
||||
bridge-wifi/
|
||||
src/
|
||||
main.py
|
||||
settings.py
|
||||
wifi_ap.py
|
||||
espnow_wire.py # uplink frame helper only
|
||||
lib/microdot/ # WebSocket server
|
||||
```
|
||||
|
||||
Deploy:
|
||||
|
||||
```bash
|
||||
cd bridge-wifi
|
||||
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
Pi: join bridge AP, `bridge_ws_url` → `ws://192.168.4.1/ws`.
|
||||
7
bridge-wifi/src/espnow_wire.py
Normal file
7
bridge-wifi/src/espnow_wire.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""WebSocket uplink framing (Pi ↔ bridge)."""
|
||||
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
|
||||
|
||||
def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
218
bridge-wifi/src/main.py
Normal file
218
bridge-wifi/src/main.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""ESP-NOW bridge: Pi WebSocket downlink, ESP-NOW to drivers."""
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import json
|
||||
import time
|
||||
|
||||
import espnow
|
||||
import machine
|
||||
from espnow_wire import BROADCAST_MAC, pack_ws_uplink
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
from settings import Settings
|
||||
from wifi_ap import init_bridge_network
|
||||
|
||||
BROADCAST = BROADCAST_MAC
|
||||
WIRE = 0x4C
|
||||
MAX_ESPNOW = 250
|
||||
ESPNOW_EXIST = -12395
|
||||
ESPNOW_FULL = -12392
|
||||
|
||||
|
||||
def mac_str(mac):
|
||||
return ":".join("%02x" % b for b in mac)
|
||||
|
||||
|
||||
def dbg(msg):
|
||||
if DEBUG:
|
||||
print(msg)
|
||||
|
||||
|
||||
def add_peer_if_needed(esp, dest, ch):
|
||||
try:
|
||||
esp.add_peer(dest, channel=ch)
|
||||
dbg("peer add " + mac_str(dest))
|
||||
except TypeError:
|
||||
try:
|
||||
esp.add_peer(dest)
|
||||
dbg("peer add " + mac_str(dest))
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
dbg("peer exists " + mac_str(dest))
|
||||
except OSError as e:
|
||||
if e.args[0] != ESPNOW_EXIST:
|
||||
raise
|
||||
dbg("peer exists " + mac_str(dest))
|
||||
|
||||
|
||||
def del_peer_if_present(esp, dest):
|
||||
try:
|
||||
esp.del_peer(dest)
|
||||
dbg("peer del " + mac_str(dest))
|
||||
except Exception as e:
|
||||
dbg("peer del skip " + mac_str(dest) + " " + repr(e))
|
||||
|
||||
|
||||
def send_espnow(esp, dest, pkt):
|
||||
try:
|
||||
esp.send(dest, pkt, True)
|
||||
return True
|
||||
except OSError as e:
|
||||
label = "bcast" if dest == BROADCAST else mac_str(dest)
|
||||
print("send err", label, len(pkt), e)
|
||||
return False
|
||||
|
||||
|
||||
def send_unicast_temp_peer(esp, dest, ch, pkt):
|
||||
try:
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
except OSError as e:
|
||||
# If peer table is full but this peer already exists, delete+retry once.
|
||||
if e.args and e.args[0] == ESPNOW_FULL:
|
||||
dbg("peer full " + mac_str(dest) + " retry")
|
||||
del_peer_if_present(esp, dest)
|
||||
add_peer_if_needed(esp, dest, ch)
|
||||
else:
|
||||
raise
|
||||
ok = send_espnow(esp, dest, pkt)
|
||||
del_peer_if_present(esp, dest)
|
||||
return ok
|
||||
|
||||
|
||||
def downlink(esp, ch, raw):
|
||||
n = len(raw)
|
||||
if not raw:
|
||||
return
|
||||
if raw[0] == WIRE:
|
||||
if n < 2:
|
||||
dbg("dl skip wire short " + str(n))
|
||||
return
|
||||
dbg("dl wire bcast " + str(n))
|
||||
send_espnow(esp, BROADCAST, raw)
|
||||
return
|
||||
if n < 8 or raw[0] != ord("{"):
|
||||
dbg("dl skip json " + str(n))
|
||||
return
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
dbg("dl skip json")
|
||||
return
|
||||
devs = data.get("dv") or data.get("devices")
|
||||
if data.get("v") != "1" or not isinstance(devs, dict):
|
||||
dbg("dl skip envelope")
|
||||
return
|
||||
dbg("dl env " + str(len(devs)) + " dev")
|
||||
for mac_s, body in devs.items():
|
||||
if not isinstance(body, dict):
|
||||
dbg("dl skip body " + str(mac_s))
|
||||
continue
|
||||
try:
|
||||
h = str(mac_s).replace(":", "").replace("-", "").strip().lower()
|
||||
dest = BROADCAST if h == "ffffffffffff" else bytes.fromhex(h)
|
||||
msg = {"v": "1"}
|
||||
msg.update(body)
|
||||
pkt = json.dumps(msg, separators=(",", ":")).encode()
|
||||
if len(pkt) > MAX_ESPNOW:
|
||||
dbg("dl skip big " + str(len(pkt)))
|
||||
continue
|
||||
except (ValueError, TypeError):
|
||||
dbg("dl skip mac " + str(mac_s))
|
||||
continue
|
||||
if dest == BROADCAST:
|
||||
dbg("dl bcast " + str(len(pkt)))
|
||||
send_espnow(esp, BROADCAST, pkt)
|
||||
else:
|
||||
dbg("dl uni " + mac_str(dest) + " " + str(len(pkt)))
|
||||
send_unicast_temp_peer(esp, dest, ch, pkt)
|
||||
time.sleep_ms(5)
|
||||
|
||||
|
||||
gc.collect()
|
||||
settings = Settings()
|
||||
DEBUG = bool(settings.get("debug", True))
|
||||
ch = max(1, min(11, int(settings.get("wifi_channel", 5))))
|
||||
init_bridge_network(settings)
|
||||
|
||||
esp = espnow.ESPNow()
|
||||
esp.active(True)
|
||||
add_peer_if_needed(esp, BROADCAST, ch)
|
||||
print(
|
||||
"bridge-wifi ch",
|
||||
ch,
|
||||
"debug",
|
||||
DEBUG,
|
||||
"heap",
|
||||
gc.mem_free(),
|
||||
"ws",
|
||||
int(settings.get("ws_port", 80)),
|
||||
)
|
||||
|
||||
app = Microdot()
|
||||
clients = set()
|
||||
wdt = machine.WDT(timeout=10000)
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws_handler(request, ws):
|
||||
clients.add(ws)
|
||||
print("ws client +", len(clients))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
raw = await ws.receive()
|
||||
except WebSocketError:
|
||||
dbg("ws closed")
|
||||
break
|
||||
if not raw:
|
||||
dbg("ws empty")
|
||||
break
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
dbg("ws rx " + str(len(raw)))
|
||||
try:
|
||||
downlink(esp, ch, raw)
|
||||
except OSError as e:
|
||||
print("dl err", e)
|
||||
finally:
|
||||
clients.discard(ws)
|
||||
print("ws client -", len(clients))
|
||||
|
||||
|
||||
async def espnow_rx_loop():
|
||||
while True:
|
||||
host, msg = esp.recv(0)
|
||||
if host:
|
||||
dbg("up " + mac_str(host) + " " + str(len(msg)))
|
||||
frame = pack_ws_uplink(host, msg)
|
||||
dead = []
|
||||
sent = 0
|
||||
for ws in list(clients):
|
||||
try:
|
||||
await ws.send(frame)
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
dbg("ws up err " + repr(e))
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
clients.discard(ws)
|
||||
if not clients:
|
||||
dbg("up no ws clients")
|
||||
else:
|
||||
dbg("up ws " + str(sent) + "/" + str(len(clients)))
|
||||
else:
|
||||
await asyncio.sleep_ms(1)
|
||||
wdt.feed()
|
||||
|
||||
|
||||
async def main():
|
||||
asyncio.create_task(espnow_rx_loop())
|
||||
port = int(settings.get("ws_port", 80))
|
||||
print("ws listen", port)
|
||||
await app.start_server(host="0.0.0.0", port=port)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -3,30 +3,21 @@ import time
|
||||
import ubinascii
|
||||
import network
|
||||
|
||||
WIFI_CHANNEL_DEFAULT = 5
|
||||
|
||||
|
||||
def _sta_mac_hex():
|
||||
"""Read STA MAC without leaving the radio up (wifi_ap owns bring-up)."""
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
was_on = False
|
||||
try:
|
||||
was_on = sta.active()
|
||||
except Exception:
|
||||
pass
|
||||
if not was_on:
|
||||
try:
|
||||
sta.active(True)
|
||||
time.sleep_ms(50)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mac = ubinascii.hexlify(sta.config("mac")).decode().lower()
|
||||
except Exception:
|
||||
mac = "000000000000"
|
||||
if not was_on:
|
||||
try:
|
||||
sta.active(False)
|
||||
except Exception:
|
||||
pass
|
||||
return mac
|
||||
|
||||
|
||||
@@ -38,29 +29,27 @@ class Settings(dict):
|
||||
self.load()
|
||||
|
||||
def set_defaults(self):
|
||||
mac = _sta_mac_hex()
|
||||
self["name"] = "bridge-" + mac
|
||||
self["wifi_channel"] = 1
|
||||
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["max_peers"] = 20
|
||||
self["debug"] = True
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "w") as file:
|
||||
file.write(json.dumps(self))
|
||||
with open(self.SETTINGS_FILE, "w") as f:
|
||||
f.write(json.dumps(self))
|
||||
except Exception as e:
|
||||
print("Error saving settings:", e)
|
||||
print("save settings:", e)
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
with open(self.SETTINGS_FILE, "r") as file:
|
||||
loaded = json.load(file)
|
||||
with open(self.SETTINGS_FILE, "r") as f:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError("settings.json is not an object")
|
||||
raise ValueError("not object")
|
||||
except Exception:
|
||||
print("Error loading settings")
|
||||
self.clear()
|
||||
self.set_defaults()
|
||||
self.save()
|
||||
@@ -69,5 +58,3 @@ class Settings(dict):
|
||||
self.set_defaults()
|
||||
for k, v in loaded.items():
|
||||
self[k] = v
|
||||
|
||||
|
||||
52
bridge-wifi/src/wifi_ap.py
Normal file
52
bridge-wifi/src/wifi_ap.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""AP + STA for ESP-NOW; Pi joins the AP for WebSocket."""
|
||||
|
||||
import time
|
||||
|
||||
import network
|
||||
|
||||
from settings import WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def _channel(settings):
|
||||
try:
|
||||
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
|
||||
except (TypeError, ValueError):
|
||||
return WIFI_CHANNEL_DEFAULT
|
||||
|
||||
|
||||
def init_bridge_network(settings):
|
||||
ch = _channel(settings)
|
||||
essid = settings.get("name") or "bridge"
|
||||
password = settings.get("ap_password") or ""
|
||||
ap_ip = settings.get("ap_ip") or "192.168.4.1"
|
||||
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
sta.active(False)
|
||||
ap.active(False)
|
||||
time.sleep_ms(100)
|
||||
|
||||
ap.active(True)
|
||||
time.sleep_ms(50)
|
||||
if password:
|
||||
try:
|
||||
ap.config(essid=essid, password=password, channel=ch)
|
||||
except TypeError:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
else:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
try:
|
||||
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sta.active(True)
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
try:
|
||||
sta.config(channel=ch)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
port = int(settings.get("ws_port", 80))
|
||||
print("bridge AP", essid, "ch", ch, "ip", ap.ifconfig()[0])
|
||||
print("bridge_ws_url: ws://%s:%s/ws" % (ap_ip, port))
|
||||
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
@@ -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"}`.
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ Configure the Pi in `settings.json`:
|
||||
```json
|
||||
{
|
||||
"bridge_ws_url": "ws://192.168.4.1/ws",
|
||||
"wifi_channel": 6
|
||||
"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** (Pi sends `BRIDGE_CH` on connect; bridge updates AP + ESP-NOW STA).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ All ESP-NOW datagrams and Pi↔bridge WebSocket frames use **binary only** (no J
|
||||
| `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`)
|
||||
@@ -57,6 +59,24 @@ Bytes 2… are a **v2 binary envelope** (see `src/util/binary_envelope.py`): 5-b
|
||||
|
||||
Drivers apply the nested envelope only if `group_id` is in their stored group list.
|
||||
|
||||
### PING_REQ (`0x05`)
|
||||
|
||||
Controller discovery ping (broadcast). Drivers reply with **PING_RSP** after a random delay (50–500 ms) to reduce ESP-NOW collisions.
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| ping_id | u32 LE |
|
||||
|
||||
### PING_RSP (`0x06`)
|
||||
|
||||
Unicast to the bridge/controller peer that sent the request (ESP-NOW source MAC of the received **PING_REQ**).
|
||||
|
||||
| Field | Type |
|
||||
|-------|------|
|
||||
| ping_id | u32 LE |
|
||||
| name_len | u8 |
|
||||
| name | UTF-8 |
|
||||
|
||||
### BRIDGE_CH (`0x10`)
|
||||
|
||||
| Field | Type |
|
||||
|
||||
@@ -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 device’s transport.
|
||||
This page describes the **main web UI** served from the Raspberry Pi app: profiles, **zones**, presets, colour palettes, and sending commands to LED devices. Traffic may go over the **ESP-NOW bridge** (WebSocket) or **Wi-Fi** (TCP to drivers on the LAN), depending on each device’s transport.
|
||||
|
||||
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# espnow-sender (ESP-NOW bridge)
|
||||
|
||||
ESP32 firmware that relays **binary** ESP-NOW packets to/from led-controller over WebSocket.
|
||||
|
||||
Layout matches **led-driver** so you deploy with **led-tool** from this directory:
|
||||
|
||||
```
|
||||
espnow-sender/
|
||||
src/ # uploaded to device root via --src
|
||||
main.py
|
||||
wifi_ap.py
|
||||
util.py
|
||||
espnow_wire.py
|
||||
lib/ # uploaded to /lib via --lib
|
||||
aioespnow.py
|
||||
microdot/
|
||||
```
|
||||
|
||||
## Deploy with led-tool
|
||||
|
||||
```bash
|
||||
cd espnow-sender
|
||||
python ../led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--src` | Upload `./src` → device `:/` (`main.py`, `util.py`, `espnow_wire.py`) |
|
||||
| `--lib` | Upload `./lib` → device `/lib` (aioespnow, Microdot) |
|
||||
| `-r` | Reset after upload |
|
||||
| `-f` | Follow serial output |
|
||||
|
||||
From **led-controller** root:
|
||||
|
||||
```bash
|
||||
python led-tool/cli.py -p /dev/ttyUSB0 --src --lib -r -f
|
||||
```
|
||||
|
||||
(run with `cwd` = `espnow-sender`, or `cd espnow-sender` first)
|
||||
|
||||
Optional: `--force-upload` to ignore `file_hashes.json` on the device.
|
||||
|
||||
## Runtime
|
||||
|
||||
- **Wi-Fi access point** (default IP **192.168.4.1**): connect the Pi to the bridge SSID (`name` in `/settings.json`, e.g. `bridge-aabbccddeeff`)
|
||||
- WebSocket server: `/ws` on port **80** — set Pi `bridge_ws_url` to `ws://192.168.4.1/ws` (or the printed IP)
|
||||
- Optional `ap_password` in `/settings.json` (empty = open network)
|
||||
- Default Wi-Fi channel: **6** (Pi sends `BRIDGE_CH` on connect; updates AP + ESP-NOW STA)
|
||||
- Max **20** ESP-NOW peers (LRU eviction)
|
||||
|
||||
## Protocol
|
||||
|
||||
- [docs/espnow-architecture.md](../docs/espnow-architecture.md)
|
||||
- [docs/espnow-binary-protocol.md](../docs/espnow-binary-protocol.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
# aioespnow module for MicroPython on ESP32 and ESP8266
|
||||
# MIT license; Copyright (c) 2022 Glenn Moloney @glenn20
|
||||
# Vendored from micropython-lib/micropython/aioespnow
|
||||
|
||||
import asyncio
|
||||
import espnow
|
||||
|
||||
|
||||
class AIOESPNow(espnow.ESPNow):
|
||||
async def arecv(self):
|
||||
yield asyncio.core._io_queue.queue_read(self)
|
||||
return self.recv(0)
|
||||
|
||||
async def airecv(self):
|
||||
yield asyncio.core._io_queue.queue_read(self)
|
||||
return self.irecv(0)
|
||||
|
||||
async def asend(self, mac, msg=None, sync=None):
|
||||
if msg is None:
|
||||
msg, mac = mac, None
|
||||
yield asyncio.core._io_queue.queue_write(self)
|
||||
return self.send(mac, msg, sync)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
return await self.airecv()
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"v": "1",
|
||||
"dv": {
|
||||
"ff:ff:ff:ff:ff:ff": {
|
||||
"p": {
|
||||
"preset_id": {
|
||||
"p": "on",
|
||||
"c": ["#FF0000"],
|
||||
"d": 100,
|
||||
"b": 255,
|
||||
"a": true
|
||||
}
|
||||
},
|
||||
"s": ["preset_id", 0],
|
||||
"sv": true,
|
||||
"df": "preset_id",
|
||||
"b": 255,
|
||||
"g": ["5", "18"],
|
||||
"sg": true
|
||||
}
|
||||
}
|
||||
}
|
||||
91
espnow-sender/src/bridge_http.py
Normal file
91
espnow-sender/src/bridge_http.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""HTTP settings API for the ESP-NOW bridge (AP IP, password, channel)."""
|
||||
|
||||
import json
|
||||
|
||||
from settings import WIFI_CHANNEL_DEFAULT
|
||||
|
||||
_SETTINGS_KEYS = frozenset(
|
||||
{"name", "ap_ip", "ap_password", "wifi_channel", "ws_port", "max_peers"}
|
||||
)
|
||||
|
||||
|
||||
def _parse_ipv4(value):
|
||||
parts = str(value).strip().split(".")
|
||||
if len(parts) != 4:
|
||||
raise ValueError("ap_ip must be dotted IPv4")
|
||||
out = []
|
||||
for p in parts:
|
||||
n = int(p)
|
||||
if n < 0 or n > 255:
|
||||
raise ValueError("ap_ip octet out of range")
|
||||
out.append(n)
|
||||
return ".".join(str(x) for x in out)
|
||||
|
||||
|
||||
def public_settings(settings):
|
||||
return {
|
||||
"name": settings.get("name", ""),
|
||||
"ap_ip": settings.get("ap_ip", "192.168.4.1"),
|
||||
"ap_password_set": bool(str(settings.get("ap_password") or "").strip()),
|
||||
"wifi_channel": settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT),
|
||||
"ws_port": settings.get("ws_port", 80),
|
||||
"max_peers": settings.get("max_peers", 20),
|
||||
}
|
||||
|
||||
|
||||
def apply_settings_update(settings, data):
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("body must be a JSON object")
|
||||
reboot_required = False
|
||||
if "name" in data:
|
||||
name = str(data["name"] or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name is required")
|
||||
if len(name) > 32:
|
||||
raise ValueError("name too long")
|
||||
settings["name"] = name
|
||||
reboot_required = True
|
||||
if "ap_ip" in data:
|
||||
settings["ap_ip"] = _parse_ipv4(data["ap_ip"])
|
||||
reboot_required = True
|
||||
if "ap_password" in data:
|
||||
pw = str(data["ap_password"] or "")
|
||||
if pw and len(pw) < 8:
|
||||
raise ValueError("ap_password must be at least 8 characters or empty")
|
||||
settings["ap_password"] = pw
|
||||
reboot_required = True
|
||||
if "wifi_channel" in data:
|
||||
ch = int(data["wifi_channel"])
|
||||
if ch < 1 or ch > 11:
|
||||
raise ValueError("wifi_channel must be 1–11")
|
||||
settings["wifi_channel"] = ch
|
||||
reboot_required = True
|
||||
if "ws_port" in data:
|
||||
port = int(data["ws_port"])
|
||||
if port < 1 or port > 65535:
|
||||
raise ValueError("ws_port out of range")
|
||||
settings["ws_port"] = port
|
||||
if "max_peers" in data:
|
||||
settings["max_peers"] = max(1, min(20, int(data["max_peers"])))
|
||||
return reboot_required
|
||||
|
||||
|
||||
def register_bridge_routes(app, settings):
|
||||
@app.get("/settings")
|
||||
async def get_bridge_settings(request):
|
||||
return json.dumps(public_settings(settings)), 200, {"Content-Type": "application/json"}
|
||||
|
||||
@app.put("/settings")
|
||||
async def put_bridge_settings(request):
|
||||
try:
|
||||
data = request.json
|
||||
reboot_required = apply_settings_update(settings, data)
|
||||
settings.save()
|
||||
body = public_settings(settings)
|
||||
body["message"] = "Settings saved"
|
||||
body["reboot_required"] = reboot_required
|
||||
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
||||
except ValueError as err:
|
||||
return json.dumps({"error": str(err)}), 400, {"Content-Type": "application/json"}
|
||||
except Exception as err:
|
||||
return json.dumps({"error": str(err)}), 500, {"Content-Type": "application/json"}
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Route Pi v1 devices envelope to ESP-NOW unicast or broadcast."""
|
||||
|
||||
import json
|
||||
import utime
|
||||
|
||||
from espnow_wire import BROADCAST_MAC
|
||||
from util import parse_mac
|
||||
from v1_wire import (
|
||||
ENV_DEVICES,
|
||||
K_PRESETS,
|
||||
K_SELECT,
|
||||
K_SET_GROUPS,
|
||||
_WIRE_KEYS,
|
||||
envelope_devices,
|
||||
normalize_body,
|
||||
)
|
||||
|
||||
MAX_ESPNOW_PAYLOAD = 250
|
||||
_CHUNK_DELAY_MS = 50
|
||||
|
||||
|
||||
def is_devices_envelope(raw):
|
||||
if not raw:
|
||||
return False
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
if raw[0:1] != b"{":
|
||||
return False
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return (
|
||||
isinstance(data, dict)
|
||||
and data.get("v") == "1"
|
||||
and envelope_devices(data) is not None
|
||||
)
|
||||
|
||||
|
||||
def _encode_v1(fields):
|
||||
out = {"v": "1"}
|
||||
short = normalize_body(fields)
|
||||
for key in _WIRE_KEYS:
|
||||
if key in short:
|
||||
out[key] = short[key]
|
||||
return json.dumps(out, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _payload_len(fields):
|
||||
return len(_encode_v1(fields))
|
||||
|
||||
|
||||
def payloads_from_body(body):
|
||||
"""One or more ESP-NOW payloads (each <= MAX_ESPNOW_PAYLOAD)."""
|
||||
if not isinstance(body, dict):
|
||||
raise ValueError("device body must be object")
|
||||
short = normalize_body(body)
|
||||
if _payload_len(short) <= MAX_ESPNOW_PAYLOAD:
|
||||
return [_encode_v1(short)]
|
||||
|
||||
parts = []
|
||||
meta = {}
|
||||
for key in _WIRE_KEYS:
|
||||
if key in short and key not in (K_PRESETS, K_SELECT):
|
||||
meta[key] = short[key]
|
||||
|
||||
presets = short.get(K_PRESETS)
|
||||
select = short.get(K_SELECT)
|
||||
|
||||
if presets and isinstance(presets, dict):
|
||||
one = dict(meta)
|
||||
one[K_PRESETS] = presets
|
||||
if _payload_len(one) <= MAX_ESPNOW_PAYLOAD:
|
||||
parts.append(_encode_v1(one))
|
||||
else:
|
||||
for pid, pdata in presets.items():
|
||||
chunk = dict(meta)
|
||||
chunk[K_PRESETS] = {pid: pdata}
|
||||
if _payload_len(chunk) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError(
|
||||
"single preset too large (%d B)" % _payload_len(chunk)
|
||||
)
|
||||
parts.append(_encode_v1(chunk))
|
||||
|
||||
if select is not None:
|
||||
sel = dict(meta)
|
||||
sel.pop(K_SAVE, None)
|
||||
sel[K_SELECT] = select
|
||||
if _payload_len(sel) > MAX_ESPNOW_PAYLOAD:
|
||||
raise ValueError("select too large (%d B)" % _payload_len(sel))
|
||||
parts.append(_encode_v1(sel))
|
||||
|
||||
if not parts:
|
||||
raise ValueError("driver payload too large (%d B)" % _payload_len(short))
|
||||
return parts
|
||||
|
||||
|
||||
async def ensure_peer(esp, mac_bytes):
|
||||
try:
|
||||
esp.add_peer(mac_bytes)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def send_unicast(esp, peer_table, mac_bytes, payload):
|
||||
await ensure_peer(esp, mac_bytes)
|
||||
peer_table.touch(mac_bytes)
|
||||
await esp.asend(mac_bytes, payload)
|
||||
|
||||
|
||||
async def _send_payloads(esp, peer_table, dest, payloads):
|
||||
for i, payload in enumerate(payloads):
|
||||
if peer_table.is_broadcast_mac(dest):
|
||||
await ensure_peer(esp, BROADCAST_MAC)
|
||||
await esp.asend(BROADCAST_MAC, payload)
|
||||
else:
|
||||
await send_unicast(esp, peer_table, dest, payload)
|
||||
if i + 1 < len(payloads):
|
||||
utime.sleep_ms(_CHUNK_DELAY_MS)
|
||||
|
||||
|
||||
async def send_device_body(esp, peer_table, mac_str, body):
|
||||
dest = parse_mac(mac_str)
|
||||
payloads = payloads_from_body(body)
|
||||
set_groups = bool(body.get("set_groups") or body.get("sg"))
|
||||
|
||||
if set_groups:
|
||||
if peer_table.is_broadcast_mac(dest):
|
||||
targets = peer_table.peers()
|
||||
if not targets:
|
||||
print("set_groups: no peers yet")
|
||||
return
|
||||
for peer in targets:
|
||||
await _send_payloads(esp, peer_table, peer, payloads)
|
||||
else:
|
||||
await _send_payloads(esp, peer_table, dest, payloads)
|
||||
return
|
||||
|
||||
await _send_payloads(esp, peer_table, dest, payloads)
|
||||
|
||||
|
||||
async def route_envelope(esp, peer_table, raw):
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("utf-8")
|
||||
data = json.loads(raw)
|
||||
devices = envelope_devices(data) or {}
|
||||
for mac_str, body in devices.items():
|
||||
try:
|
||||
await send_device_body(esp, peer_table, mac_str, body)
|
||||
except ValueError as err:
|
||||
print("downlink skip", mac_str, err)
|
||||
except Exception as err:
|
||||
print("downlink err", mac_str, err)
|
||||
@@ -1,39 +0,0 @@
|
||||
"""ESP-NOW / WebSocket framing (MicroPython). See docs/espnow-binary-protocol.md."""
|
||||
|
||||
WIRE_MAGIC = 0x4C
|
||||
MSG_BRIDGE_CH = 0x10
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
WS_FLAG_BROADCAST = 0x01
|
||||
MAX_PEERS = 20
|
||||
|
||||
|
||||
def parse_ws_downlink(frame):
|
||||
"""Return (peer_bytes, espnow_packet, is_broadcast)."""
|
||||
if not frame or len(frame) < 8:
|
||||
raise ValueError("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
|
||||
|
||||
|
||||
def pack_ws_uplink(peer, espnow_packet):
|
||||
return bytes([0]) + peer + espnow_packet
|
||||
|
||||
|
||||
def pack_ws_downlink(espnow_packet, peer_mac=None, broadcast=False):
|
||||
flags = WS_FLAG_BROADCAST if broadcast else 0
|
||||
if broadcast:
|
||||
peer = BROADCAST_MAC
|
||||
else:
|
||||
if peer_mac is None or len(peer_mac) != 6:
|
||||
raise ValueError("peer MAC required for unicast downlink")
|
||||
peer = peer_mac
|
||||
return bytes([flags]) + peer + espnow_packet
|
||||
|
||||
|
||||
def parse_bridge_channel(pkt):
|
||||
if len(pkt) >= 3 and pkt[0] == WIRE_MAGIC and pkt[1] == MSG_BRIDGE_CH:
|
||||
return pkt[2]
|
||||
return None
|
||||
@@ -6,37 +6,28 @@ from microdot.websocket import WebSocketError, with_websocket
|
||||
|
||||
import aioespnow
|
||||
import machine
|
||||
import network
|
||||
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)
|
||||
|
||||
ch = settings.get("wifi_channel", 1)
|
||||
try:
|
||||
ch = max(1, min(11, int(ch)))
|
||||
except (TypeError, ValueError):
|
||||
ch = 1
|
||||
|
||||
ap_if = network.WLAN(network.AP_IF)
|
||||
ap_if.active(True)
|
||||
ap_if.config(
|
||||
ssid=settings.get("name"),
|
||||
password=settings.get("ap_password"),
|
||||
channel=ch,
|
||||
)
|
||||
print(ap_if.ifconfig())
|
||||
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
sta_if.active(True)
|
||||
print(sta_if.config("channel"))
|
||||
init_bridge_network(settings)
|
||||
print_bridge_ip(settings.get("ws_port", 80))
|
||||
|
||||
esp = aioespnow.AIOESPNow()
|
||||
esp.active(True)
|
||||
@@ -56,7 +47,7 @@ def _note_uplink_peer(host, msg):
|
||||
name = data.get("name")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
peer_table.touch(host, name)
|
||||
peer_table.touch(host, name, esp)
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@@ -103,6 +94,26 @@ async def _espnow_receive_loop():
|
||||
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():
|
||||
@@ -114,6 +125,7 @@ async def _wdt_feed_loop():
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -7,24 +7,71 @@ try:
|
||||
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):
|
||||
self._max = max(1, int(max_peers))
|
||||
limit = max(1, int(max_peers) - _RESERVED_FOR_BROADCAST)
|
||||
self._max = limit
|
||||
self._order = []
|
||||
self._names = {}
|
||||
|
||||
def touch(self, mac_bytes, name=None):
|
||||
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:
|
||||
old = self._order.pop(0)
|
||||
self._names.pop(old, None)
|
||||
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)
|
||||
|
||||
@@ -42,8 +42,7 @@ def print_bridge_ip(ws_port=80):
|
||||
print("bridge IP: (AP not up)")
|
||||
return
|
||||
|
||||
# Prefer AP address — Pi joins the bridge access point.
|
||||
ips.sort(key=lambda x: 0 if x[0] == "AP" else 1)
|
||||
label, ip = ips[0]
|
||||
print("bridge IP (%s):" % label, ip)
|
||||
_label, ip = ips[0]
|
||||
print("bridge IP (AP):", ip)
|
||||
print("bridge_ws_url: ws://%s:%s/ws" % (ip, port))
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
"""Short v1 wire keys (MicroPython)."""
|
||||
|
||||
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"
|
||||
ENV_DEVICES = "dv"
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
def _normalize_select(val):
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
if isinstance(val, str) and val.strip():
|
||||
return [val.strip()]
|
||||
if isinstance(val, dict) and "preset" in val:
|
||||
out = [val["preset"]]
|
||||
if "step" in val:
|
||||
out.append(val["step"])
|
||||
return out
|
||||
if isinstance(val, dict) and len(val) == 1:
|
||||
one = next(iter(val.values()))
|
||||
if isinstance(one, list):
|
||||
return one
|
||||
return val
|
||||
|
||||
|
||||
_WIRE_KEYS = (
|
||||
K_PRESETS,
|
||||
K_SELECT,
|
||||
K_SAVE,
|
||||
K_DEFAULT,
|
||||
"b",
|
||||
K_GROUPS,
|
||||
K_SET_GROUPS,
|
||||
K_DEVICE_CONFIG,
|
||||
K_CLEAR_PRESETS,
|
||||
K_MANIFEST,
|
||||
)
|
||||
|
||||
|
||||
def normalize_body(body):
|
||||
"""Long or short body → short keys for encoding."""
|
||||
if not isinstance(body, dict):
|
||||
return body
|
||||
out = {}
|
||||
for long_key, short_key in _LONG_TO_SHORT.items():
|
||||
if long_key in body:
|
||||
val = body[long_key]
|
||||
if long_key == "select":
|
||||
val = _normalize_select(val)
|
||||
out[short_key] = val
|
||||
elif short_key in body:
|
||||
out[short_key] = body[short_key]
|
||||
if "b" in body:
|
||||
out["b"] = body["b"]
|
||||
return out
|
||||
|
||||
|
||||
def envelope_devices(data):
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
devs = data.get("devices")
|
||||
if devs is None:
|
||||
devs = data.get(ENV_DEVICES)
|
||||
return devs if isinstance(devs, dict) else None
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Bridge Wi-Fi: AP for Pi WebSocket client, STA for ESP-NOW (ESP32-C3: AP first)."""
|
||||
|
||||
import time
|
||||
|
||||
import network
|
||||
|
||||
|
||||
def _wait_active(wlan, timeout_ms=1000):
|
||||
for _ in range(timeout_ms // 20):
|
||||
if wlan.active():
|
||||
return True
|
||||
time.sleep_ms(20)
|
||||
return bool(wlan.active())
|
||||
|
||||
|
||||
def _boot_channel(settings):
|
||||
try:
|
||||
return max(1, min(11, int(settings.get("wifi_channel", 6))))
|
||||
except (TypeError, ValueError):
|
||||
return 6
|
||||
|
||||
|
||||
def init_bridge_network(settings):
|
||||
"""Bring up AP (Pi) then STA (ESP-NOW). Channel set on AP at boot only."""
|
||||
ch = _boot_channel(settings)
|
||||
sta = network.WLAN(network.STA_IF)
|
||||
ap = network.WLAN(network.AP_IF)
|
||||
|
||||
try:
|
||||
sta.active(False)
|
||||
ap.active(False)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep_ms(100)
|
||||
|
||||
essid = settings.get("name") or "espnow-bridge"
|
||||
password = settings.get("ap_password") or ""
|
||||
|
||||
ap.active(True)
|
||||
if not _wait_active(ap):
|
||||
raise RuntimeError("AP did not become active")
|
||||
|
||||
if password:
|
||||
ap.config(essid=essid, password=password, channel=ch)
|
||||
else:
|
||||
ap.config(essid=essid, channel=ch)
|
||||
|
||||
ap_ip = settings.get("ap_ip") or "192.168.4.1"
|
||||
try:
|
||||
ap.ifconfig((ap_ip, "255.255.255.0", ap_ip, "8.8.8.8"))
|
||||
except Exception as e:
|
||||
print("ap ifconfig:", e)
|
||||
|
||||
sta.active(True)
|
||||
if not _wait_active(sta):
|
||||
raise RuntimeError("STA did not become active")
|
||||
try:
|
||||
sta.config(pm=network.WLAN.PM_NONE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
actual = ap.config("channel")
|
||||
except Exception:
|
||||
actual = ch
|
||||
print("bridge AP:", essid, "channel=", actual, "ip=", ap.ifconfig()[0])
|
||||
Submodule led-driver updated: 088fe161a8...8403df531d
Submodule led-simulator updated: 42c14361e8...4fc3345fc9
@@ -7,7 +7,7 @@ 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 util.driver_patterns import driver_patterns_dir
|
||||
@@ -141,10 +141,10 @@ 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, dev_id):
|
||||
async def _identify_send_off_after_delay(bridge, dev_id):
|
||||
try:
|
||||
await asyncio.sleep(IDENTIFY_OFF_DELAY_S)
|
||||
await sender.send(
|
||||
await bridge.send(
|
||||
{"v": "1", "select": ["off"]},
|
||||
addr=dev_id,
|
||||
)
|
||||
@@ -152,13 +152,13 @@ async def _identify_send_off_after_delay(sender, dev_id):
|
||||
pass
|
||||
|
||||
|
||||
async def _identify_send_off_after_delay_broadcast(sender, group_ids=None):
|
||||
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 sender.send(body)
|
||||
await bridge.send(body)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -173,11 +173,11 @@ 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"
|
||||
try:
|
||||
ok = await sender.send(
|
||||
ok = await bridge.send(
|
||||
{
|
||||
"v": "1",
|
||||
"presets": {_IDENTIFY_PRESET_KEY: dict(_IDENTIFY_DRIVER_PRESET)},
|
||||
@@ -189,7 +189,7 @@ async def send_identify_to_device(dev_id: str) -> tuple[int, str]:
|
||||
return 503, "Send failed"
|
||||
|
||||
asyncio.create_task(
|
||||
_identify_send_off_after_delay(sender, dev_id)
|
||||
_identify_send_off_after_delay(bridge, dev_id)
|
||||
)
|
||||
except Exception as e:
|
||||
return 503, str(e)
|
||||
@@ -209,8 +209,8 @@ async def send_identify_to_group_devices(
|
||||
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"}]
|
||||
|
||||
body = {
|
||||
@@ -224,7 +224,7 @@ async def send_identify_to_group_devices(
|
||||
|
||||
try:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
[json.dumps(body, separators=(",", ":"))],
|
||||
None,
|
||||
devices,
|
||||
@@ -236,7 +236,7 @@ async def send_identify_to_group_devices(
|
||||
if deliveries < 1:
|
||||
return 0, errors + [{"mac": "*", "error": "Send failed"}]
|
||||
|
||||
asyncio.create_task(_identify_send_off_after_delay_broadcast(sender, gids))
|
||||
asyncio.create_task(_identify_send_off_after_delay_broadcast(bridge, gids))
|
||||
|
||||
seen: set[str] = set()
|
||||
for raw in macs:
|
||||
@@ -413,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):
|
||||
"""
|
||||
@@ -454,13 +494,13 @@ async def push_device_output_brightness(request, id):
|
||||
zone_brightness=zb,
|
||||
)
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=id)
|
||||
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
@@ -484,8 +524,8 @@ async def push_driver_config(request, id):
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
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",
|
||||
}
|
||||
@@ -514,7 +554,7 @@ 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"}
|
||||
ok = await sender.send({"v": "1", "device_config": dc, "save": True}, addr=id)
|
||||
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Send failed"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -3,7 +3,7 @@ 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.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
|
||||
@@ -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)
|
||||
@@ -119,6 +126,7 @@ 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:
|
||||
@@ -217,10 +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 = []
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
bridge = get_current_bridge()
|
||||
if not bridge:
|
||||
return json.dumps({"error": "Transport not configured"}), 503
|
||||
body = {"v": "1", "device_config": dc, "save": True}
|
||||
payload = {"v": "1", "device_config": dc, "save": True}
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(m) != 12:
|
||||
@@ -230,7 +238,7 @@ async def push_group_driver_config(request, session, id):
|
||||
errors.append({"mac": m, "error": "not in registry"})
|
||||
continue
|
||||
try:
|
||||
if await sender.send(body, addr=m):
|
||||
if await bridge.send(payload, addr=m):
|
||||
sent += 1
|
||||
else:
|
||||
errors.append({"mac": m, "error": "send failed"})
|
||||
@@ -260,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(
|
||||
@@ -270,10 +278,10 @@ async def push_group_output_brightness(request, session, id):
|
||||
m,
|
||||
zone_brightness=None,
|
||||
)
|
||||
if not sender:
|
||||
if not bridge:
|
||||
return m, False, "transport not configured"
|
||||
try:
|
||||
ok = await sender.send({"v": "1", "b": b_val, "save": True}, addr=m)
|
||||
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)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 models.transport import get_current_bridge
|
||||
from util.driver_delivery import (
|
||||
build_preset_json_chunks,
|
||||
deliver_json_messages,
|
||||
@@ -224,8 +224,8 @@ 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'}
|
||||
|
||||
send_delay_s = 0.1
|
||||
@@ -264,8 +264,7 @@ async def send_presets(request, session):
|
||||
deliveries = 0
|
||||
for msg in chunk_messages:
|
||||
d, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
[msg],
|
||||
bridge, [msg],
|
||||
target_list,
|
||||
Device(),
|
||||
delay_s=send_delay_s,
|
||||
@@ -278,7 +277,7 @@ async def send_presets(request, session):
|
||||
separators=(",", ":"),
|
||||
)
|
||||
d, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
[def_msg],
|
||||
target_list,
|
||||
Device(),
|
||||
@@ -294,7 +293,7 @@ async def send_presets(request, session):
|
||||
body["groups"] = list(group_ids)
|
||||
wire_messages.append(json.dumps(body, separators=(",", ":")))
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
wire_messages,
|
||||
None,
|
||||
Device(),
|
||||
@@ -343,8 +342,8 @@ 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 = []
|
||||
@@ -385,7 +384,7 @@ async def push_driver_messages(request, session):
|
||||
|
||||
try:
|
||||
deliveries, _chunks = await deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
messages,
|
||||
target_list,
|
||||
Device(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -88,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."""
|
||||
@@ -104,6 +111,8 @@ 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()
|
||||
|
||||
282
src/controllers/wifi_bridge.py
Normal file
282
src/controllers/wifi_bridge.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Pi Wi‑Fi and saved ESP-NOW bridge profiles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from microdot import Microdot
|
||||
|
||||
from settings import get_settings
|
||||
from util.bridge_profiles import find_bridge_profile, normalise_bridges
|
||||
from util.bridge_runtime import (
|
||||
active_bridge_profile_id,
|
||||
bridge_connected,
|
||||
bridge_serial_connected,
|
||||
bridge_ws_connected,
|
||||
connect_bridge_profile,
|
||||
connect_bridge_serial,
|
||||
connect_bridge_wifi,
|
||||
)
|
||||
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
|
||||
|
||||
controller = Microdot()
|
||||
|
||||
|
||||
def _bridge_transport(settings) -> str:
|
||||
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
return mode if mode in ("wifi", "serial") else "wifi"
|
||||
|
||||
|
||||
def _bridges_payload(settings) -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"wifi_interface": settings.get("wifi_interface") or "",
|
||||
"bridge_ws_url": settings.get("bridge_ws_url") or "",
|
||||
"bridge_connected": bridge_connected(),
|
||||
"bridge_wifi_connected": bridge_ws_connected(),
|
||||
"bridge_serial_connected": bridge_serial_connected(),
|
||||
"bridge_transport": _bridge_transport(settings),
|
||||
"active_bridge_id": active_bridge_profile_id(settings) or "",
|
||||
"bridge_serial_port": settings.get("bridge_serial_port") or "",
|
||||
"bridge_serial_baudrate": int(settings.get("bridge_serial_baudrate") or 921600),
|
||||
"bridges": normalise_bridges(settings.get("bridges")),
|
||||
}
|
||||
|
||||
|
||||
@controller.get("/interfaces")
|
||||
async def wifi_interfaces(request):
|
||||
_ = request
|
||||
if not nmcli_available():
|
||||
return (
|
||||
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
|
||||
503,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
return (
|
||||
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
|
||||
200,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
@controller.get("/scan")
|
||||
async def wifi_scan(request):
|
||||
device = (request.args.get("device") or "").strip()
|
||||
if not device:
|
||||
return json.dumps({"error": "device query param required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if not nmcli_available():
|
||||
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
networks = await scan_wifi(device)
|
||||
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.get("/bridges")
|
||||
async def get_bridges(request):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.put("/bridges")
|
||||
async def put_bridges(request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
settings = get_settings()
|
||||
if "wifi_interface" in data:
|
||||
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
|
||||
if "bridge_transport" in data:
|
||||
mode = str(data.get("bridge_transport") or "").strip().lower()
|
||||
if mode in ("wifi", "serial"):
|
||||
settings["bridge_transport"] = mode
|
||||
if "bridge_ws_url" in data:
|
||||
settings["bridge_ws_url"] = str(data.get("bridge_ws_url") or "").strip()
|
||||
if "bridge_serial_port" in data:
|
||||
settings["bridge_serial_port"] = str(data.get("bridge_serial_port") or "").strip()
|
||||
if "bridge_serial_baudrate" in data:
|
||||
settings["bridge_serial_baudrate"] = int(data.get("bridge_serial_baudrate") or 921600)
|
||||
if "bridges" in data:
|
||||
settings["bridges"] = normalise_bridges(data.get("bridges"))
|
||||
settings.save()
|
||||
return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.delete("/bridges/<bridge_id>")
|
||||
async def delete_bridge_profile(request, bridge_id):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
bid = str(bridge_id or "").strip()
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
kept = [b for b in bridges if str(b.get("id") or "") != bid]
|
||||
if len(kept) == len(bridges):
|
||||
return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
settings["bridges"] = kept
|
||||
settings.save()
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = "Bridge profile deleted"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/bridges/<bridge_id>/connect")
|
||||
async def connect_saved_bridge(request, bridge_id):
|
||||
_ = request
|
||||
settings = get_settings()
|
||||
profile = find_bridge_profile(settings, bridge_id)
|
||||
if not profile:
|
||||
return json.dumps({"error": "Bridge profile not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
ok, err = await connect_bridge_profile(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["message"] = f"Connected to {profile.get('label')}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/connect")
|
||||
async def wifi_connect_bridge(request):
|
||||
"""Join a bridge AP and open its WebSocket."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
settings = get_settings()
|
||||
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
|
||||
ssid = str(data.get("ssid") or "").strip()
|
||||
password = str(data.get("password") or "")
|
||||
ap_ip = str(data.get("ap_ip") or "192.168.4.1").strip()
|
||||
try:
|
||||
ws_port = int(data.get("ws_port") or 80)
|
||||
except (TypeError, ValueError):
|
||||
ws_port = 80
|
||||
label = str(data.get("label") or ssid).strip() or ssid
|
||||
save_profile = bool(data.get("save_profile", True))
|
||||
if not device:
|
||||
return json.dumps({"error": "Wi‑Fi interface (device) is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if not ssid:
|
||||
return json.dumps({"error": "ssid is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
settings["wifi_interface"] = device
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
profile_id = None
|
||||
if save_profile:
|
||||
profile_id = secrets.token_hex(6)
|
||||
bridges = [
|
||||
b
|
||||
for b in bridges
|
||||
if not (b.get("transport") == "wifi" and b.get("ssid") == ssid)
|
||||
]
|
||||
bridges.append(
|
||||
{
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"transport": "wifi",
|
||||
"ssid": ssid,
|
||||
"password": password,
|
||||
"ap_ip": ap_ip,
|
||||
"ws_port": ws_port,
|
||||
}
|
||||
)
|
||||
settings["bridges"] = bridges
|
||||
settings.save()
|
||||
profile = {
|
||||
"transport": "wifi",
|
||||
"ssid": ssid,
|
||||
"password": password,
|
||||
"ap_ip": ap_ip,
|
||||
"ws_port": ws_port,
|
||||
"wifi_interface": device,
|
||||
}
|
||||
ok, err = await connect_bridge_wifi(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected to {ssid}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/serial/connect")
|
||||
async def serial_connect_bridge(request):
|
||||
try:
|
||||
data = request.json or {}
|
||||
port = str(data.get("port") or data.get("serial_port") or "").strip()
|
||||
save_profile = bool(data.get("save_profile", True))
|
||||
label = str(data.get("label") or port).strip() or port
|
||||
try:
|
||||
baud = int(data.get("baudrate") or data.get("serial_baudrate") or 921600)
|
||||
except (TypeError, ValueError):
|
||||
baud = 921600
|
||||
if not port:
|
||||
return json.dumps({"error": "port is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
settings = get_settings()
|
||||
bridges = normalise_bridges(settings.get("bridges"))
|
||||
profile_id = None
|
||||
if save_profile:
|
||||
profile_id = secrets.token_hex(6)
|
||||
bridges = [
|
||||
b
|
||||
for b in bridges
|
||||
if not (b.get("transport") == "serial" and b.get("serial_port") == port)
|
||||
]
|
||||
bridges.append(
|
||||
{
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"transport": "serial",
|
||||
"serial_port": port,
|
||||
"serial_baudrate": baud,
|
||||
}
|
||||
)
|
||||
settings["bridges"] = bridges
|
||||
settings.save()
|
||||
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
|
||||
ok, err = await connect_bridge_serial(profile, settings)
|
||||
if not ok:
|
||||
return json.dumps({"ok": False, "error": err}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = _bridges_payload(settings)
|
||||
payload["profile_id"] = profile_id
|
||||
payload["message"] = f"Connected on {port}"
|
||||
return json.dumps(payload), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"ok": False, "error": str(e)}), 500, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
106
src/main.py
106
src/main.py
@@ -7,7 +7,7 @@ import signal
|
||||
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
|
||||
@@ -20,10 +20,19 @@ 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.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
|
||||
|
||||
|
||||
@@ -37,18 +46,48 @@ async def main(port=80):
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
sender = get_sender(settings)
|
||||
set_sender(sender)
|
||||
set_bridge_uplink_handler(handle_bridge_uplink)
|
||||
|
||||
bridge_url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if bridge_url:
|
||||
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", 1))
|
||||
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = 1
|
||||
bridge = init_bridge_client(bridge_url, wifi_channel=ch)
|
||||
bridge.set_uplink_handler(handle_bridge_uplink)
|
||||
bridge.start()
|
||||
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()
|
||||
@@ -63,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:
|
||||
@@ -101,6 +141,7 @@ 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')
|
||||
|
||||
@@ -163,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
|
||||
|
||||
@@ -176,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
|
||||
@@ -247,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"
|
||||
@@ -273,11 +339,11 @@ async def main(port=80):
|
||||
break
|
||||
try:
|
||||
if isinstance(data, (bytes, bytearray)):
|
||||
await sender.send(bytes(data))
|
||||
await bridge.send(bytes(data))
|
||||
continue
|
||||
parsed = json.loads(data)
|
||||
addr = parsed.pop("to", None)
|
||||
await sender.send(parsed, addr=addr)
|
||||
await bridge.send(parsed, addr=addr)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
199
src/models/bridge_serial_client.py
Normal file
199
src/models/bridge_serial_client.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Persistent USB/serial client to the ESP-NOW bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Awaitable, Callable, Optional, Union
|
||||
|
||||
import serial
|
||||
import serial_asyncio
|
||||
|
||||
from util.bridge_serial_frame import feed_serial_buffer, pack_serial_frame
|
||||
from util.espnow_wire import parse_ws_frame
|
||||
|
||||
UplinkHandler = Callable[[bytes, bytes], Awaitable[None]]
|
||||
|
||||
|
||||
class BridgeSerialClient:
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
*,
|
||||
baudrate: int = 921600,
|
||||
reconnect_delay_s: float = 2.0,
|
||||
):
|
||||
self._port = str(port or "").strip()
|
||||
self._baudrate = int(baudrate)
|
||||
self._reconnect_delay_s = reconnect_delay_s
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._uplink_handler: Optional[UplinkHandler] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._read_task: Optional[asyncio.Task] = None
|
||||
self._connected = asyncio.Event()
|
||||
self._disconnect_event = asyncio.Event()
|
||||
self._stop = False
|
||||
self._read_buf = bytearray()
|
||||
self._bad_frame_count = 0
|
||||
|
||||
def set_uplink_handler(self, handler: Optional[UplinkHandler]) -> None:
|
||||
self._uplink_handler = handler
|
||||
|
||||
def _signal_disconnect(self) -> None:
|
||||
self._connected.clear()
|
||||
self._disconnect_event.set()
|
||||
|
||||
async def _close_serial(self) -> None:
|
||||
reader = self._reader
|
||||
writer = self._writer
|
||||
self._reader = None
|
||||
self._writer = None
|
||||
if writer is not None:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _read_loop(self) -> None:
|
||||
try:
|
||||
while not self._disconnect_event.is_set() and not self._stop:
|
||||
reader = self._reader
|
||||
if reader is None:
|
||||
break
|
||||
try:
|
||||
chunk = await reader.read(4096)
|
||||
except (serial.SerialException, OSError, asyncio.IncompleteReadError) as e:
|
||||
print(f"[bridge-serial] read error: {e!r}")
|
||||
break
|
||||
if not chunk:
|
||||
await asyncio.sleep(0.01)
|
||||
continue
|
||||
frames = feed_serial_buffer(self._read_buf, chunk)
|
||||
handler = self._uplink_handler
|
||||
if handler is None:
|
||||
continue
|
||||
for frame in frames:
|
||||
try:
|
||||
peer, pkt, _bcast = parse_ws_frame(frame)
|
||||
except ValueError:
|
||||
self._bad_frame_count += 1
|
||||
if self._bad_frame_count <= 3:
|
||||
print(
|
||||
f"[bridge-serial] ignored frame ({len(frame)} B), "
|
||||
f"expected ws uplink header"
|
||||
)
|
||||
continue
|
||||
self._bad_frame_count = 0
|
||||
await handler(peer, pkt)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
finally:
|
||||
self._signal_disconnect()
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while not self._stop:
|
||||
try:
|
||||
await self._connect_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[bridge-serial] connection error: {e!r}")
|
||||
self._signal_disconnect()
|
||||
self._disconnect_event.clear()
|
||||
await self._close_serial()
|
||||
if self._stop:
|
||||
break
|
||||
print("[bridge-serial] disconnected, reconnecting...")
|
||||
await asyncio.sleep(self._reconnect_delay_s)
|
||||
|
||||
async def _connect_once(self) -> None:
|
||||
if not self._port:
|
||||
raise serial.SerialException("serial port not configured")
|
||||
print(f"[bridge-serial] opening {self._port!r} @ {self._baudrate}")
|
||||
self._read_buf.clear()
|
||||
self._disconnect_event.clear()
|
||||
reader, writer = await serial_asyncio.open_serial_connection(
|
||||
url=self._port,
|
||||
baudrate=self._baudrate,
|
||||
exclusive=True,
|
||||
)
|
||||
self._reader = reader
|
||||
self._writer = writer
|
||||
self._connected.set()
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
print("[bridge-serial] connected")
|
||||
try:
|
||||
await self._disconnect_event.wait()
|
||||
finally:
|
||||
read_task = self._read_task
|
||||
self._read_task = None
|
||||
if read_task is not None:
|
||||
read_task.cancel()
|
||||
try:
|
||||
await read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await self._close_serial()
|
||||
|
||||
async def wait_connected(self, timeout: float = 30.0) -> bool:
|
||||
try:
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=timeout)
|
||||
writer = self._writer
|
||||
return writer is not None and not writer.is_closing()
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
async def send_packet(self, packet: Union[bytes, str, dict]) -> bool:
|
||||
if isinstance(packet, dict):
|
||||
packet = json.dumps(packet, separators=(",", ":"))
|
||||
if isinstance(packet, str):
|
||||
packet = packet.encode("utf-8")
|
||||
if not await self.wait_connected(timeout=30.0):
|
||||
return False
|
||||
writer = self._writer
|
||||
if writer is None or writer.is_closing():
|
||||
return False
|
||||
frame = pack_serial_frame(bytes(packet))
|
||||
async with self._send_lock:
|
||||
try:
|
||||
writer = self._writer
|
||||
if writer is None or writer.is_closing():
|
||||
return False
|
||||
writer.write(frame)
|
||||
await writer.drain()
|
||||
return True
|
||||
except (serial.SerialException, OSError, ConnectionError) as e:
|
||||
print(f"[bridge-serial] send failed: {e!r}")
|
||||
self._signal_disconnect()
|
||||
return False
|
||||
|
||||
def start(self) -> asyncio.Task:
|
||||
self._stop = False
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.create_task(self.run_forever())
|
||||
return self._task
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop = True
|
||||
self._signal_disconnect()
|
||||
task = self._task
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
|
||||
|
||||
_client: Optional[BridgeSerialClient] = None
|
||||
|
||||
|
||||
def get_bridge_serial_client() -> Optional[BridgeSerialClient]:
|
||||
return _client
|
||||
|
||||
|
||||
def init_bridge_serial_client(port: str, *, baudrate: int = 921600) -> BridgeSerialClient:
|
||||
global _client
|
||||
if _client is not None:
|
||||
_client.stop()
|
||||
_client = BridgeSerialClient(port, baudrate=baudrate)
|
||||
return _client
|
||||
@@ -9,13 +9,16 @@ 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 = 1, reconnect_delay_s: float = 2.0):
|
||||
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
|
||||
@@ -25,6 +28,7 @@ class BridgeWsClient:
|
||||
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
|
||||
@@ -43,7 +47,7 @@ class BridgeWsClient:
|
||||
pass
|
||||
|
||||
async def run_forever(self) -> None:
|
||||
while True:
|
||||
while not self._stop:
|
||||
try:
|
||||
await self._connect_once()
|
||||
except asyncio.CancelledError:
|
||||
@@ -53,6 +57,8 @@ class BridgeWsClient:
|
||||
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)
|
||||
|
||||
@@ -136,10 +142,18 @@ class BridgeWsClient:
|
||||
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
|
||||
|
||||
@@ -148,7 +162,9 @@ def get_bridge_client() -> Optional[BridgeWsClient]:
|
||||
return _client
|
||||
|
||||
|
||||
def init_bridge_client(url: str, *, wifi_channel: int = 1) -> BridgeWsClient:
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Transport to LED drivers via ESP-NOW bridge WebSocket."""
|
||||
"""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,
|
||||
@@ -15,14 +16,14 @@ from util.bridge_envelope import (
|
||||
from util.espnow_wire import WIRE_MAGIC
|
||||
|
||||
|
||||
class NullSender:
|
||||
class NullBridge:
|
||||
"""No bridge configured."""
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BridgeWsSender:
|
||||
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:
|
||||
@@ -73,6 +74,57 @@ class BridgeWsSender:
|
||||
return await client.send_packet(envelope)
|
||||
|
||||
|
||||
class BridgeSerialTransport:
|
||||
"""Send v1 JSON or devices envelope via bridge USB/serial."""
|
||||
|
||||
async def send(self, data: Union[bytes, str, Dict[str, Any]], addr=None) -> bool:
|
||||
client = get_bridge_serial_client()
|
||||
if client is None:
|
||||
return False
|
||||
|
||||
if isinstance(data, dict):
|
||||
if data.get("v") == "1" and ("devices" in data or "dv" in data):
|
||||
from util.v1_wire import compact_envelope
|
||||
|
||||
return await client.send_packet(compact_envelope(data))
|
||||
packet = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
||||
elif isinstance(data, str):
|
||||
packet = data.encode("utf-8")
|
||||
elif isinstance(data, (bytes, bytearray)):
|
||||
packet = bytes(data)
|
||||
else:
|
||||
return False
|
||||
|
||||
if not packet:
|
||||
return False
|
||||
|
||||
if packet[0] == WIRE_MAGIC:
|
||||
return await client.send_packet(packet)
|
||||
|
||||
if packet[0:1] != b"{":
|
||||
return False
|
||||
|
||||
mac_key = _addr_to_envelope_key(addr)
|
||||
if mac_key is None:
|
||||
return await client.send_packet(packet)
|
||||
|
||||
try:
|
||||
body = json.loads(packet.decode("utf-8"))
|
||||
except (UnicodeError, ValueError, TypeError):
|
||||
return False
|
||||
if not isinstance(body, dict) or body.get("v") != "1":
|
||||
return False
|
||||
|
||||
envelope = build_devices_envelope({mac_key: body})
|
||||
return await client.send_packet(envelope)
|
||||
|
||||
async def send_envelope(self, envelope: Dict[str, Any]) -> bool:
|
||||
client = get_bridge_serial_client()
|
||||
if client is None:
|
||||
return False
|
||||
return await client.send_packet(envelope)
|
||||
|
||||
|
||||
def _addr_to_envelope_key(addr) -> Optional[str]:
|
||||
if addr is None:
|
||||
return BROADCAST_MAC
|
||||
@@ -88,24 +140,32 @@ def _addr_to_envelope_key(addr) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
_current_sender = None
|
||||
_current_bridge = None
|
||||
|
||||
|
||||
def set_sender(sender):
|
||||
global _current_sender
|
||||
_current_sender = sender
|
||||
def set_bridge(bridge):
|
||||
global _current_bridge
|
||||
_current_bridge = bridge
|
||||
|
||||
|
||||
def get_current_sender():
|
||||
return _current_sender
|
||||
def get_current_bridge():
|
||||
return _current_bridge
|
||||
|
||||
|
||||
def get_sender(settings):
|
||||
def get_bridge(settings):
|
||||
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
if mode == "wifi":
|
||||
url = str(settings.get("bridge_ws_url") or "").strip()
|
||||
if not url:
|
||||
print("[startup] bridge Wi‑Fi disabled (set bridge_ws_url in settings.json)")
|
||||
return NullBridge()
|
||||
print(f"[startup] ESP-NOW via bridge WebSocket {url!r}")
|
||||
return BridgeWsTransport()
|
||||
port = str(settings.get("bridge_serial_port") or "").strip()
|
||||
if not port:
|
||||
print(
|
||||
"[startup] bridge disabled (set bridge_ws_url in settings.json, e.g. ws://192.168.4.1/ws)"
|
||||
"[startup] bridge serial disabled (set bridge_serial_port in settings.json)"
|
||||
)
|
||||
return NullSender()
|
||||
print(f"[startup] ESP-NOW via bridge WebSocket {url!r} (devices envelope)")
|
||||
return BridgeWsSender()
|
||||
return NullBridge()
|
||||
print(f"[startup] ESP-NOW via bridge USB serial {port!r}")
|
||||
return BridgeSerialTransport()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,10 +53,20 @@ class Settings(dict):
|
||||
self.save()
|
||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||
if 'wifi_channel' not in self:
|
||||
self['wifi_channel'] = 1
|
||||
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 (0–255); shared across browsers/devices.
|
||||
if 'global_brightness' not in self:
|
||||
self['global_brightness'] = 255
|
||||
@@ -66,6 +78,9 @@ 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, 0–200).
|
||||
if 'audio_input_volume' not in self:
|
||||
self['audio_input_volume'] = 100
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
|
||||
@@ -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 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";
|
||||
function beatSyncButtonTitle(zoneSeqActive) {
|
||||
if (!audioDetectorRunning) return "Start beat detection";
|
||||
if (zoneSeqActive) return "Sync step to music (S)";
|
||||
return "Beat detection running";
|
||||
}
|
||||
|
||||
function updateSequenceSyncControls(zoneSeqActive) {
|
||||
const disabled = audioDetectorRunning && !zoneSeqActive;
|
||||
const title = beatSyncButtonTitle(zoneSeqActive);
|
||||
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
|
||||
const btn = el(id);
|
||||
if (!btn) continue;
|
||||
btn.disabled = disabled;
|
||||
btn.title = title;
|
||||
}
|
||||
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 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);
|
||||
function flashBeatSyncButton(btn) {
|
||||
if (!btn) return;
|
||||
btn.classList.add("flash");
|
||||
setTimeout(() => btn.classList.remove("flash"), 90);
|
||||
}
|
||||
|
||||
function flashBeat() {
|
||||
const top = el("audio-top-indicator");
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync && top && top.classList.contains("audio-running")) {
|
||||
flashBeatSyncButton(topSync);
|
||||
}
|
||||
const modalSync = el("audio-modal-beat-sync");
|
||||
if (modalSync && audioDetectorRunning) {
|
||||
flashBeatSyncButton(modalSync);
|
||||
}
|
||||
}
|
||||
|
||||
function gainPercentToDb(pct) {
|
||||
const gain = Math.max(0.001, pct / 100);
|
||||
return 20 * Math.log10(gain);
|
||||
}
|
||||
|
||||
function formatGainReadout(pct) {
|
||||
const db = gainPercentToDb(pct);
|
||||
const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2);
|
||||
return `${pct}% (${dbText} dB)`;
|
||||
}
|
||||
|
||||
function updateInputLevelDisplay(level) {
|
||||
const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0;
|
||||
const bar = el("audio-input-level-bar");
|
||||
const meter = el("audio-modal")?.querySelector(".audio-input-level-meter");
|
||||
if (bar) bar.style.width = `${pct}%`;
|
||||
if (meter) meter.setAttribute("aria-valuenow", String(pct));
|
||||
}
|
||||
|
||||
function clearBeatPhaseTimers() {
|
||||
@@ -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", () => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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,189 @@ 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 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)';
|
||||
@@ -277,17 +464,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');
|
||||
@@ -571,6 +757,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
applyEspnowPingAggregateToDots();
|
||||
loadDevicesModal();
|
||||
startDevicesModalLiveRefresh();
|
||||
});
|
||||
@@ -581,6 +768,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
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 +861,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;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,14 @@ async function fetchDevicesMapForGroups() {
|
||||
|
||||
function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||||
if (!containerEl) return;
|
||||
const panel =
|
||||
typeof window.prepareZoneDevicesPanel === 'function'
|
||||
? window.prepareZoneDevicesPanel(containerEl)
|
||||
: null;
|
||||
const listEl = panel ? panel.listEl : containerEl;
|
||||
if (!panel) {
|
||||
containerEl.innerHTML = '';
|
||||
}
|
||||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
macRows.forEach((row, idx) => {
|
||||
@@ -72,7 +79,7 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||||
});
|
||||
div.appendChild(label);
|
||||
div.appendChild(rm);
|
||||
containerEl.appendChild(div);
|
||||
listEl.appendChild(div);
|
||||
});
|
||||
|
||||
const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean));
|
||||
@@ -101,7 +108,11 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
if (panel) {
|
||||
panel.addSlot.appendChild(addWrap);
|
||||
} else {
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
refreshEditGroupDebug();
|
||||
}
|
||||
|
||||
@@ -320,12 +331,6 @@ function renderGroupsList(groups) {
|
||||
alert(data.error || 'Apply brightness failed');
|
||||
return;
|
||||
}
|
||||
const n = typeof data.sent === 'number' ? data.sent : 0;
|
||||
alert(
|
||||
n
|
||||
? `Sent brightness to ${n} driver(s).`
|
||||
: 'No Wi‑Fi 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 Wi‑Fi 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');
|
||||
|
||||
@@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const settingsButton = document.getElementById('settings-btn');
|
||||
const settingsModal = document.getElementById('settings-modal');
|
||||
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||
const settingsTabButtons = document.querySelectorAll('[data-settings-tab]');
|
||||
const settingsTabPanels = document.querySelectorAll('[data-settings-panel]');
|
||||
const ledToolIframe = document.getElementById('led-tool-iframe');
|
||||
let settingsActiveTab = 'bridge';
|
||||
|
||||
const showSettingsMessage = (text, type = 'success') => {
|
||||
const messageEl = document.getElementById('settings-message');
|
||||
if (!messageEl) return;
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = `message ${type} show`;
|
||||
setTimeout(() => {
|
||||
messageEl.classList.remove('show');
|
||||
}, 5000);
|
||||
function loadLedToolIframe() {
|
||||
if (!ledToolIframe) return;
|
||||
const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank';
|
||||
if (blank) {
|
||||
ledToolIframe.src = '/led-tool/editor';
|
||||
}
|
||||
}
|
||||
|
||||
function unloadLedToolIframe() {
|
||||
if (ledToolIframe) {
|
||||
ledToolIframe.src = 'about:blank';
|
||||
}
|
||||
}
|
||||
|
||||
function switchSettingsTab(tabId) {
|
||||
if (!tabId) tabId = 'bridge';
|
||||
settingsActiveTab = tabId;
|
||||
for (const btn of settingsTabButtons) {
|
||||
const on = btn.getAttribute('data-settings-tab') === tabId;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
}
|
||||
for (const panel of settingsTabPanels) {
|
||||
const on = panel.getAttribute('data-settings-panel') === tabId;
|
||||
panel.classList.toggle('active', on);
|
||||
panel.hidden = !on;
|
||||
}
|
||||
if (settingsModal) {
|
||||
settingsModal.classList.toggle('settings-modal--led-tool', tabId === 'led-tool');
|
||||
}
|
||||
if (tabId === 'led-tool') {
|
||||
loadLedToolIframe();
|
||||
}
|
||||
}
|
||||
|
||||
for (const btn of settingsTabButtons) {
|
||||
btn.addEventListener('click', () => {
|
||||
switchSettingsTab(btn.getAttribute('data-settings-tab'));
|
||||
});
|
||||
}
|
||||
|
||||
window.openSettingsModal = (tabId) => {
|
||||
if (!settingsModal) return;
|
||||
if (tabId) {
|
||||
switchSettingsTab(tabId);
|
||||
} else {
|
||||
switchSettingsTab(settingsActiveTab);
|
||||
}
|
||||
settingsModal.classList.add('active');
|
||||
if (!tabId || tabId === 'bridge') {
|
||||
loadBridgeSettings();
|
||||
}
|
||||
};
|
||||
|
||||
async function loadDeviceSettings() {
|
||||
try {
|
||||
const response = await fetch('/settings');
|
||||
const data = await response.json();
|
||||
const nameInput = document.getElementById('device-name-input');
|
||||
if (nameInput && data && typeof data === 'object') {
|
||||
nameInput.value = data.device_name || 'led-controller';
|
||||
const bridgeWsStatus = document.getElementById('bridge-ws-status');
|
||||
const bridgeConnectionDetails = document.getElementById('bridge-connection-details');
|
||||
const bridgeProfilesList = document.getElementById('bridge-profiles-list');
|
||||
let lastBridgeSettings = null;
|
||||
const bridgeSerialPortSelect = document.getElementById('bridge-serial-port');
|
||||
const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud');
|
||||
const bridgeSerialConnectBtn = document.getElementById('bridge-serial-connect-btn');
|
||||
const bridgeSerialSaveProfileBtn = document.getElementById('bridge-serial-save-profile-btn');
|
||||
const bridgeSerialRefreshBtn = document.getElementById('bridge-serial-refresh-btn');
|
||||
const bridgeWifiInterfaceSelect = document.getElementById('bridge-wifi-interface');
|
||||
const bridgeWifiRefreshInterfacesBtn = document.getElementById('bridge-wifi-refresh-interfaces-btn');
|
||||
const bridgeWifiSsidSelect = document.getElementById('bridge-wifi-ssid');
|
||||
const bridgeWifiSsidManual = document.getElementById('bridge-wifi-ssid-manual');
|
||||
const bridgeWifiPassword = document.getElementById('bridge-wifi-password');
|
||||
const bridgeWifiConnectBtn = document.getElementById('bridge-wifi-connect-btn');
|
||||
const bridgeWifiSaveProfileBtn = document.getElementById('bridge-wifi-save-profile-btn');
|
||||
const bridgeWifiScanBtn = document.getElementById('bridge-wifi-scan-btn');
|
||||
const bridgeWifiApIp = document.getElementById('bridge-wifi-ap-ip');
|
||||
const bridgeWifiWsPort = document.getElementById('bridge-wifi-ws-port');
|
||||
|
||||
function setBridgeWsStatus(text, isError = false) {
|
||||
if (!bridgeWsStatus) return;
|
||||
bridgeWsStatus.textContent = text || '';
|
||||
bridgeWsStatus.style.color = isError ? '#f44336' : '';
|
||||
}
|
||||
const chInput = document.getElementById('wifi-channel-input');
|
||||
if (chInput && data && typeof data === 'object') {
|
||||
const ch = data.wifi_channel;
|
||||
chInput.value =
|
||||
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||
|
||||
function connLabel(ok) {
|
||||
return ok ? 'connected' : 'not connected';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading device settings:', error);
|
||||
|
||||
function bridgeStatusLine(data) {
|
||||
if (!data) return '';
|
||||
const mode = data.bridge_transport === 'serial' ? 'USB serial' : 'Wi‑Fi';
|
||||
const active = data.active_bridge_id
|
||||
? (data.bridges || []).find((b) => b.id === data.active_bridge_id)
|
||||
: null;
|
||||
const activeBit = active ? ` — active profile: ${active.label}` : '';
|
||||
if (data.bridge_transport === 'wifi' && data.bridge_ws_url) {
|
||||
return `${mode}: ${data.bridge_ws_url} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||
}
|
||||
if (data.bridge_serial_port) {
|
||||
return `${mode}: ${data.bridge_serial_port} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||
}
|
||||
return `Bridge ${mode} (${connLabel(data.bridge_connected)})${activeBit}`;
|
||||
}
|
||||
|
||||
function renderBridgeConnectionDetails(data) {
|
||||
if (!bridgeConnectionDetails) return;
|
||||
bridgeConnectionDetails.innerHTML = '';
|
||||
if (!data) return;
|
||||
const rows = [
|
||||
['Transport in use', data.bridge_transport === 'serial' ? 'USB serial' : 'Wi‑Fi'],
|
||||
[
|
||||
'Wi‑Fi WebSocket',
|
||||
data.bridge_ws_url
|
||||
? `${data.bridge_ws_url} (${connLabel(data.bridge_wifi_connected)})`
|
||||
: connLabel(false),
|
||||
],
|
||||
[
|
||||
'USB serial',
|
||||
data.bridge_serial_port
|
||||
? `${data.bridge_serial_port} (${connLabel(data.bridge_serial_connected)})`
|
||||
: connLabel(false),
|
||||
],
|
||||
];
|
||||
const active = (data.bridges || []).find((b) => b.id === data.active_bridge_id);
|
||||
if (active) {
|
||||
const detail =
|
||||
active.transport === 'wifi'
|
||||
? `Wi‑Fi ${active.ssid}`
|
||||
: `USB ${active.serial_port}`;
|
||||
rows.push(['Active saved profile', `${active.label} (${detail})`]);
|
||||
} else if (data.bridge_connected) {
|
||||
rows.push(['Active saved profile', '— (connected, no matching saved profile)']);
|
||||
}
|
||||
for (const [k, v] of rows) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${k}: ${v}`;
|
||||
bridgeConnectionDetails.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAPStatus() {
|
||||
function resolvedBridgeSsid() {
|
||||
const manual = bridgeWifiSsidManual?.value?.trim();
|
||||
if (manual) return manual;
|
||||
return bridgeWifiSsidSelect?.value?.trim() || '';
|
||||
}
|
||||
|
||||
async function loadBridgeSettings() {
|
||||
try {
|
||||
const response = await fetch('/settings/wifi/ap');
|
||||
const config = await response.json();
|
||||
const statusEl = document.getElementById('ap-status');
|
||||
if (!statusEl) return;
|
||||
if (config.active) {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
||||
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||
`;
|
||||
const bridgesRes = await fetch('/settings/wifi/bridges');
|
||||
const bridgesData = await bridgesRes.json().catch(() => ({}));
|
||||
lastBridgeSettings = bridgesData;
|
||||
if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) {
|
||||
bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate);
|
||||
}
|
||||
await loadSerialPorts(bridgesData.bridge_serial_port || '');
|
||||
await loadWifiInterfaces(bridgesData.wifi_interface || '');
|
||||
renderBridgeConnectionDetails(bridgesData);
|
||||
setBridgeWsStatus(bridgeStatusLine(bridgesData));
|
||||
renderBridgeProfiles(bridgesData.bridges || [], bridgesData);
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWifiInterfaces(selectedDevice) {
|
||||
if (!bridgeWifiInterfaceSelect) return;
|
||||
try {
|
||||
const res = await fetch('/settings/wifi/interfaces');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Wi‑Fi interfaces unavailable', true);
|
||||
return;
|
||||
}
|
||||
const current = selectedDevice || bridgeWifiInterfaceSelect.value;
|
||||
bridgeWifiInterfaceSelect.innerHTML = '<option value="">— select adapter —</option>';
|
||||
for (const iface of data.interfaces || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = iface.device;
|
||||
const bits = [iface.device];
|
||||
if (iface.label && iface.label !== iface.device) bits.push(iface.label);
|
||||
if (iface.state) bits.push(`(${iface.state})`);
|
||||
opt.textContent = bits.join(' — ');
|
||||
bridgeWifiInterfaceSelect.appendChild(opt);
|
||||
}
|
||||
if (current) bridgeWifiInterfaceSelect.value = current;
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanBridgeWifi() {
|
||||
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||
if (!device) {
|
||||
setBridgeWsStatus('Select a Wi‑Fi adapter first', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus('Scanning…');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/settings/wifi/scan?device=${encodeURIComponent(device)}`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Scan failed', true);
|
||||
return;
|
||||
}
|
||||
if (!bridgeWifiSsidSelect) return;
|
||||
const prev = resolvedBridgeSsid();
|
||||
bridgeWifiSsidSelect.innerHTML = '<option value="">— select network —</option>';
|
||||
for (const net of data.networks || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = net.ssid;
|
||||
opt.textContent = `${net.ssid} (${net.signal}%)`;
|
||||
bridgeWifiSsidSelect.appendChild(opt);
|
||||
}
|
||||
if (prev) {
|
||||
bridgeWifiSsidSelect.value = prev;
|
||||
if (!bridgeWifiSsidSelect.value && bridgeWifiSsidManual) {
|
||||
bridgeWifiSsidManual.value = prev;
|
||||
}
|
||||
}
|
||||
setBridgeWsStatus(`Found ${(data.networks || []).length} network(s)`);
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSerialPorts(selectedPort) {
|
||||
if (!bridgeSerialPortSelect) return;
|
||||
try {
|
||||
const res = await fetch('/led-tool/ports');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const current = selectedPort || bridgeSerialPortSelect.value;
|
||||
bridgeSerialPortSelect.innerHTML = '<option value="">— select port —</option>';
|
||||
for (const p of data.ports || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.device;
|
||||
opt.textContent = p.description ? `${p.device} — ${p.description}` : p.device;
|
||||
bridgeSerialPortSelect.appendChild(opt);
|
||||
}
|
||||
if (current) bridgeSerialPortSelect.value = current;
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function profileStatusFor(p, data) {
|
||||
const activeId = data.active_bridge_id || '';
|
||||
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
|
||||
if (isActive) {
|
||||
return { text: 'Connected', className: 'settings-bridge-profile-status--connected' };
|
||||
}
|
||||
return { text: 'Not connected', className: 'settings-bridge-profile-status--idle' };
|
||||
}
|
||||
|
||||
async function deleteBridgeProfile(id, label) {
|
||||
const name = label || id;
|
||||
if (!window.confirm(`Delete saved bridge profile “${name}”?`)) return;
|
||||
setBridgeWsStatus('Deleting…');
|
||||
try {
|
||||
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Delete failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus(data.message || 'Profile deleted');
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderBridgeProfiles(profiles, bridgesData) {
|
||||
if (!bridgeProfilesList) return;
|
||||
bridgeProfilesList.innerHTML = '';
|
||||
const data = bridgesData || lastBridgeSettings || {};
|
||||
const activeId = data.active_bridge_id || '';
|
||||
if (!profiles.length) {
|
||||
bridgeProfilesList.innerHTML = '<li>No saved bridge profiles.</li>';
|
||||
return;
|
||||
}
|
||||
for (const p of profiles) {
|
||||
const li = document.createElement('li');
|
||||
const isActive = Boolean(activeId && p.id === activeId && data.bridge_connected);
|
||||
li.className =
|
||||
'settings-bridge-profile-row' + (isActive ? ' settings-bridge-profile-row--active' : '');
|
||||
const main = document.createElement('div');
|
||||
main.className = 'settings-bridge-profile-main';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'settings-bridge-profile-label';
|
||||
if (p.transport === 'wifi') {
|
||||
label.textContent = `${p.label} — Wi‑Fi ${p.ssid}`;
|
||||
} else {
|
||||
statusEl.innerHTML = `
|
||||
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
||||
<p>Access Point is not currently active</p>
|
||||
`;
|
||||
label.textContent = `${p.label} — USB ${p.serial_port}`;
|
||||
}
|
||||
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||
} catch (error) {
|
||||
console.error('Error loading AP status:', error);
|
||||
const status = document.createElement('span');
|
||||
const st = profileStatusFor(p, data);
|
||||
status.className = 'settings-bridge-profile-status ' + st.className;
|
||||
status.textContent = st.text;
|
||||
main.appendChild(label);
|
||||
main.appendChild(status);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'settings-bridge-profile-actions';
|
||||
const connectBtn = document.createElement('button');
|
||||
connectBtn.type = 'button';
|
||||
connectBtn.className = 'btn btn-secondary btn-small';
|
||||
connectBtn.textContent = 'Connect';
|
||||
connectBtn.addEventListener('click', () => connectSavedBridge(p.id));
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn btn-secondary btn-small settings-bridge-profile-delete';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.addEventListener('click', () => deleteBridgeProfile(p.id, p.label));
|
||||
actions.appendChild(connectBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
li.appendChild(main);
|
||||
li.appendChild(actions);
|
||||
bridgeProfilesList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSavedBridge(id) {
|
||||
setBridgeWsStatus('Connecting…');
|
||||
try {
|
||||
const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectBridgeWifi(saveProfile) {
|
||||
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||
const ssid = resolvedBridgeSsid();
|
||||
const password = bridgeWifiPassword?.value || '';
|
||||
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
|
||||
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
|
||||
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
|
||||
if (!device) {
|
||||
setBridgeWsStatus('Select a Wi‑Fi adapter', true);
|
||||
return;
|
||||
}
|
||||
if (!ssid) {
|
||||
setBridgeWsStatus('Enter or select a bridge SSID', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus('Connecting…');
|
||||
try {
|
||||
const res = await fetch('/settings/wifi/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device,
|
||||
ssid,
|
||||
password,
|
||||
ap_ip: apIp,
|
||||
ws_port: wsPort,
|
||||
label,
|
||||
save_profile: saveProfile,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectBridgeSerial(saveProfile) {
|
||||
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
|
||||
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
|
||||
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
|
||||
if (!port) {
|
||||
setBridgeWsStatus('Select a USB serial port', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus('Connecting…');
|
||||
try {
|
||||
const res = await fetch('/settings/wifi/serial/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ port, baudrate: baud, label, save_profile: saveProfile }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
setBridgeWsStatus(data.error || 'Connect failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data));
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (bridgeSerialRefreshBtn) {
|
||||
bridgeSerialRefreshBtn.addEventListener('click', () => loadSerialPorts());
|
||||
}
|
||||
|
||||
if (bridgeSerialConnectBtn) {
|
||||
bridgeSerialConnectBtn.addEventListener('click', () => connectBridgeSerial(true));
|
||||
}
|
||||
|
||||
if (bridgeWifiRefreshInterfacesBtn) {
|
||||
bridgeWifiRefreshInterfacesBtn.addEventListener('click', () => loadWifiInterfaces());
|
||||
}
|
||||
|
||||
if (bridgeWifiScanBtn) {
|
||||
bridgeWifiScanBtn.addEventListener('click', () => scanBridgeWifi());
|
||||
}
|
||||
|
||||
if (bridgeWifiConnectBtn) {
|
||||
bridgeWifiConnectBtn.addEventListener('click', () => connectBridgeWifi(true));
|
||||
}
|
||||
|
||||
if (bridgeWifiSaveProfileBtn) {
|
||||
bridgeWifiSaveProfileBtn.addEventListener('click', async () => {
|
||||
const device = bridgeWifiInterfaceSelect?.value?.trim();
|
||||
const ssid = resolvedBridgeSsid();
|
||||
if (!ssid) {
|
||||
setBridgeWsStatus('SSID required to save profile', true);
|
||||
return;
|
||||
}
|
||||
const password = bridgeWifiPassword?.value || '';
|
||||
const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1';
|
||||
const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80;
|
||||
const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid;
|
||||
try {
|
||||
const res = await fetch('/settings/wifi/bridges');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
|
||||
bridges.push({
|
||||
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
|
||||
label,
|
||||
transport: 'wifi',
|
||||
ssid,
|
||||
password,
|
||||
ap_ip: apIp,
|
||||
ws_port: wsPort,
|
||||
});
|
||||
const putRes = await fetch('/settings/wifi/bridges', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ bridges, wifi_interface: device || data.wifi_interface }),
|
||||
});
|
||||
const putData = await putRes.json().catch(() => ({}));
|
||||
if (!putRes.ok || !putData.ok) {
|
||||
setBridgeWsStatus(putData.error || 'Save failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus('Wi‑Fi profile saved');
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (bridgeSerialSaveProfileBtn) {
|
||||
bridgeSerialSaveProfileBtn.addEventListener('click', async () => {
|
||||
const port = bridgeSerialPortSelect ? bridgeSerialPortSelect.value : '';
|
||||
if (!port) {
|
||||
setBridgeWsStatus('Port required to save profile', true);
|
||||
return;
|
||||
}
|
||||
const baud = parseInt(bridgeSerialBaudInput?.value, 10) || 115200;
|
||||
const label = document.getElementById('bridge-serial-label')?.value?.trim() || port;
|
||||
try {
|
||||
const res = await fetch('/settings/wifi/bridges');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const bridges = Array.isArray(data.bridges) ? data.bridges : [];
|
||||
bridges.push({
|
||||
id: crypto.randomUUID ? crypto.randomUUID().slice(0, 12) : String(Date.now()),
|
||||
label,
|
||||
transport: 'serial',
|
||||
serial_port: port,
|
||||
serial_baudrate: baud,
|
||||
});
|
||||
const putRes = await fetch('/settings/wifi/bridges', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ bridges }),
|
||||
});
|
||||
const putData = await putRes.json().catch(() => ({}));
|
||||
if (!putRes.ok || !putData.ok) {
|
||||
setBridgeWsStatus(putData.error || 'Save failed', true);
|
||||
return;
|
||||
}
|
||||
setBridgeWsStatus('Serial profile saved');
|
||||
await loadBridgeSettings();
|
||||
} catch (err) {
|
||||
setBridgeWsStatus(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsButton && settingsModal) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -98,29 +98,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
: [];
|
||||
};
|
||||
|
||||
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05, pushOptions = {}) => {
|
||||
if (typeof window.postDriverSequence === 'function') {
|
||||
return window.postDriverSequence(sequence, targetMacs, delayS, pushOptions);
|
||||
}
|
||||
const body = { sequence, delay_s: delayS };
|
||||
if (pushOptions && pushOptions.unicast === true) {
|
||||
body.unicast = true;
|
||||
if (Array.isArray(targetMacs) && targetMacs.length) {
|
||||
body.targets = [...new Set(targetMacs)];
|
||||
}
|
||||
}
|
||||
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') {
|
||||
|
||||
@@ -2134,7 +2134,7 @@ const sendPresetSelectViaEspNow = async (presetId, deviceNames) => {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
27
src/static/zone-devices-panel.js
Normal file
27
src/static/zone-devices-panel.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Scrollable device/group list with a fixed add row (dropdown + button) below.
|
||||
* Used by group and zone edit modals.
|
||||
*/
|
||||
function prepareZoneDevicesPanel(containerEl) {
|
||||
if (!containerEl) return null;
|
||||
let listEl = containerEl.querySelector('.zone-devices-list');
|
||||
let addSlot = containerEl.querySelector('.zone-devices-add-slot');
|
||||
if (!listEl) {
|
||||
containerEl.innerHTML = '';
|
||||
containerEl.classList.add('zone-devices-panel');
|
||||
listEl = document.createElement('div');
|
||||
listEl.className = 'zone-devices-list';
|
||||
addSlot = document.createElement('div');
|
||||
addSlot.className = 'zone-devices-add-slot';
|
||||
containerEl.appendChild(listEl);
|
||||
containerEl.appendChild(addSlot);
|
||||
} else {
|
||||
listEl.innerHTML = '';
|
||||
addSlot.innerHTML = '';
|
||||
}
|
||||
return { listEl, addSlot };
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.prepareZoneDevicesPanel = prepareZoneDevicesPanel;
|
||||
}
|
||||
@@ -115,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 });
|
||||
}
|
||||
@@ -414,7 +414,14 @@ function rowsToNames(rows) {
|
||||
|
||||
function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
||||
if (!containerEl) return;
|
||||
const panel =
|
||||
typeof window.prepareZoneDevicesPanel === "function"
|
||||
? window.prepareZoneDevicesPanel(containerEl)
|
||||
: null;
|
||||
const listEl = panel ? panel.listEl : containerEl;
|
||||
if (!panel) {
|
||||
containerEl.innerHTML = "";
|
||||
}
|
||||
const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
@@ -441,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)));
|
||||
@@ -470,8 +477,12 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) {
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
if (panel) {
|
||||
panel.addSlot.appendChild(addWrap);
|
||||
} else {
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */
|
||||
function parseTabDeviceNames(section) {
|
||||
@@ -898,116 +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 payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
const gids = Array.isArray(tabData.group_ids)
|
||||
? tabData.group_ids.map((g) => String(g).trim()).filter((g) => g.length > 0)
|
||||
: [];
|
||||
if (gids.length > 0) {
|
||||
payload.group_ids = gids;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1380,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 () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -171,7 +167,12 @@
|
||||
<div class="modal-content">
|
||||
<h2>Devices</h2>
|
||||
<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 +608,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 +623,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="form-group">
|
||||
<label>Flash on beat</label>
|
||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></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="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 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>
|
||||
<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>
|
||||
<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>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 class="modal-content settings-modal-content">
|
||||
<h2>Settings</h2>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Settings sections">
|
||||
<button type="button" class="settings-tab-btn active" role="tab" id="settings-tab-bridge" data-settings-tab="bridge" aria-selected="true" aria-controls="settings-panel-bridge">Bridge</button>
|
||||
<button type="button" class="settings-tab-btn" role="tab" id="settings-tab-led-tool" data-settings-tab="led-tool" aria-selected="false" aria-controls="settings-panel-led-tool">LED Tool</button>
|
||||
</div>
|
||||
|
||||
<div id="settings-message" class="message"></div>
|
||||
|
||||
<!-- Device Name -->
|
||||
<div id="settings-panel-bridge" class="settings-tab-panel active" data-settings-panel="bridge" role="tabpanel" aria-labelledby="settings-tab-bridge">
|
||||
<div class="settings-section">
|
||||
<h3>Device</h3>
|
||||
<form id="device-form">
|
||||
<div class="profiles-actions" style="align-items:center;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.5rem;">
|
||||
<span id="bridge-ws-status" class="muted-text" aria-live="polite"></span>
|
||||
</div>
|
||||
<ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
|
||||
|
||||
<h3 class="settings-subheading">USB serial</h3>
|
||||
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses Wi‑Fi radio for ESP-NOW only.</p>
|
||||
<div class="form-group">
|
||||
<label for="device-name-input">Device Name</label>
|
||||
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||
<label for="bridge-serial-label">Profile label</label>
|
||||
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
|
||||
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
|
||||
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value everywhere.</small>
|
||||
<label for="bridge-serial-port">USB serial port</label>
|
||||
<select id="bridge-serial-port">
|
||||
<option value="">— select port —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-refresh-btn" style="margin-top:0.5rem;">Refresh ports</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- WiFi Access Point Settings -->
|
||||
<div class="settings-section ap-settings-section">
|
||||
<h3>WiFi Access Point</h3>
|
||||
|
||||
<div id="ap-status" class="status-info">
|
||||
<h4>AP Status</h4>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
|
||||
<form id="ap-form">
|
||||
<div class="form-group">
|
||||
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||
<small>The name of the WiFi access point this device creates</small>
|
||||
<label for="bridge-serial-baud">Baud rate</label>
|
||||
<input type="number" id="bridge-serial-baud" value="115200" min="9600" max="3000000" step="1">
|
||||
</div>
|
||||
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1.25rem;">
|
||||
<button type="button" class="btn btn-primary btn-small" id="bridge-serial-connect-btn">Connect serial</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-serial-save-profile-btn">Save serial profile</button>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-subheading">Wi‑Fi</h3>
|
||||
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://<bridge-ip>/ws</code>.</p>
|
||||
<div class="form-group">
|
||||
<label for="ap-password">AP Password</label>
|
||||
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||
<label for="bridge-wifi-interface">Wi‑Fi adapter</label>
|
||||
<select id="bridge-wifi-interface">
|
||||
<option value="">— select adapter —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-refresh-interfaces-btn" style="margin-top:0.5rem;">Refresh adapters</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ap-channel">Channel (1-11)</label>
|
||||
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||
<label for="bridge-wifi-ssid">Bridge SSID</label>
|
||||
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<select id="bridge-wifi-ssid" style="flex:1;min-width:12rem;">
|
||||
<option value="">— scan or type below —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-scan-btn">Scan</button>
|
||||
</div>
|
||||
<input type="text" id="bridge-wifi-ssid-manual" placeholder="Or type SSID" autocomplete="off" style="margin-top:0.5rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bridge-wifi-password">Password</label>
|
||||
<input type="password" id="bridge-wifi-password" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="bridge-wifi-label">Profile label</label>
|
||||
<input type="text" id="bridge-wifi-label" placeholder="e.g. Garden bridge" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:8rem;">
|
||||
<label for="bridge-wifi-ap-ip">Bridge IP</label>
|
||||
<input type="text" id="bridge-wifi-ap-ip" value="192.168.4.1" autocomplete="off">
|
||||
</div>
|
||||
<div style="flex:0 0 6rem;">
|
||||
<label for="bridge-wifi-ws-port">WS port</label>
|
||||
<input type="number" id="bridge-wifi-ws-port" value="80" min="1" max="65535" step="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="profiles-actions" style="gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
|
||||
<button type="button" class="btn btn-primary btn-small" id="bridge-wifi-connect-btn">Connect Wi‑Fi</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" id="bridge-wifi-save-profile-btn">Save Wi‑Fi profile</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||
<h3 class="settings-subheading">Saved profiles</h3>
|
||||
<ul id="bridge-profiles-list" class="settings-bridge-profiles muted-text"></ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="settings-panel-led-tool" class="settings-tab-panel" data-settings-panel="led-tool" role="tabpanel" aria-labelledby="settings-tab-led-tool" hidden>
|
||||
<p class="muted-text settings-led-tool-intro">USB serial setup for drivers and bridges: device settings, deploy, and firmware.</p>
|
||||
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -762,22 +771,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>
|
||||
|
||||
@@ -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 (1–11) for LED drivers and the serial bridge. Use the same value on every device.</small>
|
||||
<small>2.4 GHz channel (1–11) for ESP-NOW drivers and the bridge AP/STA. Set the same <code>wifi_channel</code> on the bridge and each led-driver; those devices need a reboot after a change. Saving here updates the Pi setting (restart led-controller to apply).</small>
|
||||
</div>
|
||||
<div 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 (1–11). Stored for reference; set <code>wifi_channel</code> on the bridge itself and reboot it to apply.</small>
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -423,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,
|
||||
@@ -594,11 +604,11 @@ async def _deliver_select(
|
||||
group_ids: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
from models.device import Device
|
||||
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()
|
||||
gids = [str(g).strip() for g in (group_ids or []) if str(g).strip()]
|
||||
@@ -607,7 +617,7 @@ async def _deliver_select(
|
||||
body["groups"] = gids
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
try:
|
||||
await deliver_json_messages(sender, [msg], None, 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}")
|
||||
|
||||
|
||||
201
src/util/bridge_for_group.py
Normal file
201
src/util/bridge_for_group.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Resolve and connect the bridge assigned to device groups."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional, Set, Any
|
||||
|
||||
from models.group import Group
|
||||
from settings import get_settings
|
||||
from util.bridge_profiles import find_bridge_profile
|
||||
from util.bridge_runtime import connect_bridge_profile
|
||||
from util.espnow_registry import push_groups_for_group_devices
|
||||
|
||||
|
||||
def _normalize_bridge_id(raw: object) -> Optional[str]:
|
||||
bid = str(raw or "").strip()
|
||||
return bid if bid else None
|
||||
|
||||
|
||||
def bridge_id_for_group_doc(gdoc: dict) -> Optional[str]:
|
||||
if not isinstance(gdoc, dict):
|
||||
return None
|
||||
return _normalize_bridge_id(gdoc.get("bridge_id"))
|
||||
|
||||
|
||||
def _bridge_ids_for_group_docs(docs: list) -> Set[Optional[str]]:
|
||||
ids: Set[Optional[str]] = set()
|
||||
for doc in docs:
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
ids.add(bridge_id_for_group_doc(doc))
|
||||
return ids
|
||||
|
||||
|
||||
def bridge_id_for_group_id(group_id: str) -> Optional[str]:
|
||||
gid = str(group_id or "").strip()
|
||||
if not gid:
|
||||
return None
|
||||
gdoc = Group().read(gid)
|
||||
if not gdoc:
|
||||
return None
|
||||
return bridge_id_for_group_doc(gdoc)
|
||||
|
||||
|
||||
def build_group_to_bridge_map(group_ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Map group id -> bridge profile id (``None`` = default / current connection)."""
|
||||
groups = Group()
|
||||
out: Dict[str, Optional[str]] = {}
|
||||
for gid in group_ids:
|
||||
s = str(gid).strip()
|
||||
if not s or s in out:
|
||||
continue
|
||||
gdoc = groups.read(s)
|
||||
out[s] = bridge_id_for_group_doc(gdoc) if gdoc else None
|
||||
return out
|
||||
|
||||
|
||||
def bridge_ids_for_group_ids(group_ids: List[str]) -> Set[Optional[str]]:
|
||||
if not group_ids:
|
||||
return set()
|
||||
return set(build_group_to_bridge_map(group_ids).values())
|
||||
|
||||
|
||||
def ordered_bridge_ids(bridge_ids: Set[Optional[str]]) -> List[Optional[str]]:
|
||||
"""Stable order: default bridge first, then profile ids sorted."""
|
||||
if not bridge_ids:
|
||||
return []
|
||||
rest = sorted(b for b in bridge_ids if b)
|
||||
if None in bridge_ids:
|
||||
return [None, *rest]
|
||||
return rest
|
||||
|
||||
|
||||
def bridges_needed_for_body(
|
||||
body: dict, group_to_bridge: Dict[str, Optional[str]]
|
||||
) -> Set[Optional[str]]:
|
||||
"""Which bridge(s) must receive this v1 body (by ``groups`` / ``g``)."""
|
||||
if not isinstance(body, dict):
|
||||
return {None}
|
||||
g = body.get("groups") or body.get("g")
|
||||
if not isinstance(g, list) or not g:
|
||||
return {None}
|
||||
needed: Set[Optional[str]] = set()
|
||||
for item in g:
|
||||
gid = str(item).strip()
|
||||
if gid:
|
||||
needed.add(group_to_bridge.get(gid))
|
||||
return needed if needed else {None}
|
||||
|
||||
|
||||
async def ensure_bridge_for_bridge_id(bridge_id: Optional[str]) -> tuple[bool, Optional[str]]:
|
||||
if not bridge_id or not str(bridge_id).strip():
|
||||
return True, None
|
||||
settings = get_settings()
|
||||
profile = find_bridge_profile(settings, bridge_id)
|
||||
if not profile:
|
||||
return False, f"Unknown bridge profile {bridge_id!r}"
|
||||
ok, err = await connect_bridge_profile(profile, settings)
|
||||
if not ok:
|
||||
return False, err or "Bridge connect failed"
|
||||
return True, None
|
||||
|
||||
|
||||
async def ensure_bridges_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
|
||||
"""Join each distinct bridge used by these groups (sequential; last stays active)."""
|
||||
bridge_ids = bridge_ids_for_group_ids(group_ids)
|
||||
for bid in ordered_bridge_ids(bridge_ids):
|
||||
ok, err = await ensure_bridge_for_bridge_id(bid)
|
||||
if not ok:
|
||||
return False, err
|
||||
return True, None
|
||||
|
||||
|
||||
async def ensure_bridge_for_group_ids(group_ids: List[str]) -> tuple[bool, Optional[str]]:
|
||||
"""Connect to every bridge referenced by these groups."""
|
||||
if not group_ids:
|
||||
return True, None
|
||||
return await ensure_bridges_for_group_ids(group_ids)
|
||||
|
||||
|
||||
async def ensure_bridge_for_group_doc(gdoc: dict) -> tuple[bool, Optional[str]]:
|
||||
if not isinstance(gdoc, dict):
|
||||
return True, None
|
||||
bid = bridge_id_for_group_doc(gdoc)
|
||||
if not bid:
|
||||
return True, None
|
||||
return await ensure_bridge_for_bridge_id(bid)
|
||||
|
||||
|
||||
def count_groups_by_bridge() -> Dict[str, int]:
|
||||
"""Map bridge profile id -> number of groups assigned."""
|
||||
counts: Dict[str, int] = {}
|
||||
groups = Group()
|
||||
for _gid, doc in groups.items():
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
bid = bridge_id_for_group_doc(doc)
|
||||
if bid:
|
||||
counts[bid] = counts.get(bid, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def groups_for_bridge_assignment(bridge_id: str) -> List[Dict[str, Any]]:
|
||||
"""All groups with ``assigned`` flag for bridge profile ``bridge_id``."""
|
||||
bid = str(bridge_id or "").strip()
|
||||
if not bid:
|
||||
return []
|
||||
groups = Group()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for gid, doc in groups.items():
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
gbid = bridge_id_for_group_doc(doc)
|
||||
devs = doc.get("devices") if isinstance(doc.get("devices"), list) else []
|
||||
out.append(
|
||||
{
|
||||
"id": str(gid),
|
||||
"name": str(doc.get("name") or gid),
|
||||
"assigned": gbid == bid,
|
||||
"bridge_id": gbid,
|
||||
"device_count": len(devs),
|
||||
}
|
||||
)
|
||||
out.sort(key=lambda row: str(row.get("name") or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
async def assign_groups_to_bridge(
|
||||
bridge_id: str, group_ids: List[str]
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Set ``bridge_id`` on listed groups; clear it on others that used this bridge."""
|
||||
bid = str(bridge_id or "").strip()
|
||||
if not bid:
|
||||
return False, "bridge_id required"
|
||||
settings = get_settings()
|
||||
if not find_bridge_profile(settings, bid):
|
||||
return False, f"Unknown bridge profile {bid!r}"
|
||||
want = {str(g).strip() for g in group_ids if str(g).strip()}
|
||||
groups = Group()
|
||||
for gid in want:
|
||||
if str(gid) not in groups or not isinstance(groups.read(str(gid)), dict):
|
||||
return False, f"Unknown group id {gid!r}"
|
||||
changed: List[dict] = []
|
||||
for gid, doc in list(groups.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
gsid = str(gid)
|
||||
current = bridge_id_for_group_doc(doc)
|
||||
if gsid in want:
|
||||
if current != bid:
|
||||
groups.update(gsid, {"bridge_id": bid})
|
||||
g = groups.read(gsid)
|
||||
if g:
|
||||
changed.append(g)
|
||||
elif current == bid:
|
||||
groups.update(gsid, {"bridge_id": None})
|
||||
g = groups.read(gsid)
|
||||
if g:
|
||||
changed.append(g)
|
||||
for gdoc in changed:
|
||||
await push_groups_for_group_devices(gdoc)
|
||||
return True, None
|
||||
67
src/util/bridge_profiles.py
Normal file
67
src/util/bridge_profiles.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Saved ESP-NOW bridge profiles from settings.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def normalise_bridges(raw: Any) -> List[Dict[str, Any]]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
bid = str(item.get("id") or "").strip() or uuid.uuid4().hex[:12]
|
||||
label = str(item.get("label") or "").strip()
|
||||
transport = str(item.get("transport") or "serial").strip().lower()
|
||||
if transport == "wifi":
|
||||
ssid = str(item.get("ssid") or "").strip()
|
||||
if not ssid:
|
||||
continue
|
||||
try:
|
||||
port = int(item.get("ws_port") or 80)
|
||||
except (TypeError, ValueError):
|
||||
port = 80
|
||||
out.append(
|
||||
{
|
||||
"id": bid,
|
||||
"label": label or ssid,
|
||||
"transport": "wifi",
|
||||
"ssid": ssid,
|
||||
"password": str(item.get("password") or ""),
|
||||
"ap_ip": str(item.get("ap_ip") or "192.168.4.1").strip(),
|
||||
"ws_port": port,
|
||||
}
|
||||
)
|
||||
continue
|
||||
serial_port = str(item.get("serial_port") or "").strip()
|
||||
if not serial_port:
|
||||
continue
|
||||
try:
|
||||
baud = int(item.get("serial_baudrate") or 921600)
|
||||
except (TypeError, ValueError):
|
||||
baud = 921600
|
||||
out.append(
|
||||
{
|
||||
"id": bid,
|
||||
"label": label or serial_port,
|
||||
"transport": "serial",
|
||||
"serial_port": serial_port,
|
||||
"serial_baudrate": baud,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def find_bridge_profile(settings: Any, bridge_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not bridge_id:
|
||||
return None
|
||||
bid = str(bridge_id).strip()
|
||||
if not bid:
|
||||
return None
|
||||
for profile in normalise_bridges(settings.get("bridges")):
|
||||
if profile.get("id") == bid:
|
||||
return profile
|
||||
return None
|
||||
233
src/util/bridge_runtime.py
Normal file
233
src/util/bridge_runtime.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""Start or refresh the bridge client after Wi‑Fi or USB serial connect."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from models.bridge_serial_client import get_bridge_serial_client, init_bridge_serial_client
|
||||
from models.bridge_ws_client import get_bridge_client, init_bridge_client
|
||||
from models.transport import BridgeSerialTransport, BridgeWsTransport, get_current_bridge, set_bridge
|
||||
from settings import WIFI_CHANNEL_DEFAULT
|
||||
from util.bridge_profiles import normalise_bridges
|
||||
from util.pi_wifi import (
|
||||
build_bridge_ws_url,
|
||||
connect_wifi,
|
||||
nmcli_available,
|
||||
ssid_visible,
|
||||
wait_for_device,
|
||||
)
|
||||
|
||||
UplinkHandler = Callable[..., Awaitable[None]]
|
||||
|
||||
_uplink_handler: Optional[UplinkHandler] = None
|
||||
|
||||
|
||||
def set_bridge_uplink_handler(handler: Optional[UplinkHandler]) -> None:
|
||||
global _uplink_handler
|
||||
_uplink_handler = handler
|
||||
|
||||
|
||||
def _bridge_transport_mode(settings) -> str:
|
||||
mode = str(settings.get("bridge_transport") or "wifi").strip().lower()
|
||||
return mode if mode in ("wifi", "serial") else "wifi"
|
||||
|
||||
|
||||
def bridge_ws_connected() -> bool:
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
return False
|
||||
return client._connected.is_set()
|
||||
|
||||
|
||||
def bridge_serial_connected() -> bool:
|
||||
client = get_bridge_serial_client()
|
||||
if client is None:
|
||||
return False
|
||||
return client._connected.is_set()
|
||||
|
||||
|
||||
def stop_bridge_ws_client() -> None:
|
||||
client = get_bridge_client()
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
|
||||
def stop_bridge_serial_client() -> None:
|
||||
client = get_bridge_serial_client()
|
||||
if client is not None:
|
||||
client.stop()
|
||||
|
||||
|
||||
def bridge_connected() -> bool:
|
||||
from settings import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if _bridge_transport_mode(settings) == "serial":
|
||||
return bridge_serial_connected()
|
||||
return bridge_ws_connected()
|
||||
|
||||
|
||||
def active_bridge_profile_id(settings) -> Optional[str]:
|
||||
"""Saved profile id matching the current transport connection, if any."""
|
||||
if not bridge_connected():
|
||||
return None
|
||||
mode = _bridge_transport_mode(settings)
|
||||
from util.pi_wifi import build_bridge_ws_url
|
||||
|
||||
for profile in normalise_bridges(settings.get("bridges")):
|
||||
pid = str(profile.get("id") or "").strip()
|
||||
if not pid:
|
||||
continue
|
||||
if mode == "serial" and profile.get("transport") == "serial":
|
||||
if str(profile.get("serial_port") or "") == str(
|
||||
settings.get("bridge_serial_port") or ""
|
||||
).strip():
|
||||
return pid
|
||||
if mode == "wifi" and profile.get("transport") == "wifi":
|
||||
try:
|
||||
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
|
||||
except ValueError:
|
||||
continue
|
||||
if url == str(settings.get("bridge_ws_url") or "").strip():
|
||||
return pid
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_bridge_client(
|
||||
url: str,
|
||||
*,
|
||||
wifi_channel: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Ensure ``BridgeWsTransport`` is active and pointed at ``url``."""
|
||||
stop_bridge_serial_client()
|
||||
url = str(url or "").strip()
|
||||
if not url:
|
||||
return False
|
||||
ch = wifi_channel if wifi_channel is not None else WIFI_CHANNEL_DEFAULT
|
||||
client = get_bridge_client()
|
||||
if client is None:
|
||||
client = init_bridge_client(url, wifi_channel=ch)
|
||||
if _uplink_handler is not None:
|
||||
client.set_uplink_handler(_uplink_handler)
|
||||
client.start()
|
||||
else:
|
||||
if client._url != url:
|
||||
client._url = url
|
||||
client._wifi_channel = ch
|
||||
if _uplink_handler is not None:
|
||||
client.set_uplink_handler(_uplink_handler)
|
||||
client._signal_disconnect()
|
||||
current = get_current_bridge()
|
||||
if current is None or not hasattr(current, "send_envelope"):
|
||||
set_bridge(BridgeWsTransport())
|
||||
return await client.wait_connected(timeout=30.0)
|
||||
|
||||
|
||||
async def ensure_bridge_serial_client(
|
||||
port: str,
|
||||
*,
|
||||
baudrate: int = 921600,
|
||||
) -> bool:
|
||||
"""Ensure ``BridgeSerialTransport`` is active on ``port``."""
|
||||
stop_bridge_ws_client()
|
||||
port = str(port or "").strip()
|
||||
if not port:
|
||||
return False
|
||||
baud = int(baudrate)
|
||||
client = get_bridge_serial_client()
|
||||
if client is None:
|
||||
client = init_bridge_serial_client(port, baudrate=baud)
|
||||
if _uplink_handler is not None:
|
||||
client.set_uplink_handler(_uplink_handler)
|
||||
client.start()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
return await client.wait_connected(timeout=20.0)
|
||||
if client._port != port or client._baudrate != baud:
|
||||
client.stop()
|
||||
client = init_bridge_serial_client(port, baudrate=baud)
|
||||
if _uplink_handler is not None:
|
||||
client.set_uplink_handler(_uplink_handler)
|
||||
client.start()
|
||||
elif _uplink_handler is not None:
|
||||
client.set_uplink_handler(_uplink_handler)
|
||||
client._signal_disconnect()
|
||||
set_bridge(BridgeSerialTransport())
|
||||
return await client.wait_connected(timeout=20.0)
|
||||
|
||||
|
||||
async def connect_bridge_serial(profile: dict, settings) -> tuple[bool, str]:
|
||||
"""Open USB/serial to the bridge and switch transport to serial."""
|
||||
if not isinstance(profile, dict):
|
||||
return False, "Invalid bridge profile"
|
||||
port = str(profile.get("serial_port") or settings.get("bridge_serial_port") or "").strip()
|
||||
if not port:
|
||||
return False, "Serial port not configured"
|
||||
try:
|
||||
baud = int(profile.get("serial_baudrate") or settings.get("bridge_serial_baudrate") or 921600)
|
||||
except (TypeError, ValueError):
|
||||
baud = 921600
|
||||
settings["bridge_transport"] = "serial"
|
||||
settings["bridge_serial_port"] = port
|
||||
settings["bridge_serial_baudrate"] = baud
|
||||
settings.save()
|
||||
stop_bridge_ws_client()
|
||||
if not await ensure_bridge_serial_client(port, baudrate=baud):
|
||||
return False, f"Serial bridge not connected ({port})"
|
||||
return True, ""
|
||||
|
||||
|
||||
async def connect_bridge_wifi(profile: dict, settings) -> tuple[bool, str]:
|
||||
"""Join bridge AP and open WebSocket to ``profile``."""
|
||||
if not isinstance(profile, dict):
|
||||
return False, "Invalid bridge profile"
|
||||
ssid = str(profile.get("ssid") or "").strip()
|
||||
if not ssid:
|
||||
return False, "Bridge SSID not configured"
|
||||
device = str(profile.get("wifi_interface") or settings.get("wifi_interface") or "").strip()
|
||||
if not device:
|
||||
return False, "Wi‑Fi interface not configured (Settings → Bridge Wi‑Fi)"
|
||||
if not nmcli_available():
|
||||
return False, "nmcli not found (install NetworkManager)"
|
||||
try:
|
||||
if not await ssid_visible(device, ssid):
|
||||
return (
|
||||
False,
|
||||
f"SSID {ssid!r} not visible on {device} — power on the bridge and scan in Settings",
|
||||
)
|
||||
await connect_wifi(
|
||||
device=device,
|
||||
ssid=ssid,
|
||||
password=str(profile.get("password") or ""),
|
||||
)
|
||||
await wait_for_device(device)
|
||||
except Exception as e:
|
||||
err = str(e).strip()
|
||||
if err.startswith("Error:"):
|
||||
err = err[6:].strip()
|
||||
return False, err or "Wi‑Fi connect failed"
|
||||
try:
|
||||
url = build_bridge_ws_url(profile.get("ap_ip"), profile.get("ws_port") or 80)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
try:
|
||||
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
|
||||
except (TypeError, ValueError):
|
||||
ch = WIFI_CHANNEL_DEFAULT
|
||||
settings["bridge_transport"] = "wifi"
|
||||
settings["bridge_ws_url"] = url
|
||||
settings["wifi_interface"] = device
|
||||
settings.save()
|
||||
stop_bridge_serial_client()
|
||||
if not await ensure_bridge_client(url, wifi_channel=ch):
|
||||
return False, f"WebSocket bridge not connected ({url})"
|
||||
return True, ""
|
||||
|
||||
|
||||
async def connect_bridge_profile(profile: dict, settings) -> tuple[bool, str]:
|
||||
"""Connect using a saved bridge profile (serial or wifi)."""
|
||||
if not isinstance(profile, dict):
|
||||
return False, "Invalid bridge profile"
|
||||
transport = str(profile.get("transport") or "serial").strip().lower()
|
||||
if transport == "wifi":
|
||||
return await connect_bridge_wifi(profile, settings)
|
||||
return await connect_bridge_serial(profile, settings)
|
||||
38
src/util/bridge_serial_frame.py
Normal file
38
src/util/bridge_serial_frame.py
Normal 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
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
|
||||
from util.bridge_envelope import (
|
||||
BROADCAST_MAC,
|
||||
@@ -12,15 +12,9 @@ from util.bridge_envelope import (
|
||||
split_v1_body_for_espnow,
|
||||
)
|
||||
from util.espnow_message import build_message
|
||||
from util.espnow_wire import WIRE_MAGIC, pack_group_cmd
|
||||
|
||||
_MAX_JSON_ESPNOW = 240
|
||||
|
||||
|
||||
def v1_message_bytes(body: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(body, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
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:
|
||||
@@ -44,17 +38,7 @@ def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Opt
|
||||
return None
|
||||
|
||||
|
||||
async def deliver_envelope(sender, envelope: Dict[str, Any], delay_s: float = 0.1) -> int:
|
||||
if not envelope or not isinstance(envelope.get("devices"), dict):
|
||||
return 0
|
||||
if await sender.send(envelope):
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
|
||||
async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
|
||||
deliveries = 0
|
||||
try:
|
||||
chunks = split_v1_body_for_espnow(body)
|
||||
@@ -62,76 +46,13 @@ async def _deliver_v1_body(sender, mac_key: str, body: Dict[str, Any], delay_s:
|
||||
return 0
|
||||
for chunk in chunks:
|
||||
env = build_devices_envelope({mac_key: chunk})
|
||||
if await sender.send(env):
|
||||
if await bridge.send(env):
|
||||
deliveries += 1
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_packets(
|
||||
sender,
|
||||
packets: List[bytes],
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
target_macs: Optional[List[str]] = None,
|
||||
unicast: bool = False,
|
||||
) -> int:
|
||||
if not packets:
|
||||
return 0
|
||||
deliveries = 0
|
||||
mac_keys = _unicast_mac_keys(target_macs) if unicast and target_macs else [BROADCAST_MAC]
|
||||
for mac_key in mac_keys:
|
||||
for pkt in packets:
|
||||
body = _body_from_message(pkt)
|
||||
if body:
|
||||
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
|
||||
else:
|
||||
if await sender.send(pkt):
|
||||
deliveries += 1
|
||||
if delay_s > 0:
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
async def deliver_binary_packets(
|
||||
sender,
|
||||
packets: List[bytes],
|
||||
target_macs: Optional[List[str]] = None,
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
unicast: bool = False,
|
||||
) -> int:
|
||||
return await deliver_packets(
|
||||
sender, packets, delay_s=delay_s, target_macs=target_macs, unicast=unicast
|
||||
)
|
||||
|
||||
|
||||
async def deliver_group_binary_packets(
|
||||
sender,
|
||||
group_id: str,
|
||||
packets: List[bytes],
|
||||
*,
|
||||
delay_s: float = 0.1,
|
||||
) -> int:
|
||||
"""Broadcast GROUP_CMD wire packets (legacy binary passthrough on bridge)."""
|
||||
from util.espnow_wire import parse_cmd
|
||||
|
||||
deliveries = 0
|
||||
for pkt in packets:
|
||||
env, save = parse_cmd(pkt)
|
||||
if env is None:
|
||||
continue
|
||||
try:
|
||||
g_pkt = pack_group_cmd(str(group_id), env, save=save)
|
||||
except ValueError:
|
||||
continue
|
||||
if await sender.send(g_pkt):
|
||||
deliveries += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return deliveries
|
||||
|
||||
|
||||
def build_preset_json_chunks(
|
||||
presets_by_name: Dict[str, Any],
|
||||
*,
|
||||
@@ -174,29 +95,6 @@ def build_preset_json_chunks(
|
||||
return [c for c in chunks if c]
|
||||
|
||||
|
||||
async def deliver_preset_broadcast_then_per_device(
|
||||
sender,
|
||||
chunk_messages,
|
||||
target_macs,
|
||||
devices_model,
|
||||
default_id,
|
||||
delay_s=0.1,
|
||||
):
|
||||
del devices_model, target_macs
|
||||
deliveries = 0
|
||||
for msg in chunk_messages:
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
|
||||
|
||||
if default_id:
|
||||
body = {"default": str(default_id), "save": True}
|
||||
deliveries += await _deliver_v1_body(sender, BROADCAST_MAC, body, delay_s)
|
||||
|
||||
return deliveries
|
||||
|
||||
|
||||
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
|
||||
"""One formatted MAC per target; empty list means broadcast."""
|
||||
if not target_macs:
|
||||
@@ -212,7 +110,7 @@ def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
|
||||
|
||||
|
||||
async def deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
messages,
|
||||
target_macs,
|
||||
devices_model,
|
||||
@@ -224,17 +122,27 @@ async def deliver_json_messages(
|
||||
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
|
||||
deliveries = 0
|
||||
from models.transport import get_current_bridge
|
||||
|
||||
active = get_current_bridge() or bridge
|
||||
if active is None:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
if unicast and target_macs:
|
||||
mac_keys = _unicast_mac_keys(target_macs)
|
||||
else:
|
||||
mac_keys = [BROADCAST_MAC]
|
||||
|
||||
deliveries = 0
|
||||
for mac_key in mac_keys:
|
||||
for msg in messages:
|
||||
body = _body_from_message(msg)
|
||||
if not body:
|
||||
continue
|
||||
deliveries += await _deliver_v1_body(sender, mac_key, body, delay_s)
|
||||
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s)
|
||||
|
||||
return deliveries, len(messages)
|
||||
|
||||
@@ -55,24 +55,6 @@ def build_message(presets=None, select=None, save=False, default=None):
|
||||
return json.dumps(message)
|
||||
|
||||
|
||||
def build_select_list(preset_name, step=None):
|
||||
"""
|
||||
Build a select list for one driver (unicast / per-MAC envelope).
|
||||
|
||||
Wire shape: ``["preset_id"]`` or ``["preset_id", step]`` — no device name.
|
||||
"""
|
||||
select_list = [str(preset_name)]
|
||||
if step is not None:
|
||||
select_list.append(step)
|
||||
return select_list
|
||||
|
||||
|
||||
def build_select_message(device_name, preset_name, step=None):
|
||||
"""Legacy name-map select; prefer :func:`build_select_list` for ESP-NOW."""
|
||||
del device_name
|
||||
return build_select_list(preset_name, step=step)
|
||||
|
||||
|
||||
def _hex_from_background_raw(bg_raw):
|
||||
"""Coerce ``background`` / ``bg`` field to a ``#RRGGBB`` string (driver wire format)."""
|
||||
if isinstance(bg_raw, str):
|
||||
@@ -233,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
86
src/util/espnow_ping.py
Normal 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,
|
||||
}
|
||||
@@ -7,10 +7,12 @@ 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_sender
|
||||
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,
|
||||
@@ -24,8 +26,11 @@ async def handle_bridge_uplink(peer_mac: bytes, payload: bytes) -> None:
|
||||
if not payload:
|
||||
return
|
||||
if payload[0] == WIRE_MAGIC:
|
||||
if wire_msg_type(payload) == MSG_ANNOUNCE:
|
||||
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:
|
||||
@@ -128,17 +133,47 @@ async def push_groups_broadcast() -> bool:
|
||||
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())
|
||||
sender = get_current_sender()
|
||||
if sender is None:
|
||||
bridge = get_current_bridge()
|
||||
if bridge is None:
|
||||
return False
|
||||
envelope = build_groups_envelope(mac, gids)
|
||||
ok = await sender.send(envelope)
|
||||
ok = await bridge.send(envelope)
|
||||
if ok:
|
||||
print(f"[espnow] groups sent mac={mac} groups={gids!r}")
|
||||
return bool(ok)
|
||||
|
||||
@@ -22,6 +22,8 @@ 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")
|
||||
@@ -238,6 +240,49 @@ def parse_group_cmd(payload: bytes) -> Optional[Tuple[str, bytes]]:
|
||||
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]))
|
||||
|
||||
229
src/util/pi_wifi.py
Normal file
229
src/util/pi_wifi.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Pi Wi‑Fi 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"
|
||||
331
src/util/pulse_audio_devices.py
Normal file
331
src/util/pulse_audio_devices.py
Normal 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
|
||||
@@ -422,45 +422,58 @@ 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)
|
||||
if not device_names:
|
||||
return
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
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(
|
||||
@@ -472,15 +485,128 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
|
||||
gids = [str(g).strip() for g in zg if str(g).strip()]
|
||||
wire = str(preset_id)
|
||||
auto = _coerce_auto(display_preset)
|
||||
delay_s = 0.05
|
||||
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
|
||||
if gids:
|
||||
body["groups"] = list(gids)
|
||||
if auto:
|
||||
body["select"] = [wire]
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], None, devices_model, delay_s=delay_s)
|
||||
return display_preset, device_names, gids, wire, auto
|
||||
|
||||
|
||||
_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:
|
||||
@@ -491,10 +617,33 @@ async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> 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
|
||||
|
||||
@@ -516,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:
|
||||
@@ -541,7 +690,7 @@ async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
|
||||
)
|
||||
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
|
||||
await deliver_json_messages(
|
||||
sender, [msg], [mac], devices_model, delay_s=0.05, unicast=True
|
||||
bridge, [msg], [mac], devices_model, delay_s=0.05, unicast=True
|
||||
)
|
||||
|
||||
|
||||
@@ -696,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,
|
||||
@@ -704,8 +853,8 @@ 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")
|
||||
|
||||
if not device_names and not gids:
|
||||
@@ -713,19 +862,31 @@ async def _send_lane(
|
||||
|
||||
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)
|
||||
await deliver_json_messages(sender, [msg], None, 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, group_ids=gids or None
|
||||
)
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
|
||||
|
||||
@@ -772,12 +933,6 @@ def _build_ctx(
|
||||
}
|
||||
|
||||
|
||||
def playback_active() -> bool:
|
||||
"""True while a zone sequence run is active (step timing owned by ``process_active_beat_advance``)."""
|
||||
with _beat_run_lock:
|
||||
return _beat_run is not None
|
||||
|
||||
|
||||
def playback_status() -> Dict[str, Any]:
|
||||
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
|
||||
with _beat_run_lock:
|
||||
@@ -891,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:
|
||||
@@ -910,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
|
||||
|
||||
@@ -919,8 +1076,8 @@ 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")
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
@@ -936,7 +1093,7 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
|
||||
if gids:
|
||||
body["groups"] = gids
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], None, devices, delay_s=0.05)
|
||||
await deliver_json_messages(bridge, [msg], None, devices, delay_s=0.05)
|
||||
|
||||
|
||||
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Envelope: devices map
|
||||
ENV_DEVICES = "dv"
|
||||
@@ -33,14 +33,6 @@ _BODY_LONG_TO_SHORT = {
|
||||
_BODY_SHORT_TO_LONG = {v: k for k, v in _BODY_LONG_TO_SHORT.items()}
|
||||
|
||||
|
||||
def wire_select_list(preset_id: Union[str, int], step: Optional[Union[int, str]] = None) -> List[Any]:
|
||||
"""Preset id (+ optional step) for ``select`` on unicast/broadcast to one driver."""
|
||||
out: List[Any] = [str(preset_id)]
|
||||
if step is not None:
|
||||
out.append(step)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_select_for_wire(select: Any) -> Any:
|
||||
"""Long or legacy shapes → wire list ``[preset_id, step?]``."""
|
||||
if isinstance(select, list):
|
||||
|
||||
16
tests/bridge_uart_sample.py
Normal file
16
tests/bridge_uart_sample.py
Normal 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)
|
||||
176
tests/test_audio_device_select.py
Normal file
176
tests/test_audio_device_select.py
Normal 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.")
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_unicast_mac_keys_per_device():
|
||||
def test_deliver_json_messages_defaults_broadcast():
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
class _Sender:
|
||||
class _Bridge:
|
||||
def __init__(self):
|
||||
self.keys = []
|
||||
|
||||
@@ -49,14 +49,14 @@ def test_deliver_json_messages_defaults_broadcast():
|
||||
return True
|
||||
|
||||
async def _run():
|
||||
sender = _Sender()
|
||||
bridge = _Bridge()
|
||||
await deliver_json_messages(
|
||||
sender,
|
||||
bridge,
|
||||
[json.dumps({"v": "1", "select": ["2"]})],
|
||||
["188b0e1560a8", "e8f60a16ea10"],
|
||||
None,
|
||||
)
|
||||
return sender.keys
|
||||
return bridge.keys
|
||||
|
||||
keys = __import__("asyncio").run(_run())
|
||||
assert keys == [BROADCAST_MAC]
|
||||
|
||||
48
tests/test_bridge_serial_frame.py
Normal file
48
tests/test_bridge_serial_frame.py
Normal 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]
|
||||
40
tests/test_bridge_wifi_connect.py
Normal file
40
tests/test_bridge_wifi_connect.py
Normal 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"
|
||||
@@ -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,21 +667,21 @@ 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"] == ["__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 len(bridge.sent) >= 2
|
||||
second = json.loads(bridge.sent[1][0])
|
||||
assert second.get("select") == ["off"]
|
||||
|
||||
resp = c.post(
|
||||
|
||||
45
tests/test_espnow_ping.py
Normal file
45
tests/test_espnow_ping.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""ESP-NOW ping session collection."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from util import espnow_ping # noqa: E402
|
||||
from util.espnow_wire import pack_ping_rsp, parse_ping_req # noqa: E402
|
||||
|
||||
|
||||
class _FakeBridge:
|
||||
def __init__(self):
|
||||
self.packets = []
|
||||
|
||||
async def send(self, data, addr=None):
|
||||
self.packets.append(data)
|
||||
return True
|
||||
|
||||
|
||||
def test_run_ping_collects_responses(monkeypatch):
|
||||
bridge = _FakeBridge()
|
||||
monkeypatch.setattr(espnow_ping, "get_current_bridge", lambda: bridge)
|
||||
|
||||
async def _run():
|
||||
async def _inject():
|
||||
await asyncio.sleep(0.05)
|
||||
assert len(bridge.packets) == 1
|
||||
ping_id = parse_ping_req(bridge.packets[0])
|
||||
peer = bytes.fromhex("aabbccddeeff")
|
||||
espnow_ping.record_ping_rsp(peer, pack_ping_rsp(ping_id, "led-1"))
|
||||
|
||||
task = asyncio.create_task(_inject())
|
||||
result = await espnow_ping.run_ping(timeout_s=0.2)
|
||||
await task
|
||||
return result
|
||||
|
||||
result = asyncio.run(_run())
|
||||
|
||||
assert result["ok"] is True
|
||||
assert "aabbccddeeff" in result["responses"]
|
||||
assert result["responses"]["aabbccddeeff"]["name"] == "led-1"
|
||||
assert bridge.packets[0][0:2] == bytes([0x4C, 0x05])
|
||||
@@ -13,6 +13,8 @@ from util.espnow_wire import ( # noqa: E402
|
||||
MSG_ANNOUNCE,
|
||||
MSG_CMD,
|
||||
MSG_GROUPS,
|
||||
MSG_PING_REQ,
|
||||
MSG_PING_RSP,
|
||||
WIRE_MAGIC,
|
||||
pack_announce,
|
||||
pack_bridge_channel,
|
||||
@@ -20,12 +22,16 @@ from util.espnow_wire import ( # noqa: E402
|
||||
pack_cmd_from_kwargs,
|
||||
pack_group_cmd_from_kwargs,
|
||||
pack_groups,
|
||||
pack_ping_req,
|
||||
pack_ping_rsp,
|
||||
pack_ws_downlink,
|
||||
pack_ws_uplink,
|
||||
parse_announce,
|
||||
parse_cmd_as_v1_dict,
|
||||
parse_group_cmd,
|
||||
parse_groups,
|
||||
parse_ping_req,
|
||||
parse_ping_rsp,
|
||||
parse_ws_frame,
|
||||
wire_msg_type,
|
||||
)
|
||||
@@ -104,6 +110,19 @@ def test_ws_frame_round_trip():
|
||||
assert bcast4
|
||||
|
||||
|
||||
def test_ping_round_trip():
|
||||
ping_id = 0xA1B2C3D4
|
||||
req = pack_ping_req(ping_id)
|
||||
assert wire_msg_type(req) == MSG_PING_REQ
|
||||
assert parse_ping_req(req) == ping_id
|
||||
rsp = pack_ping_rsp(ping_id, "led-test")
|
||||
assert wire_msg_type(rsp) == MSG_PING_RSP
|
||||
assert len(rsp) <= MAX_ESPNOW_PAYLOAD
|
||||
info = parse_ping_rsp(rsp)
|
||||
assert info["ping_id"] == ping_id
|
||||
assert info["name"] == "led-test"
|
||||
|
||||
|
||||
def test_bridge_channel():
|
||||
raw = pack_bridge_channel(6)
|
||||
assert len(raw) == 3
|
||||
|
||||
66
tests/test_pi_wifi_scan.py
Normal file
66
tests/test_pi_wifi_scan.py
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for nmcli Wi‑Fi scan parsing."""
|
||||
|
||||
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.pi_wifi import _unescape_nmcli # noqa: E402
|
||||
|
||||
|
||||
def test_unescape_nmcli():
|
||||
assert _unescape_nmcli("bridge\\:abc") == "bridge:abc"
|
||||
assert _unescape_nmcli("plain") == "plain"
|
||||
|
||||
|
||||
def test_interface_display_name_fallback(monkeypatch):
|
||||
from util import pi_wifi
|
||||
|
||||
monkeypatch.setattr(
|
||||
pi_wifi,
|
||||
"_interface_display_name",
|
||||
lambda device: "Test Wi‑Fi" if device == "wlan0" else device,
|
||||
)
|
||||
import subprocess
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
class _R:
|
||||
stdout = "wlan0:wifi:connected:HomeNet\neth0:ethernet:connected:\n"
|
||||
|
||||
return _R()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
ifaces = pi_wifi.list_wifi_interfaces()
|
||||
assert len(ifaces) == 1
|
||||
assert ifaces[0]["device"] == "wlan0"
|
||||
assert ifaces[0]["label"] == "Test Wi‑Fi"
|
||||
assert ifaces[0]["connection"] == "HomeNet"
|
||||
|
||||
|
||||
def test_scan_wifi_parses_terse_nmcli(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from util import pi_wifi
|
||||
|
||||
sample = "\n".join(
|
||||
[
|
||||
"bridge-588c81a2fc18:84:",
|
||||
"My Network:72:WPA2",
|
||||
":50:WPA2",
|
||||
"led:100:WPA2",
|
||||
]
|
||||
)
|
||||
|
||||
async def fake_run(*args, **kwargs):
|
||||
return 0, sample, ""
|
||||
|
||||
monkeypatch.setattr(pi_wifi, "_run_nmcli", fake_run)
|
||||
|
||||
networks = asyncio.run(pi_wifi.scan_wifi("wlan0"))
|
||||
ssids = [n["ssid"] for n in networks]
|
||||
assert ssids == ["led", "bridge-588c81a2fc18", "My Network"]
|
||||
assert networks[0]["signal"] == 100
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Deferred sequence start on beat / downbeat."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -86,3 +87,176 @@ def test_downbeat_start_counts_trigger_beat(monkeypatch):
|
||||
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
|
||||
sp.stop()
|
||||
|
||||
|
||||
def _prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log):
|
||||
import types
|
||||
|
||||
async def fake_deliver(_bridge, messages, _macs, _devices, **_kw):
|
||||
for raw in messages:
|
||||
deliver_log.append(json.loads(raw))
|
||||
return ([], 0)
|
||||
|
||||
def fake_clear(lane_index):
|
||||
route_log.append(("clear", lane_index))
|
||||
|
||||
monkeypatch.setattr(sp, "_resolve_lane_device_names", lambda _i, _c: ["dev-a"])
|
||||
monkeypatch.setattr(
|
||||
"util.driver_delivery.deliver_json_messages",
|
||||
fake_deliver,
|
||||
)
|
||||
fake_transport = types.ModuleType("models.transport")
|
||||
fake_transport.get_current_bridge = lambda: object()
|
||||
monkeypatch.setitem(sys.modules, "models.transport", fake_transport)
|
||||
monkeypatch.setattr(
|
||||
sp,
|
||||
"_reset_after_sequence_change",
|
||||
lambda: reset_log.extend(["route", "audio"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"util.beat_driver_route.clear_sequence_manual_lane_route",
|
||||
fake_clear,
|
||||
)
|
||||
|
||||
|
||||
def test_prime_all_lanes_delivery_order(monkeypatch):
|
||||
"""Sequence start: step-0 presets, select, rest presets, then beat/route reset."""
|
||||
deliver_log: list = []
|
||||
route_log: list = []
|
||||
reset_log: list = []
|
||||
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
||||
|
||||
ctx = {
|
||||
"lanes": [
|
||||
[
|
||||
{"preset_id": "p1", "beats": 2},
|
||||
{"preset_id": "p2", "beats": 2},
|
||||
]
|
||||
],
|
||||
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
||||
"num_lanes": 1,
|
||||
"sequence_doc": {
|
||||
"lanes": [
|
||||
[
|
||||
{"preset_id": "p1", "beats": 2},
|
||||
{"preset_id": "p2", "beats": 2},
|
||||
]
|
||||
],
|
||||
"group_ids": ["g1"],
|
||||
},
|
||||
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
||||
"presets_map": {
|
||||
"p1": {
|
||||
"pattern": "solid",
|
||||
"colors": ["#FF0000"],
|
||||
"auto": True,
|
||||
},
|
||||
"p2": {
|
||||
"pattern": "solid",
|
||||
"colors": ["#00FF00"],
|
||||
"auto": True,
|
||||
},
|
||||
},
|
||||
"devices": object(),
|
||||
"groups": object(),
|
||||
"settings": {},
|
||||
"palette_colors": [],
|
||||
}
|
||||
|
||||
asyncio.run(sp._prime_all_lanes(ctx))
|
||||
|
||||
assert len(deliver_log) == 3
|
||||
step0_msg = deliver_log[0]
|
||||
select_msg = deliver_log[1]
|
||||
rest_msg = deliver_log[2]
|
||||
assert set(step0_msg["presets"]) == {"p1"}
|
||||
assert select_msg["select"] == ["p1"]
|
||||
assert set(rest_msg["presets"]) == {"p2"}
|
||||
for body in deliver_log:
|
||||
assert not ("presets" in body and "select" in body)
|
||||
assert route_log == [("clear", 0)]
|
||||
assert reset_log == ["route", "audio"]
|
||||
assert ctx.get("_sequence_primed") is True
|
||||
|
||||
|
||||
def test_prime_all_lanes_single_preset(monkeypatch):
|
||||
"""One preset in the lane: step-0 presets, select, reset (no rest phase)."""
|
||||
deliver_log: list = []
|
||||
route_log: list = []
|
||||
reset_log: list = []
|
||||
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
||||
|
||||
ctx = {
|
||||
"lanes": [[{"preset_id": "p1", "beats": 2}]],
|
||||
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
|
||||
"num_lanes": 1,
|
||||
"sequence_doc": {
|
||||
"lanes": [[{"preset_id": "p1", "beats": 2}]],
|
||||
"group_ids": ["g1"],
|
||||
},
|
||||
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
||||
"presets_map": {
|
||||
"p1": {
|
||||
"pattern": "solid",
|
||||
"colors": ["#FF0000"],
|
||||
"auto": True,
|
||||
}
|
||||
},
|
||||
"devices": object(),
|
||||
"groups": object(),
|
||||
"settings": {},
|
||||
"palette_colors": [],
|
||||
}
|
||||
|
||||
asyncio.run(sp._prime_all_lanes(ctx))
|
||||
|
||||
assert len(deliver_log) == 2
|
||||
assert set(deliver_log[0]["presets"]) == {"p1"}
|
||||
assert deliver_log[1]["select"] == ["p1"]
|
||||
assert route_log == [("clear", 0)]
|
||||
assert reset_log == ["route", "audio"]
|
||||
|
||||
|
||||
def test_prime_all_lanes_merges_same_groups(monkeypatch):
|
||||
"""Lanes sharing group ids get one step-0 broadcast, not one per lane."""
|
||||
deliver_log: list = []
|
||||
route_log: list = []
|
||||
reset_log: list = []
|
||||
_prime_all_lanes_test_mocks(monkeypatch, deliver_log, route_log, reset_log)
|
||||
|
||||
ctx = {
|
||||
"lanes": [
|
||||
[{"preset_id": "p1", "beats": 2}],
|
||||
[{"preset_id": "p2", "beats": 2}],
|
||||
],
|
||||
"lane_states": [
|
||||
{"stepIdx": 0, "beatCount": 0, "done": False},
|
||||
{"stepIdx": 0, "beatCount": 0, "done": False},
|
||||
],
|
||||
"num_lanes": 2,
|
||||
"sequence_doc": {
|
||||
"lanes": [
|
||||
[{"preset_id": "p1", "beats": 2}],
|
||||
[{"preset_id": "p2", "beats": 2}],
|
||||
],
|
||||
"lanes_group_ids": [["g1"], ["g1"]],
|
||||
},
|
||||
"zone_doc": {"group_ids": ["g1"], "brightness": 200},
|
||||
"presets_map": {
|
||||
"p1": {"pattern": "solid", "colors": ["#FF0000"], "auto": True},
|
||||
"p2": {"pattern": "solid", "colors": ["#00FF00"], "auto": True},
|
||||
},
|
||||
"devices": object(),
|
||||
"groups": object(),
|
||||
"settings": {},
|
||||
"palette_colors": [],
|
||||
}
|
||||
|
||||
asyncio.run(sp._prime_all_lanes(ctx))
|
||||
|
||||
preset_msgs = [b for b in deliver_log if "presets" in b]
|
||||
select_msgs = [b for b in deliver_log if "select" in b]
|
||||
assert len(preset_msgs) == 1
|
||||
assert set(preset_msgs[0]["presets"]) == {"p1", "p2"}
|
||||
assert preset_msgs[0]["groups"] == ["g1"]
|
||||
assert len(select_msgs) == 2
|
||||
assert route_log == [("clear", 0), ("clear", 1)]
|
||||
|
||||
35
tests/test_update_groups.py
Normal file
35
tests/test_update_groups.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for pushing group membership to all ESP-NOW devices."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def test_push_groups_all_espnow_devices(monkeypatch):
|
||||
from util import espnow_registry
|
||||
|
||||
class _Devices:
|
||||
def items(self):
|
||||
return [
|
||||
("aabbccddeeff", {"transport": "espnow"}),
|
||||
("wifi-1", {"transport": "wifi", "address": "192.168.1.1"}),
|
||||
]
|
||||
|
||||
pushed = []
|
||||
|
||||
async def fake_push(mac):
|
||||
pushed.append(mac)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(espnow_registry, "Device", _Devices)
|
||||
monkeypatch.setattr(espnow_registry, "push_groups_to_mac", fake_push)
|
||||
|
||||
result = asyncio.run(espnow_registry.push_groups_all_espnow_devices())
|
||||
assert result["ok"] is True
|
||||
assert result["sent"] == 1
|
||||
assert result["total"] == 1
|
||||
assert pushed == ["aabbccddeeff"]
|
||||
Reference in New Issue
Block a user