Compare commits

...

4 Commits

11 changed files with 573 additions and 146 deletions

View File

@@ -11,6 +11,7 @@ watchfiles = "*"
fastapi = "*" fastapi = "*"
uvicorn = "*" uvicorn = "*"
flask = "*" flask = "*"
serial = "*"
[dev-packages] [dev-packages]

215
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "921fc0268aaeb27ac977902942dd25f0f84ea35bcbbee0412a4d7c801652eb67" "sha256": "5d970f8c0ea9e8ffa98cf0ea5f791161589a97d953d2629da026d01fa7a8bce7"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -151,11 +151,11 @@
}, },
"bitstring": { "bitstring": {
"hashes": [ "hashes": [
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a", "sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a" "sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.3.1" "version": "==4.4.0"
}, },
"blinker": { "blinker": {
"hashes": [ "hashes": [
@@ -265,84 +265,89 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822" "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.4" "version": "==46.0.5"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'", "version": "==5.2.0"
"version": "==5.1.0"
}, },
"fastapi": { "fastapi": {
"hashes": [ "hashes": [
"sha256:c8cdf7c2182c9a06bf9cfa3329819913c189dc86389b90d5709892053582db29", "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e",
"sha256:ed99383fd96063447597d5aa2a9ec3973be198e3b4fc10c55f15c62efdb21c60" "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "version": "==0.135.1"
"version": "==0.128.3"
}, },
"flask": { "flask": {
"hashes": [ "hashes": [
"sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb",
"sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c" "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "version": "==3.1.3"
"version": "==3.1.2" },
"future": {
"hashes": [
"sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216",
"sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.0.0"
}, },
"h11": { "h11": {
"hashes": [ "hashes": [
@@ -367,6 +372,14 @@
], ],
"version": "==2.3.0" "version": "==2.3.0"
}, },
"iso8601": {
"hashes": [
"sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df",
"sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242"
],
"markers": "python_version >= '3.7' and python_version < '4.0'",
"version": "==2.1.0"
},
"itsdangerous": { "itsdangerous": {
"hashes": [ "hashes": [
"sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef",
@@ -500,16 +513,15 @@
"sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5" "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.27.0" "version": "==1.27.0"
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31" "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==4.5.1" "version": "==4.9.4"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
@@ -758,11 +770,11 @@
}, },
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.8.0'",
"version": "==14.3.2" "version": "==14.3.3"
}, },
"rich-click": { "rich-click": {
"hashes": [ "hashes": [
@@ -772,6 +784,14 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.9.7" "version": "==1.9.7"
}, },
"serial": {
"hashes": [
"sha256:542150a127ddbf5ed2acc3a6ac4ce807cbcdae3b197acf785bbda6565c94f848",
"sha256:e887f06e07e190e39174b694eee6724e3c48bd361be1d97964caef5d5b61c73b"
],
"index": "pypi",
"version": "==0.0.97"
},
"starlette": { "starlette": {
"hashes": [ "hashes": [
"sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74",
@@ -780,6 +800,43 @@
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==0.52.1" "version": "==0.52.1"
}, },
"tibs": {
"hashes": [
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
],
"markers": "python_version >= '3.8'",
"version": "==0.5.7"
},
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
@@ -798,12 +855,11 @@
}, },
"uvicorn": { "uvicorn": {
"hashes": [ "hashes": [
"sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359",
"sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee" "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'", "version": "==0.42.0"
"version": "==0.40.0"
}, },
"watchfiles": { "watchfiles": {
"hashes": [ "hashes": [
@@ -918,16 +974,15 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"werkzeug": { "werkzeug": {
"hashes": [ "hashes": [
"sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25",
"sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67" "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==3.1.5" "version": "==3.1.6"
} }
}, },
"develop": {} "develop": {}

27
dev.py
View File

@@ -3,11 +3,12 @@
import subprocess import subprocess
import serial import serial
import sys import sys
from pathlib import Path
print(sys.argv) print(sys.argv)
# Extract port (first arg if it's not a command) # Extract port (first arg if it's not a command)
commands = ["src", "lib", "ls", "reset", "follow", "db"] commands = ["src", "lib", "ls", "reset", "follow", "db", "test"]
port = None port = None
if len(sys.argv) > 1 and sys.argv[1] not in commands: if len(sys.argv) > 1 and sys.argv[1] not in commands:
port = sys.argv[1] port = sys.argv[1]
@@ -51,3 +52,27 @@ for cmd in sys.argv[1:]:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ]) subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
else: else:
print("Error: Port required for 'db' command") print("Error: Port required for 'db' command")
case "test":
if port:
if "all" in sys.argv[1:]:
test_files = sorted(
str(path)
for path in Path("test").rglob("*.py")
if path.is_file()
)
failed = []
for test_file in test_files:
print(f"Running {test_file}")
code = subprocess.call(
["mpremote", "connect", port, "run", test_file]
)
if code != 0:
failed.append((test_file, code))
if failed:
print("Some tests failed:")
for test_file, code in failed:
print(f" {test_file} (exit {code})")
else:
subprocess.call(["mpremote", "connect", port, "run", "test/all.py"])
else:
print("Error: Port required for 'test' command")

View File

@@ -48,17 +48,17 @@ Presets define LED patterns with their configuration. Each preset has a name and
- **`pattern`** (required): Pattern type. Options: - **`pattern`** (required): Pattern type. Options:
- `"off"` - Turn off all LEDs - `"off"` - Turn off all LEDs
- `"on"` - Solid color - `"on"` - Solid colour
- `"blink"` - Blinking pattern - `"blink"` - Blinking pattern
- `"rainbow"` - Rainbow color cycle - `"rainbow"` - Rainbow colour cycle
- `"pulse"` - Pulse/fade pattern - `"pulse"` - Pulse/fade pattern
- `"transition"` - Color transition - `"transition"` - Colour transition
- `"chase"` - Chasing pattern - `"chase"` - Chasing pattern
- `"circle"` - Circle loading pattern - `"circle"` - Circle loading pattern
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]` - **`colors`** (optional): Array of hex colour strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colors are automatically converted from hex to RGB and reordered based on device color order setting - Colours are automatically converted from hex to RGB and reordered based on device colour order setting
- Supports multiple colors for patterns that use them - Supports multiple colours for patterns that use them
- **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100` - **`delay`** (optional): Delay in milliseconds between pattern updates. Default: `100`
@@ -74,7 +74,7 @@ Presets define LED patterns with their configuration. Each preset has a name and
### Pattern-Specific Parameters ### Pattern-Specific Parameters
#### Rainbow #### Rainbow
- **`n1`**: Step increment (how many color wheel positions to advance per update). Default: `1` - **`n1`**: Step increment (how many colour wheel positions to advance per update). Default: `1`
#### Pulse #### Pulse
- **`n1`**: Attack time in milliseconds (fade in) - **`n1`**: Attack time in milliseconds (fade in)
@@ -86,8 +86,8 @@ Presets define LED patterns with their configuration. Each preset has a name and
- **`delay`**: Transition duration in milliseconds - **`delay`**: Transition duration in milliseconds
#### Chase #### Chase
- **`n1`**: Number of LEDs with first color - **`n1`**: Number of LEDs with first colour
- **`n2`**: Number of LEDs with second color - **`n2`**: Number of LEDs with second colour
- **`n3`**: Movement amount on even steps (can be negative) - **`n3`**: Movement amount on even steps (can be negative)
- **`n4`**: Movement amount on odd steps (can be negative) - **`n4`**: Movement amount on odd steps (can be negative)
@@ -235,7 +235,7 @@ All devices will start at step 10 and advance together on subsequent beats.
1. **Version Check**: Messages with `v != "1"` are rejected 1. **Version Check**: Messages with `v != "1"` are rejected
2. **Preset Processing**: Presets are created or updated (upsert behavior) 2. **Preset Processing**: Presets are created or updated (upsert behavior)
3. **Color Conversion**: Hex colors are converted to RGB tuples and reordered based on device color order 3. **Colour Conversion**: Hex colours are converted to RGB tuples and reordered based on device colour order
4. **Selection**: Devices select their assigned preset, optionally with step value 4. **Selection**: Devices select their assigned preset, optionally with step value
## Best Practices ## Best Practices
@@ -244,20 +244,20 @@ All devices will start at step 10 and advance together on subsequent beats.
2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns 2. **Use "off" for sync**: Select "off" pattern to synchronize devices before starting patterns
3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns 3. **Beats for manual mode**: Send select messages repeatedly with same preset name to advance manual patterns
4. **Step for precision**: Use step parameter when exact synchronization is required 4. **Step for precision**: Use step parameter when exact synchronization is required
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic 5. **Colour format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
## Error Handling ## Error Handling
- Invalid version: Message is ignored - Invalid version: Message is ignored
- Missing preset: Selection fails, device keeps current preset - Missing preset: Selection fails, device keeps current preset
- Invalid pattern: Selection fails, device keeps current preset - Invalid pattern: Selection fails, device keeps current preset
- Missing colors: Pattern uses default white color - Missing colours: Pattern uses default white colour
- Invalid step: Step value is used as-is (may cause unexpected behavior) - Invalid step: Step value is used as-is (may cause unexpected behavior)
## Notes ## Notes
- Colors are automatically converted from hex strings to RGB tuples - Colours are automatically converted from hex strings to RGB tuples
- Color order reordering happens automatically based on device settings - Colour order reordering happens automatically based on device settings
- Step counter wraps around (0-255 for rainbow, unbounded for others) - Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat - Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed - Auto mode patterns run continuously until changed

View File

@@ -11,11 +11,15 @@ settings = Settings()
print(settings) print(settings)
presets = Presets(settings["led_pin"], settings["num_leds"]) presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load() presets.load(settings)
presets.b = settings.get("brightness", 255) presets.b = settings.get("brightness", 255)
# Use the default preset name from settings (set via controller or defaults) # Use the default preset name from settings (set via controller or defaults)
default_preset = settings.get("default") default_preset = settings.get("default")
if default_preset: if (
isinstance(default_preset, str)
and default_preset
and default_preset in presets.presets
):
presets.select(default_preset) presets.select(default_preset)
print(f"Selected startup preset: {default_preset}") print(f"Selected startup preset: {default_preset}")
@@ -35,9 +39,14 @@ while True:
wdt.feed() wdt.feed()
presets.tick() presets.tick()
if e.any(): if e.any():
try:
host, msg = e.recv() host, msg = e.recv()
print(msg)
data = json.loads(msg) data = json.loads(msg)
print(msg)
except (ValueError, TypeError):
continue
if not isinstance(data, dict):
continue
# Only handle messages with the expected version. # Only handle messages with the expected version.
if data.get("v") != "1": if data.get("v") != "1":
continue continue
@@ -53,23 +62,60 @@ while True:
last_brightness_save = now last_brightness_save = now
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
if "presets" in data: if isinstance(data.get("presets"), dict):
for id, preset_data in data["presets"].items(): for id, preset_data in data["presets"].items():
# Convert hex color strings to RGB tuples and reorder based on device color order if not isinstance(preset_data, dict):
if "c" in preset_data: continue
preset_data["c"] = convert_and_reorder_colors(preset_data["c"], settings) # Convert hex color strings to RGB tuples and reorder based on device colour order.
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
if color_key is not None:
try:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], settings
)
except (TypeError, ValueError):
continue
presets.edit(id, preset_data) presets.edit(id, preset_data)
print(f"Edited preset {id}: {preset_data.get('name', '')}") print(f"Edited preset {id}: {preset_data.get('name', '')}")
if settings.get("name") in data.get("select", {}): if isinstance(data.get("select"), dict) and settings.get("name") in data["select"]:
select_list = data["select"][settings.get("name")] select_list = data["select"][settings.get("name")]
# Select value is always a list: ["preset_name"] or ["preset_name", step] # Select value is always a list: ["preset_name"] or ["preset_name", step]
if select_list: if isinstance(select_list, list) and select_list:
preset_name = select_list[0] preset_name = select_list[0]
step = select_list[1] if len(select_list) > 1 else None step = select_list[1] if len(select_list) > 1 else None
if isinstance(preset_name, str):
presets.select(preset_name, step=step) presets.select(preset_name, step=step)
if "default" in data: if "default" in data:
settings["default"] = data["default"] default_name = data["default"]
print(f"Set startup preset to: {data['default']}") this_device_name = settings.get("name")
this_device_name_norm = (
this_device_name.strip().lower()
if isinstance(this_device_name, str)
else None
)
should_apply_default = True
if "targets" in data:
# When targets are present, default must only apply to named targets.
should_apply_default = False
targets = data.get("targets")
if isinstance(targets, list) and this_device_name_norm:
normalized_targets = [
target.strip().lower()
for target in targets
if isinstance(target, str) and target.strip()
]
should_apply_default = this_device_name_norm in normalized_targets
if not should_apply_default:
print("Ignored default: device not in targets")
if (
should_apply_default
and
isinstance(default_name, str)
and default_name
and default_name in presets.presets
):
settings["default"] = default_name
print(f"Set startup preset to: {default_name}")
settings.save() settings.save()
if "save" in data: if "save" in data:
presets.save() presets.save()

View File

@@ -1,16 +0,0 @@
import asyncio
import aioespnow
import json
async def p2p(settings, patterns):
e = aioespnow.AIOESPNow() # Returns AIOESPNow enhanced with async support
e.active(True)
async for mac, msg in e:
try:
data = json.loads(msg)
except:
print(f"Failed to load espnow data {msg}")
continue
if "names" not in data or settings.get("name") in data.get("names", []):
await settings.set_settings(data.get("settings", {}), patterns, data.get("save", False))

View File

@@ -19,7 +19,44 @@ class Preset:
def edit(self, data=None): def edit(self, data=None):
if not data: if not data:
return False return False
aliases = {
"pattern": "p",
"colors": "c",
"delay": "d",
"brightness": "b",
"auto": "a",
}
int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"}
allowed_fields = {"p", "c", "d", "b", "a", "n1", "n2", "n3", "n4", "n5", "n6"}
for key, value in data.items(): for key, value in data.items():
key = aliases.get(key, key)
if key not in allowed_fields:
continue
if key in int_fields:
try:
parsed = int(value)
if key == "b":
parsed = max(0, min(255, parsed))
elif key in ("d", "n1", "n2", "n3", "n4", "n5", "n6"):
parsed = max(0, parsed)
setattr(self, key, parsed)
except (TypeError, ValueError):
continue
elif key == "a":
if isinstance(value, bool):
self.a = value
elif isinstance(value, int):
self.a = bool(value)
elif isinstance(value, str):
lowered = value.lower()
if lowered in ("true", "1", "yes", "on"):
self.a = True
elif lowered in ("false", "0", "no", "off"):
self.a = False
elif key == "c":
if isinstance(value, (list, tuple)):
self.c = value
else:
setattr(self, key, value) setattr(self, key, value)
return True return True

View File

@@ -2,6 +2,7 @@ from machine import Pin
from neopixel import NeoPixel from neopixel import NeoPixel
from preset import Preset from preset import Preset
from patterns import Blink, Rainbow, Pulse, Transition, Chase, Circle from patterns import Blink, Rainbow, Pulse, Transition, Chase, Circle
from utils import convert_and_reorder_colors
import json import json
@@ -35,8 +36,13 @@ class Presets:
json.dump({name: preset.to_dict() for name, preset in self.presets.items()}, f) json.dump({name: preset.to_dict() for name, preset in self.presets.items()}, f)
return True return True
def load(self): def load(self, settings=None):
"""Load presets from a file.""" """Load presets from a file.
`settings` is used to convert hex strings in `c` to RGB tuples and apply
the device's colour order (same as ESPNow receive). If omitted, RGB order
is assumed.
"""
try: try:
with open("presets.json", "r") as f: with open("presets.json", "r") as f:
data = json.load(f) data = json.load(f)
@@ -46,10 +52,14 @@ class Presets:
self.save() self.save()
return True return True
order = settings if settings is not None else "rgb"
self.presets = {} self.presets = {}
for name, preset_data in data.items(): for name, preset_data in data.items():
if "c" in preset_data: color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
preset_data["c"] = [tuple(color) for color in preset_data["c"]] if color_key is not None:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], order
)
self.presets[name] = Preset(preset_data) self.presets[name] = Preset(preset_data)
if self.presets: if self.presets:
print("Loaded presets:") print("Loaded presets:")
@@ -80,6 +90,9 @@ class Presets:
next(self.generator) next(self.generator)
except StopIteration: except StopIteration:
self.generator = None self.generator = None
except Exception as e:
print(f"Error in tick: {e}")
self.generator = None
def select(self, preset_name, step=None): def select(self, preset_name, step=None):
# Auto-create simple built-in presets for common names on first use # Auto-create simple built-in presets for common names on first use

View File

@@ -33,21 +33,26 @@ def convert_and_reorder_colors(colors, settings_or_color_order):
converted_colors = [] converted_colors = []
for color in colors: for color in colors:
try:
# Convert "#RRGGBB" to (R, G, B) # Convert "#RRGGBB" to (R, G, B)
if isinstance(color, str) and color.startswith("#"): if isinstance(color, str) and color.startswith("#") and len(color) == 7:
r = int(color[1:3], 16) r = int(color[1:3], 16)
g = int(color[3:5], 16) g = int(color[3:5], 16)
b = int(color[5:7], 16) b = int(color[5:7], 16)
rgb = (r, g, b) rgb = (r, g, b)
elif isinstance(color, (list, tuple)) and len(color) == 3:
# Already a tuple/list, just coerce and clamp.
rgb = tuple(max(0, min(255, int(x))) for x in color)
else:
# Unknown format: ignore safely.
continue
# Reorder based on device color order # Reorder based on device color order
reordered = (rgb[channel_order[0]], rgb[channel_order[1]], rgb[channel_order[2]]) reordered = (rgb[channel_order[0]], rgb[channel_order[1]], rgb[channel_order[2]])
converted_colors.append(reordered) converted_colors.append(reordered)
elif isinstance(color, (list, tuple)) and len(color) == 3: except (TypeError, ValueError, IndexError):
# Already a tuple/list, just reorder # Skip malformed color entries to avoid crashing pattern loops.
rgb = tuple(color) continue
reordered = (rgb[channel_order[0]], rgb[channel_order[1]], rgb[channel_order[2]]) if not converted_colors:
converted_colors.append(reordered) converted_colors.append((255, 255, 255))
else:
# Keep as-is if not recognized format
converted_colors.append(color)
return converted_colors return converted_colors

261
test/all.py Normal file
View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Self-contained led-driver test runner for MicroPython/mpremote."""
import json
import os
import utime
from machine import WDT
from settings import Settings
from presets import Presets
from utils import convert_and_reorder_colors
class _TestContext:
def __init__(self):
self.settings = Settings()
self.settings["name"] = self.settings.get("name", "test_device")
self.presets = Presets(self.settings["led_pin"], self.settings["num_leds"])
self.presets.b = self.settings.get("brightness", 255)
self.wdt = WDT(timeout=10000)
def tick_for_ms(self, duration_ms, sleep_ms=5):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
self.wdt.feed()
self.presets.tick()
utime.sleep_ms(sleep_ms)
def _process_message(ctx, payload):
"""Small test helper that mirrors the main message handling logic."""
try:
if isinstance(payload, (bytes, bytearray)):
data = json.loads(payload)
elif isinstance(payload, str):
data = json.loads(payload)
else:
data = payload
except (TypeError, ValueError):
return "invalid_json"
if not isinstance(data, dict):
return "invalid_shape"
if data.get("v") != "1":
return "wrong_version"
if "b" in data:
try:
ctx.presets.b = max(0, min(255, int(data["b"])))
except (TypeError, ValueError):
pass
if isinstance(data.get("presets"), dict):
for name, preset_data in data["presets"].items():
if not isinstance(preset_data, dict):
continue
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
if color_key is not None:
try:
preset_data[color_key] = convert_and_reorder_colors(
preset_data[color_key], ctx.settings
)
except (TypeError, ValueError):
continue
ctx.presets.edit(name, preset_data)
if isinstance(data.get("select"), dict) and ctx.settings.get("name") in data["select"]:
select_list = data["select"][ctx.settings.get("name")]
if isinstance(select_list, list) and select_list:
preset_name = select_list[0]
step = select_list[1] if len(select_list) > 1 else None
if isinstance(preset_name, str):
ctx.presets.select(preset_name, step=step)
if "default" in data:
default_name = data["default"]
this_device_name = ctx.settings.get("name")
this_device_name_norm = (
this_device_name.strip().lower()
if isinstance(this_device_name, str)
else None
)
should_apply_default = True
if "targets" in data:
should_apply_default = False
targets = data.get("targets")
if isinstance(targets, list) and this_device_name_norm:
normalized_targets = [
target.strip().lower()
for target in targets
if isinstance(target, str) and target.strip()
]
should_apply_default = this_device_name_norm in normalized_targets
if (
should_apply_default
and
isinstance(default_name, str)
and default_name
and default_name in ctx.presets.presets
):
ctx.settings["default"] = default_name
if "save" in data:
ctx.presets.save()
return "ok"
def test_invalid_messages_do_not_crash():
ctx = _TestContext()
cases = [
b"{not-json",
"[]",
json.dumps({"v": "2"}),
json.dumps({"v": "1", "presets": ["bad"]}),
json.dumps({"v": "1", "select": {"test_device": "not-list"}}),
json.dumps({"v": "1", "presets": {"x": {"c": ["#GG0000"]}}}),
]
for payload in cases:
_process_message(ctx, payload)
ctx.wdt.feed()
def test_preset_edit_sanitization():
ctx = _TestContext()
ctx.presets.edit(
"sanitize",
{
"pattern": "blink",
"delay": "120",
"brightness": "999",
"auto": "false",
"n1": "-5",
"n2": "7",
"unknown_field": "ignored",
},
)
p = ctx.presets.presets["sanitize"]
assert p.p == "blink"
assert p.d == 120
assert p.b == 255
assert p.a is False
assert p.n1 == 0
assert p.n2 == 7
assert not hasattr(p, "unknown_field")
def test_colour_conversion_and_transition():
ctx = _TestContext()
msg = {
"v": "1",
"presets": {
"fade": {
"p": "transition",
"c": ["#ff0000", "#00ff00"],
"d": 80,
"a": True,
}
},
"select": {ctx.settings["name"]: ["fade"]},
}
result = _process_message(ctx, msg)
assert result == "ok"
assert ctx.presets.selected == "fade"
# Smoke-run the generator to ensure math runs without type errors.
ctx.tick_for_ms(250)
def test_pattern_smoke():
ctx = _TestContext()
cases = {
"t_on": {"p": "on", "c": [(16, 8, 4)]},
"t_off": {"p": "off"},
"t_blink": {"p": "blink", "c": [(255, 0, 0)], "d": 20},
"t_rainbow": {"p": "rainbow", "d": 5, "n1": 2},
"t_pulse": {"p": "pulse", "c": [(255, 0, 0)], "n1": 20, "n2": 10, "n3": 20, "d": 10},
"t_transition": {"p": "transition", "c": [(255, 0, 0), (0, 0, 255)], "d": 30},
"t_chase": {"p": "chase", "c": [(255, 0, 0), (0, 0, 255)], "n1": 3, "n2": 2, "n3": 1, "n4": 1, "d": 20},
"t_circle": {"p": "circle", "c": [(255, 255, 0), (0, 0, 8)], "n1": 5, "n2": 10, "n3": 5, "n4": 2},
}
for name, data in cases.items():
ctx.presets.edit(name, data)
assert ctx.presets.select(name), "select failed: %s" % name
ctx.tick_for_ms(120)
def test_default_requires_existing_preset():
ctx = _TestContext()
_process_message(ctx, {"v": "1", "default": "missing"})
assert ctx.settings.get("default") != "missing"
ctx.presets.edit("exists", {"p": "on"})
_process_message(ctx, {"v": "1", "default": "exists"})
assert ctx.settings.get("default") == "exists"
def test_default_targets_gate_by_device_name():
ctx = _TestContext()
ctx.settings["name"] = "a"
ctx.presets.edit("targeted", {"p": "on"})
ctx.settings["default"] = "baseline"
_process_message(
ctx,
{"v": "1", "default": "targeted", "targets": ["11"]},
)
assert ctx.settings.get("default") == "baseline"
_process_message(
ctx,
{"v": "1", "default": "targeted", "targets": [" A "]},
)
assert ctx.settings.get("default") == "targeted"
def test_save_and_load_roundtrip():
ctx = _TestContext()
ctx.presets.edit(
"persist",
{"p": "blink", "c": [(1, 2, 3), (4, 5, 6)], "d": 77, "b": 123, "a": False},
)
assert ctx.presets.save()
reloaded = Presets(ctx.settings["led_pin"], ctx.settings["num_leds"])
assert reloaded.load(ctx.settings)
p = reloaded.presets.get("persist")
assert p is not None
assert p.p == "blink"
assert p.d == 77
assert p.b == 123
assert p.a is False
assert p.c == [(1, 2, 3), (4, 5, 6)]
try:
os.remove("presets.json")
except OSError:
pass
def run_all():
tests = [
test_invalid_messages_do_not_crash,
test_preset_edit_sanitization,
test_colour_conversion_and_transition,
test_pattern_smoke,
test_default_requires_existing_preset,
test_default_targets_gate_by_device_name,
test_save_and_load_roundtrip,
]
print("=" * 56)
print("led-driver self-contained tests")
print("=" * 56)
for test_func in tests:
print("Running %s ..." % test_func.__name__)
test_func()
print(" PASS")
print("-" * 56)
print("All tests passed")
if __name__ == "__main__":
run_all()

View File

@@ -644,7 +644,7 @@ def test_preset_save_load():
assert patterns.save(), "Save should return True" assert patterns.save(), "Save should return True"
reloaded = Presets(settings["led_pin"], settings["num_leds"]) reloaded = Presets(settings["led_pin"], settings["num_leds"])
assert reloaded.load(), "Load should return True" assert reloaded.load(settings), "Load should return True"
preset = reloaded.presets.get("saved_preset") preset = reloaded.presets.get("saved_preset")
assert preset is not None, "Preset should be loaded" assert preset is not None, "Preset should be loaded"