diff --git a/Pipfile b/Pipfile index 6908fa0..438abba 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ name = "pypi" [packages] mpremote = "*" pyserial = "*" +pyserial-asyncio = "*" esptool = "*" pyjwt = "*" watchfiles = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 097b3dd..b1b8b6a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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": [ diff --git a/bridge-serial/README.md b/bridge-serial/README.md new file mode 100644 index 0000000..77b3f37 --- /dev/null +++ b/bridge-serial/README.md @@ -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`). diff --git a/bridge-serial/src/main.py b/bridge-serial/src/main.py new file mode 100644 index 0000000..7b797be --- /dev/null +++ b/bridge-serial/src/main.py @@ -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) diff --git a/bridge-serial/src/settings.py b/bridge-serial/src/settings.py new file mode 100644 index 0000000..9f06837 --- /dev/null +++ b/bridge-serial/src/settings.py @@ -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 diff --git a/bridge-wifi/README.md b/bridge-wifi/README.md new file mode 100644 index 0000000..cbf200f --- /dev/null +++ b/bridge-wifi/README.md @@ -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`. diff --git a/espnow-sender/lib/microdot/__init__.py b/bridge-wifi/lib/microdot/__init__.py similarity index 100% rename from espnow-sender/lib/microdot/__init__.py rename to bridge-wifi/lib/microdot/__init__.py diff --git a/espnow-sender/lib/microdot/helpers.py b/bridge-wifi/lib/microdot/helpers.py similarity index 100% rename from espnow-sender/lib/microdot/helpers.py rename to bridge-wifi/lib/microdot/helpers.py diff --git a/espnow-sender/lib/microdot/microdot.py b/bridge-wifi/lib/microdot/microdot.py similarity index 100% rename from espnow-sender/lib/microdot/microdot.py rename to bridge-wifi/lib/microdot/microdot.py diff --git a/espnow-sender/lib/microdot/session.py b/bridge-wifi/lib/microdot/session.py similarity index 100% rename from espnow-sender/lib/microdot/session.py rename to bridge-wifi/lib/microdot/session.py diff --git a/espnow-sender/lib/microdot/utemplate.py b/bridge-wifi/lib/microdot/utemplate.py similarity index 100% rename from espnow-sender/lib/microdot/utemplate.py rename to bridge-wifi/lib/microdot/utemplate.py diff --git a/espnow-sender/lib/microdot/websocket.py b/bridge-wifi/lib/microdot/websocket.py similarity index 100% rename from espnow-sender/lib/microdot/websocket.py rename to bridge-wifi/lib/microdot/websocket.py diff --git a/bridge-wifi/src/espnow_wire.py b/bridge-wifi/src/espnow_wire.py new file mode 100644 index 0000000..9893e77 --- /dev/null +++ b/bridge-wifi/src/espnow_wire.py @@ -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 diff --git a/bridge-wifi/src/main.py b/bridge-wifi/src/main.py new file mode 100644 index 0000000..970f57e --- /dev/null +++ b/bridge-wifi/src/main.py @@ -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()) diff --git a/espnow-sender/src/settings.py b/bridge-wifi/src/settings.py similarity index 52% rename from espnow-sender/src/settings.py rename to bridge-wifi/src/settings.py index b508776..5a931ec 100644 --- a/espnow-sender/src/settings.py +++ b/bridge-wifi/src/settings.py @@ -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 + was_on = sta.active() if not was_on: - try: - sta.active(True) - time.sleep_ms(50) - except Exception: - pass + sta.active(True) + time.sleep_ms(50) try: mac = ubinascii.hexlify(sta.config("mac")).decode().lower() except Exception: mac = "000000000000" if not was_on: - try: - sta.active(False) - except Exception: - pass + sta.active(False) 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 - - \ No newline at end of file diff --git a/bridge-wifi/src/wifi_ap.py b/bridge-wifi/src/wifi_ap.py new file mode 100644 index 0000000..4cb600b --- /dev/null +++ b/bridge-wifi/src/wifi_ap.py @@ -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)) diff --git a/db/group.json b/db/group.json index 7debd04..8d133f0 100644 --- a/db/group.json +++ b/db/group.json @@ -1 +1 @@ -{"6": {"name": "winter top-left", "devices": ["a0b100000001"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "7": {"name": "winter top-centre", "devices": ["a0b100000002"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "8": {"name": "winter top-right", "devices": ["a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "9": {"name": "winter bottom-left", "devices": ["a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "10": {"name": "winter bottom-centre", "devices": ["a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "11": {"name": "winter bottom-right", "devices": ["a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "12": {"name": "winter top row", "devices": ["a0b100000001", "a0b100000002", "a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "13": {"name": "winter bottom row", "devices": ["a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "14": {"name": "winter left column", "devices": ["a0b100000001", "a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "15": {"name": "winter centre column", "devices": ["a0b100000002", "a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "16": {"name": "winter right column", "devices": ["a0b100000003", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "17": {"name": "winter grid (all)", "devices": ["a0b100000001", "a0b100000002", "a0b100000003", "a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null}, "18": {"name": "test", "devices": ["e8f60a16dad0", "e8f60a1707c0"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ No newline at end of file +{"6": {"name": "winter top-left", "devices": ["a0b100000001"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "7": {"name": "winter top-centre", "devices": ["a0b100000002"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "8": {"name": "winter top-right", "devices": ["a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "9": {"name": "winter bottom-left", "devices": ["a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "10": {"name": "winter bottom-centre", "devices": ["a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "11": {"name": "winter bottom-right", "devices": ["a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "12": {"name": "winter top row", "devices": ["a0b100000001", "a0b100000002", "a0b100000003"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "13": {"name": "winter bottom row", "devices": ["a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "14": {"name": "winter left column", "devices": ["a0b100000001", "a0b100000004"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "15": {"name": "winter centre column", "devices": ["a0b100000002", "a0b100000005"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "16": {"name": "winter right column", "devices": ["a0b100000003", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "17": {"name": "winter grid (all)", "devices": ["a0b100000001", "a0b100000002", "a0b100000003", "a0b100000004", "a0b100000005", "a0b100000006"], "profile_id": "3", "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "E8F4FF"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "bridge_id": null}, "18": {"name": "test", "devices": ["588c81a200d8", "588c81a37458", "983daeac8d00", "983daeacb650", "983daead21ec", "a085e34efaec", "aabbccddeeff", "e4b323c15c20", "e4b323c53870", "e4b323c5411c", "e4b323c5ad6c", "e8f60a16dad0", "e8f60a16db34", "e8f60a16e0d8", "e8f60a16e57c", "e8f60a16e79c", "e8f60a16eba4", "e8f60a16f050", "e8f60a16f1b4", "e8f60a16f208", "e8f60a16f288", "e8f60a16f640", "e8f60a16f94c", "e8f60a16fb00", "e8f60a16fb14", "e8f60a1701a8", "e8f60a170794", "e8f60a1707c0", "e8f60a170874"], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "bridge_id": "544ea771d14f"}, "19": {"name": "test2", "devices": [], "wifi_driver_display_name": null, "wifi_driver_num_leds": null, "wifi_color_order": "rgb", "wifi_startup_mode": "default", "output_brightness": 255, "bridge_id": null, "pattern": "on", "colors": ["000000", "FF0000"], "brightness": 100, "delay": 100, "step_offset": 0, "step_increment": 1, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}} \ No newline at end of file diff --git a/db/preset.json b/db/preset.json index ab14a8b..5f3b074 100644 --- a/db/preset.json +++ b/db/preset.json @@ -1 +1 @@ -{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 128, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null, "mode": 0}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": true, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "background_palette_ref": null, "manual_beat_n": 1}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1, "background_palette_ref": null}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}, "78": {"name": "winter off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "79": {"name": "winter twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 220, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null, null, null]}, "80": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "81": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "82": {"name": "winter rime", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 220, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "83": {"name": "winter aurora", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 220, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "84": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "85": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 220, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "86": {"name": "winter cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "87": {"name": "winter ice chase", "pattern": "chase", "colors": ["#E8F4FF", "#5080C8"], "brightness": 220, "delay": 120, "auto": false, "n1": 20, "n2": 20, "n3": 15, "n4": 15, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#071018", "manual_beat_n": 1, "palette_refs": [null, null]}} \ No newline at end of file +{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 128, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null, "mode": 0}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "background_palette_ref": null, "manual_beat_n": 1}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1, "background_palette_ref": null}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 180, "n2": 4, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1, "background_palette_ref": null}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1, "background_palette_ref": null}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}, "78": {"name": "winter off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "79": {"name": "winter twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 220, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null, null, null]}, "80": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "81": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "82": {"name": "winter rime", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 220, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "83": {"name": "winter aurora", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 220, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "84": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "85": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 220, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1, "palette_refs": [null, null, null]}, "86": {"name": "winter cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#0A1520", "manual_beat_n": 1}, "87": {"name": "winter ice chase", "pattern": "chase", "colors": ["#E8F4FF", "#5080C8"], "brightness": 220, "delay": 120, "auto": false, "n1": 20, "n2": 20, "n3": 15, "n4": 15, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "3", "background": "#071018", "manual_beat_n": 1, "palette_refs": [null, null]}} \ No newline at end of file diff --git a/db/sequence.json b/db/sequence.json index 66221ef..57c8748 100644 --- a/db/sequence.json +++ b/db/sequence.json @@ -1 +1 @@ -{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": [], "lanes": [[{"preset_id": "3", "beats": 4}]], "lanes_group_ids": [[]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "12": {"name": "Winter cell cascade", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 6}], [{"preset_id": "85", "beats": 6}], [{"preset_id": "81", "beats": 6}], [{"preset_id": "82", "beats": 6}], [{"preset_id": "83", "beats": 6}], [{"preset_id": "84", "beats": 6}]], "lanes_group_ids": [["6"], ["7"], ["8"], ["9"], ["10"], ["11"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 6}, {"preset_id": "85", "beats": 6}, {"preset_id": "81", "beats": 6}, {"preset_id": "82", "beats": 6}, {"preset_id": "83", "beats": 6}, {"preset_id": "84", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 85, "sequence_transition": 500, "loop": true}, "13": {"name": "Winter row waves", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}], [{"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}]], "lanes_group_ids": [["12"], ["13"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "14": {"name": "Winter column chase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "87", "beats": 12}], [{"preset_id": "79", "beats": 12}], [{"preset_id": "84", "beats": 12}]], "lanes_group_ids": [["14"], ["15"], ["16"]], "advance_mode": "beats", "steps": [{"preset_id": "87", "beats": 12}, {"preset_id": "79", "beats": 12}, {"preset_id": "84", "beats": 12}], "step_duration_ms": 3000, "simulated_bpm": 95, "sequence_transition": 500, "loop": true}, "15": {"name": "Winter full blizzard", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 16}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 16}], "step_duration_ms": 3000, "simulated_bpm": 75, "sequence_transition": 500, "loop": true}, "16": {"name": "Winter showcase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 72, "sequence_transition": 500, "loop": true}} \ No newline at end of file +{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "3", "beats": 4}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": ["18"], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [["18"]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "12": {"name": "Winter cell cascade", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 6}], [{"preset_id": "85", "beats": 6}], [{"preset_id": "81", "beats": 6}], [{"preset_id": "82", "beats": 6}], [{"preset_id": "83", "beats": 6}], [{"preset_id": "84", "beats": 6}]], "lanes_group_ids": [["6"], ["7"], ["8"], ["9"], ["10"], ["11"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 6}, {"preset_id": "85", "beats": 6}, {"preset_id": "81", "beats": 6}, {"preset_id": "82", "beats": 6}, {"preset_id": "83", "beats": 6}, {"preset_id": "84", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 85, "sequence_transition": 500, "loop": true}, "13": {"name": "Winter row waves", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}], [{"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}]], "lanes_group_ids": [["12"], ["13"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 8}, {"preset_id": "80", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "82", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "14": {"name": "Winter column chase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "87", "beats": 12}], [{"preset_id": "79", "beats": 12}], [{"preset_id": "84", "beats": 12}]], "lanes_group_ids": [["14"], ["15"], ["16"]], "advance_mode": "beats", "steps": [{"preset_id": "87", "beats": 12}, {"preset_id": "79", "beats": 12}, {"preset_id": "84", "beats": 12}], "step_duration_ms": 3000, "simulated_bpm": 95, "sequence_transition": 500, "loop": true}, "15": {"name": "Winter full blizzard", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "81", "beats": 16}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "81", "beats": 16}], "step_duration_ms": 3000, "simulated_bpm": 75, "sequence_transition": 500, "loop": true}, "16": {"name": "Winter showcase", "profile_id": "3", "group_ids": ["17"], "lanes": [[{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}]], "lanes_group_ids": [["17"]], "advance_mode": "beats", "steps": [{"preset_id": "80", "beats": 8}, {"preset_id": "81", "beats": 8}, {"preset_id": "82", "beats": 8}, {"preset_id": "83", "beats": 8}, {"preset_id": "84", "beats": 8}, {"preset_id": "79", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 72, "sequence_transition": 500, "loop": true}} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index c4a2442..e3a6ead 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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//apply`**, which sets `current_ Connect to **`ws://:/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"}`. diff --git a/docs/espnow-architecture.md b/docs/espnow-architecture.md index b3e5bc8..086dcb2 100644 --- a/docs/espnow-architecture.md +++ b/docs/espnow-architecture.md @@ -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). --- diff --git a/docs/espnow-binary-protocol.md b/docs/espnow-binary-protocol.md index c1dd36a..84cec45 100644 --- a/docs/espnow-binary-protocol.md +++ b/docs/espnow-binary-protocol.md @@ -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 | diff --git a/docs/help.md b/docs/help.md index 5e604db..7aea1e1 100644 --- a/docs/help.md +++ b/docs/help.md @@ -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**. diff --git a/espnow-sender/README.md b/espnow-sender/README.md deleted file mode 100644 index f646220..0000000 --- a/espnow-sender/README.md +++ /dev/null @@ -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) diff --git a/espnow-sender/lib/aioespnow.py b/espnow-sender/lib/aioespnow.py deleted file mode 100644 index 5d78075..0000000 --- a/espnow-sender/lib/aioespnow.py +++ /dev/null @@ -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() diff --git a/espnow-sender/msg.json b/espnow-sender/msg.json deleted file mode 100644 index d28ca24..0000000 --- a/espnow-sender/msg.json +++ /dev/null @@ -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 - } - } -} diff --git a/espnow-sender/src/bridge_http.py b/espnow-sender/src/bridge_http.py new file mode 100644 index 0000000..cdfe479 --- /dev/null +++ b/espnow-sender/src/bridge_http.py @@ -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"} diff --git a/espnow-sender/src/downlink_router.py b/espnow-sender/src/downlink_router.py deleted file mode 100644 index af66b21..0000000 --- a/espnow-sender/src/downlink_router.py +++ /dev/null @@ -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) diff --git a/espnow-sender/src/espnow_wire.py b/espnow-sender/src/espnow_wire.py deleted file mode 100644 index a1b4c98..0000000 --- a/espnow-sender/src/espnow_wire.py +++ /dev/null @@ -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 diff --git a/espnow-sender/src/main.py b/espnow-sender/src/main.py index ba8710c..c44d09f 100644 --- a/espnow-sender/src/main.py +++ b/espnow-sender/src/main.py @@ -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) diff --git a/espnow-sender/src/peer_table.py b/espnow-sender/src/peer_table.py index 5eabc6e..289228a 100644 --- a/espnow-sender/src/peer_table.py +++ b/espnow-sender/src/peer_table.py @@ -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) diff --git a/espnow-sender/src/util.py b/espnow-sender/src/util.py index c65e8f4..b0e3425 100644 --- a/espnow-sender/src/util.py +++ b/espnow-sender/src/util.py @@ -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)) diff --git a/espnow-sender/src/v1_wire.py b/espnow-sender/src/v1_wire.py deleted file mode 100644 index 03bda57..0000000 --- a/espnow-sender/src/v1_wire.py +++ /dev/null @@ -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 diff --git a/espnow-sender/src/wifi_ap.py b/espnow-sender/src/wifi_ap.py deleted file mode 100644 index d8221f3..0000000 --- a/espnow-sender/src/wifi_ap.py +++ /dev/null @@ -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]) diff --git a/led-driver b/led-driver index 088fe16..8403df5 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit 088fe161a83c2f3c0408c3c48d28012b5ffbca88 +Subproject commit 8403df531da837aa046e2a0f83d9bdff41ee9476 diff --git a/led-simulator b/led-simulator index 42c1436..4fc3345 160000 --- a/led-simulator +++ b/led-simulator @@ -1 +1 @@ -Subproject commit 42c14361e8cb82ef5e5c4fd1927b5ccddfe39764 +Subproject commit 4fc3345fc9220b4ec5593f0a39cf175f3dbcfb22 diff --git a/src/controllers/device.py b/src/controllers/device.py index 53d8aa6..a1f0437 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -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("//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", diff --git a/src/controllers/group.py b/src/controllers/group.py index 9e70e33..59c096e 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -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) diff --git a/src/controllers/preset.py b/src/controllers/preset.py index 205bb15..383d682 100644 --- a/src/controllers/preset.py +++ b/src/controllers/preset.py @@ -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(), diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index 4b641f7..c32818d 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -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, diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 40da5b0..4821da7 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -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() diff --git a/src/controllers/wifi_bridge.py b/src/controllers/wifi_bridge.py new file mode 100644 index 0000000..f3a59d1 --- /dev/null +++ b/src/controllers/wifi_bridge.py @@ -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/") +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//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", + } diff --git a/src/main.py b/src/main.py index caeb998..ef95007 100644 --- a/src/main.py +++ b/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: - try: - ch = int(settings.get("wifi_channel", 1)) - except (TypeError, ValueError): - ch = 1 - bridge = init_bridge_client(bridge_url, wifi_channel=ch) - bridge.set_uplink_handler(handle_bridge_uplink) - bridge.start() + bridge = get_bridge(settings) + set_bridge(bridge) + + bridge_mode = str(settings.get("bridge_transport") or "wifi").strip().lower() + if bridge_mode == "wifi": + ws_url = str(settings.get("bridge_ws_url") or "").strip() + if ws_url: + try: + ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT)) + except (TypeError, ValueError): + ch = WIFI_CHANNEL_DEFAULT + ws_client = init_bridge_client(ws_url, wifi_channel=ch) + ws_client.set_uplink_handler(handle_bridge_uplink) + ws_client.start() + set_bridge(BridgeWsTransport()) + elif bridge_mode == "serial": + serial_port = str(settings.get("bridge_serial_port") or "").strip() + if serial_port: + baud = 115200 + for prof in settings.get("bridges") or []: + if not isinstance(prof, dict): + continue + if str(prof.get("transport") or "").strip().lower() != "serial": + continue + if str(prof.get("serial_port") or "").strip() != serial_port: + continue + try: + baud = int(prof.get("serial_baudrate") or baud) + except (TypeError, ValueError): + pass + break + else: + try: + baud = int(settings.get("bridge_serial_baudrate") or baud) + except (TypeError, ValueError): + pass + serial_client = init_bridge_serial_client(serial_port, baudrate=baud) + serial_client.set_uplink_handler(handle_bridge_uplink) + serial_client.start() + set_bridge(BridgeSerialTransport()) app = Microdot() audio_detector = AudioBeatDetector() @@ -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: diff --git a/src/models/bridge_serial_client.py b/src/models/bridge_serial_client.py new file mode 100644 index 0000000..332089f --- /dev/null +++ b/src/models/bridge_serial_client.py @@ -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 diff --git a/src/models/bridge_ws_client.py b/src/models/bridge_ws_client.py index 6755237..4c23ea4 100644 --- a/src/models/bridge_ws_client.py +++ b/src/models/bridge_ws_client.py @@ -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 diff --git a/src/models/device.py b/src/models/device.py index 4442540..08bb242 100644 --- a/src/models/device.py +++ b/src/models/device.py @@ -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. diff --git a/src/models/group.py b/src/models/group.py index baed051..33ca67c 100644 --- a/src/models/group.py +++ b/src/models/group.py @@ -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, diff --git a/src/models/transport.py b/src/models/transport.py index 568f583..a37a736 100644 --- a/src/models/transport.py +++ b/src/models/transport.py @@ -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): - url = str(settings.get("bridge_ws_url") or "").strip() - if not url: +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() diff --git a/src/models/wifi_ws_clients.py b/src/models/wifi_ws_clients.py index 0429f1d..5672187 100644 --- a/src/models/wifi_ws_clients.py +++ b/src/models/wifi_ws_clients.py @@ -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 diff --git a/src/settings.py b/src/settings.py index 1bcca2b..78995d3 100644 --- a/src/settings.py +++ b/src/settings.py @@ -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: diff --git a/src/static/audio.js b/src/static/audio.js index f5be613..3669a89 100644 --- a/src/static/audio.js +++ b/src/static/audio.js @@ -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>} */ 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} 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|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|undefined} */ (status && status.sequence); - if (!seq || !seq.active) return null; - - const laneBeatAt = Number(seq.lane0_beat_in_step); - const laneBeatsPerStep = Number(seq.lane0_beats_per_step); - if ( - !Number.isFinite(laneBeatAt) || - laneBeatAt <= 0 || - !Number.isFinite(laneBeatsPerStep) || - laneBeatsPerStep <= 0 - ) { - return null; - } - const presetFraction = `${Math.floor(laneBeatAt)}/${Math.floor(laneBeatsPerStep)}`; - - const sequenceBeatAt = Number(seq.sequence_beat_at); - const sequenceBeatsPerPass = Number(seq.sequence_beats_per_pass); - if ( - !Number.isFinite(sequenceBeatAt) || - sequenceBeatAt <= 0 || - !Number.isFinite(sequenceBeatsPerPass) || - sequenceBeatsPerPass <= 0 - ) { - return null; - } - const sequenceFraction = `${Math.floor(sequenceBeatAt)}/${Math.floor(sequenceBeatsPerPass)}`; - - return `${presetFraction} ${sequenceFraction}`; - } - function updateHitTypeDisplay(hitType, confidence) { const node = el("audio-hit-type-value"); if (!node) return; @@ -136,11 +75,9 @@ top.classList.toggle("audio-running", !!on); } - function setNavResetVisible(on) { - for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) { - const node = el(id); - if (node) node.hidden = !on; - } + function setResetDetectorEnabled(on) { + const btn = el("audio-reset-btn"); + if (btn) btn.disabled = !on; } async function resetAudioTracking() { @@ -160,20 +97,21 @@ } } + function beatSyncButtonTitle(zoneSeqActive) { + if (!audioDetectorRunning) return "Start beat detection"; + if (zoneSeqActive) return "Sync step to music (S)"; + return "Beat detection running"; + } + function updateSequenceSyncControls(zoneSeqActive) { - const topSync = el("audio-top-beat-sync"); - if (topSync) { - topSync.disabled = audioDetectorRunning && !zoneSeqActive; - topSync.title = !audioDetectorRunning - ? "Start beat detection" - : zoneSeqActive - ? "Sync step to music (S)" - : "Beat detection running"; + const disabled = audioDetectorRunning && !zoneSeqActive; + const title = beatSyncButtonTitle(zoneSeqActive); + for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) { + const btn = el(id); + if (!btn) continue; + btn.disabled = disabled; + btn.title = title; } - const modalBeat = el("audio-modal-beat-readout"); - if (modalBeat) modalBeat.disabled = !zoneSeqActive; - const passBtn = el("audio-sync-pass-btn"); - if (passBtn) passBtn.disabled = !zoneSeqActive; } async function handleTopBpmButtonClick() { @@ -212,17 +150,41 @@ return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; } + function flashBeatSyncButton(btn) { + if (!btn) return; + btn.classList.add("flash"); + setTimeout(() => btn.classList.remove("flash"), 90); + } + function flashBeat() { - const node = el("audio-beat-flash"); - if (!node) return; - node.classList.add("active"); - setTimeout(() => node.classList.remove("active"), 80); - const syncBtn = el("audio-top-beat-sync"); const top = el("audio-top-indicator"); - if (syncBtn && top && top.classList.contains("audio-running")) { - syncBtn.classList.add("flash"); - setTimeout(() => syncBtn.classList.remove("flash"), 90); + const topSync = el("audio-top-beat-sync"); + if (topSync && top && top.classList.contains("audio-running")) { + flashBeatSyncButton(topSync); } + const modalSync = el("audio-modal-beat-sync"); + if (modalSync && audioDetectorRunning) { + flashBeatSyncButton(modalSync); + } + } + + function gainPercentToDb(pct) { + const gain = Math.max(0.001, pct / 100); + return 20 * Math.log10(gain); + } + + function formatGainReadout(pct) { + const db = gainPercentToDb(pct); + const dbText = db >= 0 ? `+${db.toFixed(2)}` : db.toFixed(2); + return `${pct}% (${dbText} dB)`; + } + + function updateInputLevelDisplay(level) { + const pct = Number.isFinite(level) ? Math.round(Math.min(1, Math.max(0, level)) * 100) : 0; + const bar = el("audio-input-level-bar"); + const meter = el("audio-modal")?.querySelector(".audio-input-level-meter"); + if (bar) bar.style.width = `${pct}%`; + if (meter) meter.setAttribute("aria-valuenow", String(pct)); } function clearBeatPhaseTimers() { @@ -231,24 +193,38 @@ } function getBeatPhaseDelayMs() { - const inp = el("audio-beat-phase-ms"); - if (inp && String(inp.value).trim() !== "") { - const n = parseInt(String(inp.value).trim(), 10); - if (Number.isFinite(n)) return Math.min(500, Math.max(0, n)); - } - return 0; + return Math.min(500, Math.max(0, cachedBeatPhaseMs)); } - async function persistBeatPhaseMs() { - const ms = getBeatPhaseDelayMs(); + function getInputVolumePercent() { + const inp = el("audio-input-volume"); + if (!inp) return 100; + const n = parseInt(String(inp.value).trim(), 10); + if (!Number.isFinite(n)) return 100; + return Math.min(200, Math.max(0, n)); + } + + function updateInputVolumeReadout() { + const readout = el("audio-input-volume-readout"); + const slider = el("audio-input-volume"); + const pct = getInputVolumePercent(); + if (readout) readout.textContent = formatGainReadout(pct); + if (slider) { + slider.style.setProperty("--audio-volume-pct", `${(pct / 200) * 100}%`); + } + } + + async function persistInputVolume() { + const vol = getInputVolumePercent(); + updateInputVolumeReadout(); try { await fetch("/settings", { method: "PUT", headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ audio_beat_phase_ms: ms }), + body: JSON.stringify({ audio_input_volume: vol }), }); } catch (e) { - console.warn("beat phase ms save failed", e); + console.warn("input volume save failed", e); } } @@ -277,7 +253,7 @@ async function stopAudioOnly() { audioDetectorRunning = false; setTopBpmVisible(false); - setNavResetVisible(false); + setResetDetectorEnabled(false); clearBeatPhaseTimers(); if (pollTimer) { clearInterval(pollTimer); @@ -286,8 +262,8 @@ lastBeatSeq = 0; prevZoneSequencePlaybackActive = false; headerBeatStickyIdleAfterSeq = false; - lastBeatConsoleKey = ""; updateBeatReadoutDisplays({}); + updateInputLevelDisplay(0); try { await fetch("/api/audio/stop", { method: "POST" }); } catch (e) { @@ -313,8 +289,9 @@ updateBeatReadoutDisplays({}); audioDetectorRunning = !!status.running; updateBpmDisplay(null); + updateInputLevelDisplay(0); setTopBpmVisible(!!status.running); - setNavResetVisible(!!status.running); + setResetDetectorEnabled(!!status.running); if (!status.running && pollTimer) { clearInterval(pollTimer); pollTimer = null; @@ -324,11 +301,14 @@ audioDetectorRunning = !!status.running; const zoneSeqActive = sequencePlaybackActiveFromStatus(status); setTopBpmVisible(!!status.running || zoneSeqActive); - setNavResetVisible(!!status.running); + setResetDetectorEnabled(!!status.running); updateSequenceSyncControls(zoneSeqActive); updateBpmDisplay(status.bpm); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); updateBarPhaseDisplay(status); + updateInputLevelDisplay( + status.running ? Number(status.input_level) : 0, + ); applyServerAudioUiFields(status); if (typeof window.applySequenceSwitchWaitFromServer === "function") { window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait); @@ -344,7 +324,6 @@ prevZoneSequencePlaybackActive = zoneSeqActive; if (startedSeq) { headerBeatStickyIdleAfterSeq = false; - lastLoggedSequenceBeatFractions = ""; } if (endedSeq) { headerBeatStickyIdleAfterSeq = true; @@ -354,38 +333,137 @@ if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) { if (beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; - logServerBeatConsoleOnPollEdge(status); scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); headerBeatStickyIdleAfterSeq = false; } } else if (beatSeq > lastBeatSeq) { lastBeatSeq = beatSeq; - logServerBeatConsoleOnPollEdge(status); scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); } - const beatFractions = formatSequenceBeatFractionsForLog(status); - if (beatFractions) { - if (beatFractions !== lastLoggedSequenceBeatFractions) { - lastLoggedSequenceBeatFractions = beatFractions; - } - } else { - lastLoggedSequenceBeatFractions = ""; - } updateBeatReadoutDisplays(status); } catch (e) { console.warn("audio status poll failed", e); } } - async function startAudio() { + /** Ignore server device sync briefly after the user picks from the dropdown. */ + let deviceSelectLockUntil = 0; + /** Suppress change handler while rebuilding or programmatically setting the select. */ + let suppressDeviceSelectEvents = false; + /** Last explicit UI choice (dropdown); not overwritten by server poll. */ + let uiDeviceSelectId = ""; + + function lockDeviceSelect(ms = 10000) { + deviceSelectLockUntil = Date.now() + ms; + } + + function preferredSavedDeviceId() { + return cachedAudioRun.device_select ? String(cachedAudioRun.device_select) : ""; + } + + function optionIdForSavedDevice(select, savedId) { + const saved = savedId == null ? "" : String(savedId); + if (!saved || !select) return ""; + if (selectHasDeviceOptionId(select, saved)) return saved; + if (!/^-?\d+$/.test(saved)) return ""; + for (const opt of select.options) { + if (String(opt.dataset.sdIndex ?? "") === saved) return opt.value; + } + return ""; + } + + function restoreDeviceSelectAfterRefresh(select, defaultId, restoreId = "") { + const picked = restoreId || getSelectedDeviceId(); + if (picked && selectHasDeviceOptionId(select, picked)) { + setSelectedDeviceId(picked); + return; + } + const saved = preferredSavedDeviceId(); + const savedId = optionIdForSavedDevice(select, saved) || saved; + if (savedId && selectHasDeviceOptionId(select, savedId)) { + setSelectedDeviceId(savedId); + return; + } + if (defaultId && selectHasDeviceOptionId(select, defaultId)) { + setSelectedDeviceId(defaultId); + return; + } + setSelectedDeviceId(""); + } + + function getSelectedDeviceId() { + return String(el("audio-device-select")?.value ?? ""); + } + + function selectHasDeviceOptionId(select, deviceId) { + const id = deviceId == null ? "" : String(deviceId); + return [...select.options].some((opt) => opt.value === id); + } + + function audioRunPreferredDeviceId(run) { + return run.device_select ? String(run.device_select) : ""; + } + + function setSelectedDeviceId(deviceId, { force = false } = {}) { + const id = deviceId == null ? "" : String(deviceId); + const select = el("audio-device-select"); + if (!select) return false; + if (id !== "" && !selectHasDeviceOptionId(select, id)) { + if (!force) return false; + } + suppressDeviceSelectEvents = true; + try { + select.value = id; + uiDeviceSelectId = id; + } finally { + suppressDeviceSelectEvents = false; + } + return true; + } + + function readDeviceForm() { + return { override: "", selected: getSelectedDeviceId() }; + } + + async function persistDeviceSelection(deviceId) { + const selected = deviceId != null ? String(deviceId) : getSelectedDeviceId(); + uiDeviceSelectId = selected; + cachedAudioRun.device_select = selected; + try { + const res = await fetch("/api/audio/device", { + method: "PUT", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ device_select: selected, device_override: "" }), + }); + const data = await res.json().catch(() => ({})); + if (data?.audio_run && typeof data.audio_run === "object") { + const saved = data.audio_run.device_select + ? String(data.audio_run.device_select) + : ""; + if (saved === selected) { + cachedAudioRun.device_select = saved; + } + } + } catch (e) { + console.warn("device selection save failed", e); + } + } + + async function startAudio(deviceId) { + const selected = + deviceId != null && deviceId !== undefined + ? String(deviceId) + : uiDeviceSelectId || getSelectedDeviceId(); + lockDeviceSelect(); + uiDeviceSelectId = selected; + cachedAudioRun.device_select = selected; await stopAudioOnly(); - const override = (el("audio-device-override")?.value || "").trim(); - const selected = el("audio-device-select")?.value || ""; - const rawDevice = override !== "" ? override : selected; + await persistDeviceSelection(selected); + const rawDevice = selected; const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice; const body = { device: rawDevice === "" ? null : numeric, - device_override: override, + device_override: "", device_select: selected, }; const res = await fetch("/api/audio/start", { @@ -397,6 +475,8 @@ const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to start audio detector"); } + cachedAudioRun.device_select = selected; + setSelectedDeviceId(selected); updateBpmDisplay(null); updateHitTypeDisplay("unknown", NaN); pollTimer = setInterval(pollStatus, 250); @@ -405,36 +485,36 @@ async function refreshDevices() { const select = el("audio-device-select"); - const debug = el("audio-devices-debug"); if (!select) return; - const current = select.value; const res = await fetch("/api/audio/devices"); const data = await res.json(); + // Re-read after fetch so a pick during the request is not overwritten by a stale value. + const restoreId = getSelectedDeviceId(); const inputs = Array.isArray(data?.devices) ? data.devices.slice() : []; - if (debug) { - debug.value = JSON.stringify(data?.diagnostics || data, null, 2); - } - inputs.sort((a, b) => { - const am = String(a?.name || "").toLowerCase().includes("monitor"); - const bm = String(b?.name || "").toLowerCase().includes("monitor"); - if (am !== bm) return am ? -1 : 1; - return Number(a?.id || 0) - Number(b?.id || 0); - }); - select.innerHTML = ''; + select.innerHTML = ""; + const defaultOpt = document.createElement("option"); + defaultOpt.value = ""; + defaultOpt.textContent = "System default input"; + select.appendChild(defaultOpt); let defaultId = ""; inputs.forEach((d, idx) => { - const option = document.createElement("option"); - option.value = String(d.id); - option.textContent = d.label || d.name || `Input ${idx + 1}`; - if (d.is_default) { - defaultId = String(d.id); + const opt = document.createElement("option"); + opt.value = String(d.id); + const text = d.display_name || d.name || `Input ${idx + 1}`; + opt.textContent = text; + const title = d.label || d.name || ""; + if (title && title !== text) opt.title = title; + if (d.sounddevice_index != null && d.sounddevice_index !== "") { + opt.dataset.sdIndex = String(d.sounddevice_index); } - select.appendChild(option); + select.appendChild(opt); + if (d.is_default) defaultId = String(d.id); }); - if (current) { - select.value = current; - } else if (defaultId) { - select.value = defaultId; + suppressDeviceSelectEvents = true; + try { + restoreDeviceSelectAfterRefresh(select, defaultId, restoreId); + } finally { + suppressDeviceSelectEvents = false; } } @@ -444,7 +524,7 @@ const closeBtn = el("audio-close-btn"); const startBtn = el("audio-start-btn"); const stopBtn = el("audio-stop-btn"); - const navResetBtn = el("audio-nav-reset-btn"); + const resetBtn = el("audio-reset-btn"); const refreshBtn = el("audio-refresh-btn"); if (!modal || !openBtn) return; @@ -455,6 +535,8 @@ } catch (e) { console.warn("audio device refresh failed", e); } + await loadServerAudioUiFields(); + setResetDetectorEnabled(audioDetectorRunning); }); if (closeBtn) { closeBtn.addEventListener("click", () => { @@ -463,9 +545,9 @@ } if (startBtn) { startBtn.addEventListener("click", async () => { + const picked = getSelectedDeviceId(); try { - await startAudio(); - await refreshDevices(); + await startAudio(picked); } catch (e) { console.error("audio start failed", e); alert("Failed to start audio input. Check mic permissions."); @@ -477,8 +559,8 @@ await stopAudio(); }); } - if (navResetBtn) { - navResetBtn.addEventListener("click", () => resetAudioTracking()); + if (resetBtn) { + resetBtn.addEventListener("click", () => resetAudioTracking()); } if (refreshBtn) { refreshBtn.addEventListener("click", async () => { @@ -489,35 +571,38 @@ } }); } - - const phaseInp = el("audio-beat-phase-ms"); - if (phaseInp) { - phaseInp.addEventListener("change", () => { - void persistBeatPhaseMs(); - }); - phaseInp.addEventListener("input", () => { - void persistBeatPhaseMs(); + const deviceSelect = el("audio-device-select"); + if (deviceSelect) { + deviceSelect.addEventListener("change", async () => { + if (suppressDeviceSelectEvents) return; + const picked = getSelectedDeviceId(); + uiDeviceSelectId = picked; + lockDeviceSelect(); + cachedAudioRun.device_select = picked; + await persistDeviceSelection(picked); }); } - - const bindSync = (node, mode) => { - if (!node) return; - node.addEventListener("click", async () => { - try { - await syncSequenceBeatPhase(mode); - } catch (e) { - console.warn("sequence beat sync failed", e); - } + const volInp = el("audio-input-volume"); + if (volInp) { + volInp.addEventListener("input", () => { + updateInputVolumeReadout(); + void persistInputVolume(); }); - }; - const topBpm = el("audio-top-beat-sync"); - if (topBpm) { - topBpm.addEventListener("click", () => { - void handleTopBpmButtonClick(); + volInp.addEventListener("change", () => { + updateInputVolumeReadout(); + void persistInputVolume(); }); + updateInputVolumeReadout(); + } + + for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) { + const btn = el(id); + if (btn) { + btn.addEventListener("click", () => { + void handleTopBpmButtonClick(); + }); + } } - bindSync(el("audio-modal-beat-readout"), "step"); - bindSync(el("audio-sync-pass-btn"), "pass"); document.addEventListener("keydown", (ev) => { if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return; @@ -548,39 +633,50 @@ } } - /** Apply server-owned audio UI fields from status (device form, beat phase delay). */ + /** Apply server-owned audio UI fields from status (volume; device dropdown is user-owned). */ function applyServerAudioUiFields(status) { if (!status || typeof status !== "object") return; const run = status.audio_run; if (run && typeof run === "object") { - const ov = el("audio-device-override"); - const sel = el("audio-device-select"); - if (ov && run.device_override != null) ov.value = String(run.device_override); - if (sel && run.device_select) sel.value = String(run.device_select); + cachedAudioRun = { + device: run.device ?? null, + device_override: run.device_override != null ? String(run.device_override) : "", + device_select: run.device_select ? String(run.device_select) : "", + }; } - const phaseInp = el("audio-beat-phase-ms"); - if ( - phaseInp && - status.beat_phase_ms != null && - document.activeElement !== phaseInp - ) { + if (status.beat_phase_ms != null) { const ms = parseInt(String(status.beat_phase_ms), 10); if (Number.isFinite(ms)) { - phaseInp.value = String(Math.min(500, Math.max(0, ms))); + cachedBeatPhaseMs = Math.min(500, Math.max(0, ms)); + } + } + const volInp = el("audio-input-volume"); + if ( + volInp && + status.input_volume != null && + document.activeElement !== volInp + ) { + const vol = parseInt(String(status.input_volume), 10); + if (Number.isFinite(vol)) { + volInp.value = String(Math.min(200, Math.max(0, vol))); + updateInputVolumeReadout(); } } } async function loadServerAudioUiFields() { - try { - await refreshDevices(); - } catch (e) { - console.warn("audio device list refresh failed", e); - } try { const res = await fetch("/api/audio/status", { cache: "no-store" }); const data = await res.json(); - applyServerAudioUiFields(data?.status || {}); + const status = data?.status || {}; + applyServerAudioUiFields(status); + const select = el("audio-device-select"); + const saved = audioRunPreferredDeviceId(status.audio_run || {}); + if (select && saved && selectHasDeviceOptionId(select, saved)) { + uiDeviceSelectId = saved; + setSelectedDeviceId(saved); + } + updateInputLevelDisplay(status.running ? Number(status.input_level) : 0); } catch (e) { console.warn("audio status load failed", e); } diff --git a/src/static/devices.js b/src/static/devices.js index 5315b32..6a97acb 100644 --- a/src/static/devices.js +++ b/src/static/devices.js @@ -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; +} diff --git a/src/static/groups.js b/src/static/groups.js index 52f0e36..4226eaa 100644 --- a/src/static/groups.js +++ b/src/static/groups.js @@ -45,7 +45,14 @@ async function fetchDevicesMapForGroups() { function renderGroupDevicesEditor(containerEl, macRows, devicesMap) { if (!containerEl) return; - containerEl.innerHTML = ''; + const panel = + typeof window.prepareZoneDevicesPanel === 'function' + ? window.prepareZoneDevicesPanel(containerEl) + : null; + const listEl = panel ? panel.listEl : containerEl; + if (!panel) { + containerEl.innerHTML = ''; + } const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b)); macRows.forEach((row, idx) => { @@ -72,7 +79,7 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) { }); div.appendChild(label); div.appendChild(rm); - containerEl.appendChild(div); + listEl.appendChild(div); }); const macsInRows = new Set(macRows.map((r) => r.mac).filter(Boolean)); @@ -101,7 +108,11 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) { }); addWrap.appendChild(sel); addWrap.appendChild(addBtn); - containerEl.appendChild(addWrap); + if (panel) { + panel.addSlot.appendChild(addWrap); + } else { + containerEl.appendChild(addWrap); + } refreshEditGroupDebug(); } @@ -320,12 +331,6 @@ function renderGroupsList(groups) { alert(data.error || 'Apply brightness failed'); return; } - const n = typeof data.sent === 'number' ? data.sent : 0; - alert( - n - ? `Sent brightness to ${n} driver(s).` - : 'No 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'); diff --git a/src/static/help.js b/src/static/help.js index 0c7aad4..4719ddd 100644 --- a/src/static/help.js +++ b/src/static/help.js @@ -41,157 +41,534 @@ document.addEventListener('DOMContentLoaded', () => { const settingsButton = document.getElementById('settings-btn'); const settingsModal = document.getElementById('settings-modal'); const settingsCloseButton = document.getElementById('settings-close-btn'); + const settingsTabButtons = document.querySelectorAll('[data-settings-tab]'); + const settingsTabPanels = document.querySelectorAll('[data-settings-panel]'); + const ledToolIframe = document.getElementById('led-tool-iframe'); + let settingsActiveTab = 'bridge'; - const showSettingsMessage = (text, type = 'success') => { - const messageEl = document.getElementById('settings-message'); - if (!messageEl) return; - messageEl.textContent = text; - messageEl.className = `message ${type} show`; - setTimeout(() => { - messageEl.classList.remove('show'); - }, 5000); - }; - - async function loadDeviceSettings() { - try { - const response = await fetch('/settings'); - const data = await response.json(); - const nameInput = document.getElementById('device-name-input'); - if (nameInput && data && typeof data === 'object') { - nameInput.value = data.device_name || 'led-controller'; - } - const chInput = document.getElementById('wifi-channel-input'); - if (chInput && data && typeof data === 'object') { - const ch = data.wifi_channel; - chInput.value = - ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6'; - } - } catch (error) { - console.error('Error loading device settings:', error); + function loadLedToolIframe() { + if (!ledToolIframe) return; + const blank = !ledToolIframe.src || ledToolIframe.src === 'about:blank'; + if (blank) { + ledToolIframe.src = '/led-tool/editor'; } } - async function loadAPStatus() { - try { - const response = await fetch('/settings/wifi/ap'); - const config = await response.json(); - const statusEl = document.getElementById('ap-status'); - if (!statusEl) return; - if (config.active) { - statusEl.innerHTML = ` -

AP Status: Active

-

SSID: ${config.ssid || 'N/A'}

-

Channel: ${config.channel || 'Auto'}

-

IP Address: ${config.ip || 'N/A'}

- `; - } else { - statusEl.innerHTML = ` -

AP Status: Inactive

-

Access Point is not currently active

- `; - } - if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid; - if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel; - } catch (error) { - console.error('Error loading AP status:', error); + function unloadLedToolIframe() { + if (ledToolIframe) { + ledToolIframe.src = 'about:blank'; } } + function switchSettingsTab(tabId) { + if (!tabId) tabId = 'bridge'; + settingsActiveTab = tabId; + for (const btn of settingsTabButtons) { + const on = btn.getAttribute('data-settings-tab') === tabId; + btn.classList.toggle('active', on); + btn.setAttribute('aria-selected', on ? 'true' : 'false'); + } + for (const panel of settingsTabPanels) { + const on = panel.getAttribute('data-settings-panel') === tabId; + panel.classList.toggle('active', on); + panel.hidden = !on; + } + if (settingsModal) { + settingsModal.classList.toggle('settings-modal--led-tool', tabId === 'led-tool'); + } + if (tabId === 'led-tool') { + loadLedToolIframe(); + } + } + + for (const btn of settingsTabButtons) { + btn.addEventListener('click', () => { + switchSettingsTab(btn.getAttribute('data-settings-tab')); + }); + } + + window.openSettingsModal = (tabId) => { + if (!settingsModal) return; + if (tabId) { + switchSettingsTab(tabId); + } else { + switchSettingsTab(settingsActiveTab); + } + settingsModal.classList.add('active'); + if (!tabId || tabId === 'bridge') { + loadBridgeSettings(); + } + }; + + const bridgeWsStatus = document.getElementById('bridge-ws-status'); + const bridgeConnectionDetails = document.getElementById('bridge-connection-details'); + const bridgeProfilesList = document.getElementById('bridge-profiles-list'); + let lastBridgeSettings = null; + const bridgeSerialPortSelect = document.getElementById('bridge-serial-port'); + const bridgeSerialBaudInput = document.getElementById('bridge-serial-baud'); + const bridgeSerialConnectBtn = document.getElementById('bridge-serial-connect-btn'); + const bridgeSerialSaveProfileBtn = document.getElementById('bridge-serial-save-profile-btn'); + const bridgeSerialRefreshBtn = document.getElementById('bridge-serial-refresh-btn'); + const bridgeWifiInterfaceSelect = document.getElementById('bridge-wifi-interface'); + const bridgeWifiRefreshInterfacesBtn = document.getElementById('bridge-wifi-refresh-interfaces-btn'); + const bridgeWifiSsidSelect = document.getElementById('bridge-wifi-ssid'); + const bridgeWifiSsidManual = document.getElementById('bridge-wifi-ssid-manual'); + const bridgeWifiPassword = document.getElementById('bridge-wifi-password'); + const bridgeWifiConnectBtn = document.getElementById('bridge-wifi-connect-btn'); + const bridgeWifiSaveProfileBtn = document.getElementById('bridge-wifi-save-profile-btn'); + const bridgeWifiScanBtn = document.getElementById('bridge-wifi-scan-btn'); + const bridgeWifiApIp = document.getElementById('bridge-wifi-ap-ip'); + const bridgeWifiWsPort = document.getElementById('bridge-wifi-ws-port'); + + function setBridgeWsStatus(text, isError = false) { + if (!bridgeWsStatus) return; + bridgeWsStatus.textContent = text || ''; + bridgeWsStatus.style.color = isError ? '#f44336' : ''; + } + + function connLabel(ok) { + return ok ? 'connected' : 'not connected'; + } + + function bridgeStatusLine(data) { + if (!data) return ''; + const mode = data.bridge_transport === 'serial' ? 'USB serial' : '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); + } + } + + function resolvedBridgeSsid() { + const manual = bridgeWifiSsidManual?.value?.trim(); + if (manual) return manual; + return bridgeWifiSsidSelect?.value?.trim() || ''; + } + + async function loadBridgeSettings() { + try { + const bridgesRes = await fetch('/settings/wifi/bridges'); + const bridgesData = await bridgesRes.json().catch(() => ({})); + lastBridgeSettings = bridgesData; + if (bridgeSerialBaudInput && bridgesData.bridge_serial_baudrate) { + bridgeSerialBaudInput.value = String(bridgesData.bridge_serial_baudrate); + } + await loadSerialPorts(bridgesData.bridge_serial_port || ''); + await loadWifiInterfaces(bridgesData.wifi_interface || ''); + renderBridgeConnectionDetails(bridgesData); + setBridgeWsStatus(bridgeStatusLine(bridgesData)); + renderBridgeProfiles(bridgesData.bridges || [], bridgesData); + } catch (err) { + setBridgeWsStatus(err.message, true); + } + } + + async function loadWifiInterfaces(selectedDevice) { + if (!bridgeWifiInterfaceSelect) return; + try { + const res = await fetch('/settings/wifi/interfaces'); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + setBridgeWsStatus(data.error || 'Wi‑Fi interfaces unavailable', true); + return; + } + const current = selectedDevice || bridgeWifiInterfaceSelect.value; + bridgeWifiInterfaceSelect.innerHTML = ''; + 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 = ''; + 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 = ''; + 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 = '
  • No saved bridge profiles.
  • '; + 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 { + label.textContent = `${p.label} — USB ${p.serial_port}`; + } + const status = document.createElement('span'); + const st = profileStatusFor(p, data); + status.className = 'settings-bridge-profile-status ' + st.className; + status.textContent = st.text; + main.appendChild(label); + main.appendChild(status); + const actions = document.createElement('div'); + actions.className = 'settings-bridge-profile-actions'; + const connectBtn = document.createElement('button'); + connectBtn.type = 'button'; + connectBtn.className = 'btn btn-secondary btn-small'; + connectBtn.textContent = 'Connect'; + connectBtn.addEventListener('click', () => connectSavedBridge(p.id)); + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'btn btn-secondary btn-small settings-bridge-profile-delete'; + deleteBtn.textContent = 'Delete'; + deleteBtn.addEventListener('click', () => deleteBridgeProfile(p.id, p.label)); + actions.appendChild(connectBtn); + actions.appendChild(deleteBtn); + li.appendChild(main); + li.appendChild(actions); + bridgeProfilesList.appendChild(li); + } + } + + async function connectSavedBridge(id) { + setBridgeWsStatus('Connecting…'); + try { + const res = await fetch(`/settings/wifi/bridges/${encodeURIComponent(id)}/connect`, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + setBridgeWsStatus(data.error || 'Connect failed', true); + return; + } + setBridgeWsStatus(data.message ? `${data.message} — ${bridgeStatusLine(data)}` : bridgeStatusLine(data)); + await loadBridgeSettings(); + } catch (err) { + setBridgeWsStatus(err.message, true); + } + } + + async function connectBridgeWifi(saveProfile) { + const device = bridgeWifiInterfaceSelect?.value?.trim(); + const ssid = resolvedBridgeSsid(); + const password = bridgeWifiPassword?.value || ''; + const apIp = bridgeWifiApIp?.value?.trim() || '192.168.4.1'; + const wsPort = parseInt(bridgeWifiWsPort?.value, 10) || 80; + const label = document.getElementById('bridge-wifi-label')?.value?.trim() || ssid; + if (!device) { + setBridgeWsStatus('Select a 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(); }); } }); diff --git a/src/static/led_tool.js b/src/static/led_tool.js deleted file mode 100644 index 3995697..0000000 --- a/src/static/led_tool.js +++ /dev/null @@ -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'; - }); - } -}); diff --git a/src/static/patterns.js b/src/static/patterns.js index 2cb8a2e..b84e39e 100644 --- a/src/static/patterns.js +++ b/src/static/patterns.js @@ -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') { diff --git a/src/static/presets.js b/src/static/presets.js index 54ce020..81dd7f3 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -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; diff --git a/src/static/style.css b/src/static/style.css index 699149e..102d8ff 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -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; +} diff --git a/src/static/zone-devices-panel.js b/src/static/zone-devices-panel.js new file mode 100644 index 0000000..637b529 --- /dev/null +++ b/src/static/zone-devices-panel.js @@ -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; +} diff --git a/src/static/zones.js b/src/static/zones.js index cfa61b0..a613c2e 100644 --- a/src/static/zones.js +++ b/src/static/zones.js @@ -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; - containerEl.innerHTML = ""; + const panel = + typeof window.prepareZoneDevicesPanel === "function" + ? window.prepareZoneDevicesPanel(containerEl) + : null; + const listEl = panel ? panel.listEl : containerEl; + if (!panel) { + containerEl.innerHTML = ""; + } const entries = Object.entries(groupsMap || {}).sort(([a], [b]) => a.localeCompare(b)); rows.forEach((row, idx) => { @@ -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,7 +477,11 @@ function renderZoneGroupsEditor(containerEl, rows, groupsMap) { }); addWrap.appendChild(sel); addWrap.appendChild(addBtn); - containerEl.appendChild(addWrap); + if (panel) { + panel.addSlot.appendChild(addWrap); + } else { + containerEl.appendChild(addWrap); + } } /** Read zone device names from the presets section (JSON attr preferred; legacy comma list fallback). */ @@ -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 () => { diff --git a/src/templates/index.html b/src/templates/index.html index 19d17c9..a805308 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -18,7 +18,7 @@ Downbeat
    - - - - +
    @@ -68,10 +66,8 @@ - - - + @@ -171,7 +167,12 @@