Compare commits

..

15 Commits

Author SHA1 Message Date
fb53f900fb refactor(driver): simplify and harden espnow message handlers 2026-03-22 02:53:23 +13:00
044dd815dc refactor(driver): harden preset parsing and refresh tooling 2026-03-22 02:00:13 +13:00
f3bcc89320 test(driver): cover default targets and color alias handling 2026-03-22 01:52:15 +13:00
4b74f3ef02 fix(driver): gate targeted default and normalize preset colours 2026-03-22 01:47:14 +13:00
8403f36a1f fix(presets): normalize loaded colours before pattern math 2026-03-22 00:36:53 +13:00
4c7646b2fe Adjust defaults and preset handling
- Switch startup_preset to default key
- Add built-in on/off presets and tweak device defaults

Made-with: Cursor
2026-03-14 02:41:07 +13:00
1616471859 Change startup_preset to default 2026-03-10 22:48:19 +13:00
a06d526ad5 Update Pipfile.lock.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:39:32 +13:00
d82fd9e47c Persist global brightness settings.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:39:29 +13:00
39390b2311 Add patterns package.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 19:08:18 +13:00
3080548f47 Add preset persistence and startup default.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 18:48:44 +13:00
7cc0a3b7d7 Remove unused preset parameter mapping.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:18:48 +13:00
43957adb28 Rename patterns module to presets
Rename the driver module and update imports so tests and main entry use the new presets naming, while moving Preset to its own file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 11:40:04 +13:00
f35d8f7084 Add global brightness support to driver
Handle per-device global brightness via ESPNow messages and apply it alongside per-preset brightness in all patterns.
2026-01-29 00:02:28 +13:00
337e8c9906 Use shortened preset fields in driver
Switch led-driver patterns and main loop to use compact preset keys (p, d, b, c, a, n1..n6) and remove unused settings defaults.
2026-01-28 23:28:54 +13:00
32 changed files with 1490 additions and 863 deletions

View File

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

259
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "1d0184b0df68796cc30d8a808f27b6a5d447b3e1f8af0633b2a543d14f0ab829" "sha256": "5d970f8c0ea9e8ffa98cf0ea5f791161589a97d953d2629da026d01fa7a8bce7"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -34,20 +34,11 @@
}, },
"anyio": { "anyio": {
"hashes": [ "hashes": [
"sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
"sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb" "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==4.12.0" "version": "==4.12.1"
},
"asgiref": {
"hashes": [
"sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4",
"sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==3.11.0"
}, },
"bitarray": { "bitarray": {
"hashes": [ "hashes": [
@@ -160,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": [
@@ -274,89 +265,89 @@
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
"sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
"sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
"sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
"sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
"sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
"sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
"sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
"sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
"sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
"sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
"sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
"sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
"sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
"sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
"sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
"sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
"sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
"sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
"sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
"sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
"sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
"sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
"sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
"sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
"sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
"sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
"sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
"sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
"sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
"sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
"sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
"sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
"sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
"sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
"sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
"sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
"sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
"sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
"sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
"sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
"sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
"sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
"sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
"sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
"sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
"sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
"sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
"sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
"sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422",
"sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849",
"sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c",
"sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963",
"sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"
], ],
"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.3" "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:0503b7b7bc71bc98f7c90c9117d21fdf6147c0d74703011b87936becc86985c1", "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e",
"sha256:624d384d7cda7c096449c889fc776a0571948ba14c3c929fa8e9a78cd0b0a6a8" "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "version": "==0.135.1"
"version": "==0.123.10"
}, },
"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": [
@@ -381,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",
@@ -510,28 +509,27 @@
}, },
"mpremote": { "mpremote": {
"hashes": [ "hashes": [
"sha256:39251644305be718c52bc5965315adc4ae824901750abf6a3fb63683234df05c", "sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
"sha256:61a39bf5af502e1ec56d1b28bf067766c3a0daea9d7487934cb472e378a12fe1" "sha256:6bb75774648091dad6833af4f86c5bf6505f8d7aec211380f9e6996c01d23cb5"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'", "version": "==1.27.0"
"version": "==1.26.1"
}, },
"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": [
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934" "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
], ],
"markers": "implementation_name != 'PyPy'", "markers": "implementation_name != 'PyPy'",
"version": "==2.23" "version": "==3.0"
}, },
"pydantic": { "pydantic": {
"hashes": [ "hashes": [
@@ -772,27 +770,72 @@
}, },
"rich": { "rich": {
"hashes": [ "hashes": [
"sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
"sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd" "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
], ],
"markers": "python_full_version >= '3.8.0'", "markers": "python_full_version >= '3.8.0'",
"version": "==14.2.0" "version": "==14.3.3"
}, },
"rich-click": { "rich-click": {
"hashes": [ "hashes": [
"sha256:af73dc68e85f3bebb80ce302a642b9fe3b65f3df0ceb42eb9a27c467c1b678c8", "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
"sha256:d70f39938bcecaf5543e8750828cbea94ef51853f7d0e174cda1e10543767389" "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.9.4" "version": "==1.9.7"
},
"serial": {
"hashes": [
"sha256:542150a127ddbf5ed2acc3a6ac4ce807cbcdae3b197acf785bbda6565c94f848",
"sha256:e887f06e07e190e39174b694eee6724e3c48bd361be1d97964caef5d5b61c73b"
],
"index": "pypi",
"version": "==0.0.97"
}, },
"starlette": { "starlette": {
"hashes": [ "hashes": [
"sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74",
"sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca" "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==0.50.0" "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": [
@@ -812,12 +855,11 @@
}, },
"uvicorn": { "uvicorn": {
"hashes": [ "hashes": [
"sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359",
"sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d" "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "version": "==0.42.0"
"version": "==0.38.0"
}, },
"watchfiles": { "watchfiles": {
"hashes": [ "hashes": [
@@ -932,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:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905", "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25",
"sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e" "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==3.1.4" "version": "==3.1.6"
} }
}, },
"develop": {} "develop": {}

View File

@@ -26,7 +26,7 @@ MicroPython-based LED driver application for ESP32 microcontrollers.
led-driver/ led-driver/
├── src/ ├── src/
│ ├── main.py # Main application code │ ├── main.py # Main application code
│ ├── patterns.py # LED pattern implementations (includes Preset and Patterns classes) │ ├── presets.py # LED pattern implementations (includes Preset and Presets classes)
│ ├── settings.py # Settings management │ ├── settings.py # Settings management
│ └── p2p.py # Peer-to-peer communication │ └── p2p.py # Peer-to-peer communication
├── test/ # Pattern tests ├── test/ # Pattern tests

0
deploy.sh Normal file
View File

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

@@ -1,15 +1,23 @@
from settings import Settings from settings import Settings
from machine import WDT from machine import WDT
from espnow import ESPNow from espnow import ESPNow
import utime
import network import network
from patterns import Patterns from presets import Presets
from utils import convert_and_reorder_colors from utils import convert_and_reorder_colors
import json import json
settings = Settings() settings = Settings()
print(settings) print(settings)
patterns = Patterns(settings["led_pin"], settings["num_leds"], selected=settings["pattern"]) presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
presets.b = settings.get("brightness", 255)
# Use the default preset name from settings (set via controller or defaults)
default_preset = settings.get("default", "")
if default_preset and default_preset in presets.presets:
presets.select(default_preset)
print(f"Selected startup preset: {default_preset}")
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
wdt.feed() wdt.feed()
@@ -22,25 +30,95 @@ e = ESPNow()
e.active(True) e.active(True)
while True: def as_dict(value):
wdt.feed() return value if isinstance(value, dict) else {}
patterns.tick()
if e.any():
host, msg = e.recv() def as_list(value):
return value if isinstance(value, list) else []
def receive_data(receiver):
"""Read one ESPNow message and decode JSON dict payload."""
try:
host, msg = receiver.recv()
data = json.loads(msg) data = json.loads(msg)
if data["v"] != "1": print(msg)
data = as_dict(data)
if data.get("v", "") != "1":
return None
return data
except (ValueError, TypeError):
return None
def apply_brightness(data):
"""Apply and persist global brightness from payload."""
try:
presets.b = max(0, min(255, int(data["b"])))
settings["brightness"] = presets.b
except (TypeError, ValueError):
pass
def apply_presets(data):
"""Create/update preset definitions from payload."""
presets_map = as_dict(data.get("presets"))
for id, preset_data in presets_map.items():
preset_data = as_dict(preset_data)
if not preset_data:
continue continue
if "presets" in data: color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
for name, preset_data in data["presets"].items(): if color_key is not None:
# Convert hex color strings to RGB tuples and reorder based on device color order try:
if "colors" in preset_data: preset_data[color_key] = convert_and_reorder_colors(
preset_data["colors"] = convert_and_reorder_colors(preset_data["colors"], settings) preset_data[color_key], settings
patterns.edit(name, preset_data) )
if settings.get("name") in data.get("select", {}): except (TypeError, ValueError, KeyError):
select_list = data["select"][settings.get("name")] continue
# Select value is always a list: ["preset_name"] or ["preset_name", step] presets.edit(id, preset_data)
if select_list: print(f"Edited preset {id}: {preset_data.get('name', '')}")
def apply_select(data):
"""Select preset for this device when addressed."""
select_map = as_dict(data.get("select"))
device_name = settings["name"]
# Case-sensitive: select key must match device name exactly.
select_list = as_list(select_map.get(device_name))
if not select_list:
return
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
patterns.select(preset_name, step=step) if isinstance(preset_name, str):
presets.select(preset_name, step=step)
def apply_default(data):
targets = as_list(data.get("targets"))
default_name = data.get("default", "")
if (
settings["name"] in targets
and isinstance(default_name, str)
and default_name in presets.presets
):
settings["default"] = default_name
while True:
wdt.feed()
presets.tick()
if e.any():
if (data := receive_data(e)) is None:
continue
if "b" in data:
apply_brightness(data)
if "presets" in data:
apply_presets(data)
if "select" in data:
apply_select(data)
if "default" in data:
apply_default(data)
if "save" in data and ("presets" in data or "default" in data):
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

@@ -1,500 +0,0 @@
from machine import Pin
from neopixel import NeoPixel
import utime
# Short-key parameter mapping for convenience setters
param_mapping = {
"pt": "selected",
"pa": "selected",
"cl": "colors",
"br": "brightness",
"dl": "delay",
"nl": "num_leds",
"co": "color_order",
"lp": "led_pin",
"n1": "n1",
"n2": "n2",
"n3": "n3",
"n4": "n4",
"n5": "n5",
"n6": "n6",
"auto": "auto",
}
class Preset:
def __init__(self, data):
# Set default values for all preset attributes
self.pattern = "off"
self.delay = 100
self.brightness = 127
self.colors = [(255, 255, 255)]
self.auto = True
self.n1 = 0
self.n2 = 0
self.n3 = 0
self.n4 = 0
self.n5 = 0
self.n6 = 0
# Override defaults with provided data
self.edit(data)
def edit(self, data=None):
if not data:
return False
for key, value in data.items():
setattr(self, key, value)
return True
class Patterns:
def __init__(self, pin, num_leds, brightness=127, selected="off", delay=100):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
self.brightness = brightness
self.step = 0
self.selected = selected
self.generator = None
self.presets = {}
# Register all pattern methods
self.patterns = {
"off": self.off,
"on": self.on,
"blink": self.blink,
"rainbow": self.rainbow,
"pulse": self.pulse,
"transition": self.transition,
"chase": self.chase,
"circle": self.circle,
}
self.select(self.selected)
def edit(self, name, data):
"""Create or update a preset with the given name."""
if name in self.presets:
# Update existing preset
self.presets[name].edit(data)
else:
# Create new preset
self.presets[name] = Preset(data)
return True
def delete(self, name):
if name in self.presets:
del self.presets[name]
return True
return False
def tick(self):
if self.generator is None:
return
try:
next(self.generator)
except StopIteration:
self.generator = None
def select(self, preset_name, step=None):
if preset_name in self.presets:
preset = self.presets[preset_name]
if preset.pattern in self.patterns:
# Set step value if explicitly provided
if step is not None:
self.step = step
elif preset.pattern == "off" or self.selected != preset_name:
self.step = 0
self.generator = self.patterns[preset.pattern](preset)
self.selected = preset_name # Store the preset name, not the object
return True
# If preset doesn't exist or pattern not found, default to "off"
return False
def set_param(self, key, value):
if key in param_mapping:
setattr(self, param_mapping[key], value)
return True
print(f"Invalid parameter: {key}")
return False
def update_num_leds(self, pin, num_leds):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
def apply_brightness(self, color, brightness_override=None):
effective_brightness = brightness_override if brightness_override is not None else self.brightness
return tuple(int(c * effective_brightness / 255) for c in color)
def fill(self, color=None):
fill_color = color if color is not None else (0, 0, 0)
for i in range(self.num_leds):
self.n[i] = fill_color
self.n.write()
def off(self, preset=None):
self.fill((0, 0, 0))
def on(self, preset):
colors = preset.colors
color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.brightness))
def wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def blink(self, preset):
state = True # True = on, False = off
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= preset.delay:
if state:
color = preset.colors[0] if preset.colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.brightness))
else:
self.fill((0, 0, 0))
state = not state
last_update = current_time
# Yield once per tick so other logic can run
yield
def rainbow(self, preset):
step = self.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not preset.auto:
for i in range(self.num_leds):
rc_index = (i * 256 // self.num_leds) + step
self.n[i] = self.apply_brightness(self.wheel(rc_index & 255), preset.brightness)
self.n.write()
# Increment step by n1 for next manual call
self.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
yield
return
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.delay)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.num_leds):
rc_index = (i * 256 // self.num_leds) + step
self.n[i] = self.apply_brightness(self.wheel(rc_index & 255), preset.brightness)
self.n.write()
step = (step + step_amount) % 256
self.step = step
last_update = current_time
# Yield once per tick so other logic can run
yield
def pulse(self, preset):
self.off()
# Get colors from preset
colors = preset.colors
if not colors:
colors = [(255, 255, 255)]
color_index = 0
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
# Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.delay))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
total_ms = 1
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, cycle_start)
base_color = colors[color_index % len(colors)]
if elapsed < attack_ms and attack_ms > 0:
# Attack: fade 0 -> 1
factor = elapsed / attack_ms
color = tuple(int(c * factor) for c in base_color)
self.fill(self.apply_brightness(color, preset.brightness))
elif elapsed < attack_ms + hold_ms:
# Hold: full brightness
self.fill(self.apply_brightness(base_color, preset.brightness))
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
# Decay: fade 1 -> 0
dec_elapsed = elapsed - attack_ms - hold_ms
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
color = tuple(int(c * factor) for c in base_color)
self.fill(self.apply_brightness(color, preset.brightness))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.fill((0, 0, 0))
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not preset.auto:
break
# Skip drawing this tick, start next cycle
yield
continue
# Yield once per tick
yield
def transition(self, preset):
"""Transition between colors, blending over `delay` ms."""
colors = preset.colors
if not colors:
self.off()
yield
return
# Only one color: just keep it on
if len(colors) == 1:
while True:
self.fill(self.apply_brightness(colors[0], preset.brightness))
yield
return
color_index = 0
start_time = utime.ticks_ms()
while True:
if not colors:
break
# Get current and next color based on live list
c1 = colors[color_index % len(colors)]
c2 = colors[(color_index + 1) % len(colors)]
duration = max(10, int(preset.delay)) # At least 10ms
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# End of this transition step
if not preset.auto:
# One-shot: transition from first to second color only
self.fill(self.apply_brightness(c2, preset.brightness))
break
# Auto: move to next pair
color_index = (color_index + 1) % len(colors)
start_time = now
yield
continue
# Interpolate between c1 and c2
factor = elapsed / duration
interpolated = tuple(
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
)
self.fill(self.apply_brightness(interpolated, preset.brightness))
yield
def chase(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
colors = preset.colors
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
color0 = self.apply_brightness(color0, preset.brightness)
color1 = self.apply_brightness(color1, preset.brightness)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
segment_length = n1 + n2
# Calculate position from step_count
step_count = self.step
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position to keep it reasonable
max_pos = self.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# If auto is False, run a single step and then stop
if not preset.auto:
# Clear all LEDs
self.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.n[i] = color0
else:
self.n[i] = color1
self.n.write()
# Increment step for next beat
self.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
last_update = utime.ticks_ms()
transition_duration = max(10, int(preset.delay))
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position
max_pos = self.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# Clear all LEDs
self.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.n[i] = color0
else:
self.n[i] = color1
self.n.write()
# Increment step
step_count += 1
self.step = step_count
last_update = current_time
# Yield once per tick so other logic can run
yield
def circle(self, preset):
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
head = 0
tail = 0
# Calculate timing from preset
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
max_length = max(1, int(preset.n2)) # n2 = max length
min_length = max(0, int(preset.n4)) # n4 = min length
head_delay = 1000 // head_rate # ms between head movements
tail_delay = 1000 // tail_rate # ms between tail movements
last_head_move = utime.ticks_ms()
last_tail_move = utime.ticks_ms()
phase = "growing" # "growing", "shrinking", or "off"
colors = preset.colors
color = self.apply_brightness(colors[0] if colors else (255, 255, 255), preset.brightness)
while True:
current_time = utime.ticks_ms()
# Clear all LEDs
self.n.fill((0, 0, 0))
# Calculate segment length
segment_length = (head - tail) % self.num_leds
if segment_length == 0 and head != tail:
segment_length = self.num_leds
# Draw segment from tail to head
for i in range(segment_length + 1):
led_pos = (tail + i) % self.num_leds
self.n[led_pos] = color
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.num_leds
last_head_move = current_time
# Tail behavior based on phase
if phase == "growing":
# Growing phase: tail stays at 0 until max length reached
if segment_length >= max_length:
phase = "shrinking"
elif phase == "shrinking":
# Shrinking phase: move tail forward at n3 LEDs per second
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
tail = (tail + 1) % self.num_leds
last_tail_move = current_time
# Check if we've reached min length
current_length = (head - tail) % self.num_leds
if current_length == 0 and head != tail:
current_length = self.num_leds
# For min_length = 0, we need at least 1 LED (the head)
if min_length == 0 and current_length <= 1:
phase = "off" # All LEDs off for 1 step
elif min_length > 0 and current_length <= min_length:
phase = "growing" # Cycle repeats
else: # phase == "off"
# Off phase: all LEDs off for 1 step, then restart
tail = head # Reset tail to head position to start fresh
phase = "growing"
self.n.write()
# Yield once per tick so other logic can run
yield

6
src/patterns/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .blink import Blink
from .rainbow import Rainbow
from .pulse import Pulse
from .transition import Transition
from .chase import Chase
from .circle import Circle

33
src/patterns/blink.py Normal file
View File

@@ -0,0 +1,33 @@
import utime
class Blink:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Blink pattern: toggles LEDs on/off using preset delay, cycling through colors."""
# Use provided colors, or default to white if none
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
state = True # True = on, False = off
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
# Re-read delay each loop so live updates to preset.d take effect
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(current_time, last_update) >= delay_ms:
if state:
base_color = colors[color_index % len(colors)]
color = self.driver.apply_brightness(base_color, preset.b)
self.driver.fill(color)
# Advance to next color for the next "on" phase
color_index += 1
else:
# "Off" phase: turn all LEDs off
self.driver.fill((0, 0, 0))
state = not state
last_update = current_time
# Yield once per tick so other logic can run
yield

124
src/patterns/chase.py Normal file
View File

@@ -0,0 +1,124 @@
import utime
class Chase:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Chase pattern: n1 LEDs of color0, n2 LEDs of color1, repeating.
Moves by n3 on even steps, n4 on odd steps (n3/n4 can be positive or negative)"""
colors = preset.c
if len(colors) < 1:
# Need at least 1 color
return
# Access colors, delay, and n values from preset
if not colors:
return
# If only one color provided, use it for both colors
if len(colors) < 2:
color0 = colors[0]
color1 = colors[0]
else:
color0 = colors[0]
color1 = colors[1]
color0 = self.driver.apply_brightness(color0, preset.b)
color1 = self.driver.apply_brightness(color1, preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = int(preset.n3) # Step movement on even steps (can be negative)
n4 = int(preset.n4) # Step movement on odd steps (can be negative)
segment_length = n1 + n2
# Calculate position from step_count
step_count = self.driver.step
# Position alternates: step 0 adds n3, step 1 adds n4, step 2 adds n3, etc.
if step_count % 2 == 0:
# Even steps: (step_count//2) pairs of (n3+n4) plus one extra n3
position = (step_count // 2) * (n3 + n4) + n3
else:
# Odd steps: ((step_count+1)//2) pairs of (n3+n4)
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position to keep it reasonable
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# If auto is False, run a single step and then stop
if not preset.a:
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step for next beat
self.driver.step = step_count + 1
# Allow tick() to advance the generator once
yield
return
# Auto mode: continuous loop
# Use transition_duration for timing and force the first update to happen immediately
transition_duration = max(10, int(preset.d))
last_update = utime.ticks_ms() - transition_duration
while True:
current_time = utime.ticks_ms()
if utime.ticks_diff(current_time, last_update) >= transition_duration:
# Calculate current position from step_count
if step_count % 2 == 0:
position = (step_count // 2) * (n3 + n4) + n3
else:
position = ((step_count + 1) // 2) * (n3 + n4)
# Wrap position
max_pos = self.driver.num_leds + segment_length
position = position % max_pos
if position < 0:
position += max_pos
# Clear all LEDs
self.driver.n.fill((0, 0, 0))
# Draw repeating pattern starting at position
for i in range(self.driver.num_leds):
# Calculate position in the repeating segment
relative_pos = (i - position) % segment_length
if relative_pos < 0:
relative_pos = (relative_pos + segment_length) % segment_length
# Determine which color based on position in segment
if relative_pos < n1:
self.driver.n[i] = color0
else:
self.driver.n[i] = color1
self.driver.n.write()
# Increment step
step_count += 1
self.driver.step = step_count
last_update = current_time
# Yield once per tick so other logic can run
yield

96
src/patterns/circle.py Normal file
View File

@@ -0,0 +1,96 @@
import utime
class Circle:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Circle loading pattern - grows to n2, then tail moves forward at n3 until min length n4"""
head = 0
tail = 0
# Calculate timing from preset
head_rate = max(1, int(preset.n1)) # n1 = head moves per second
tail_rate = max(1, int(preset.n3)) # n3 = tail moves per second
max_length = max(1, int(preset.n2)) # n2 = max length
min_length = max(0, int(preset.n4)) # n4 = min length
head_delay = 1000 // head_rate # ms between head movements
tail_delay = 1000 // tail_rate # ms between tail movements
last_head_move = utime.ticks_ms()
last_tail_move = utime.ticks_ms()
phase = "growing" # "growing", "shrinking", or "off"
# Support up to two colors (like chase). If only one color is provided,
# use black for the second; if none, default to white.
colors = preset.c
if not colors:
base0 = base1 = (255, 255, 255)
elif len(colors) == 1:
base0 = colors[0]
base1 = (0, 0, 0)
else:
base0 = colors[0]
base1 = colors[1]
color0 = self.driver.apply_brightness(base0, preset.b)
color1 = self.driver.apply_brightness(base1, preset.b)
while True:
current_time = utime.ticks_ms()
# Background: use second color during the "off" phase, otherwise clear to black
if phase == "off":
self.driver.n.fill(color1)
else:
self.driver.n.fill((0, 0, 0))
# Calculate segment length
segment_length = (head - tail) % self.driver.num_leds
if segment_length == 0 and head != tail:
segment_length = self.driver.num_leds
# Draw segment from tail to head as a solid color (no per-LED alternation)
current_color = color0
for i in range(segment_length + 1):
led_pos = (tail + i) % self.driver.num_leds
self.driver.n[led_pos] = current_color
# Move head continuously at n1 LEDs per second
if utime.ticks_diff(current_time, last_head_move) >= head_delay:
head = (head + 1) % self.driver.num_leds
last_head_move = current_time
# Tail behavior based on phase
if phase == "growing":
# Growing phase: tail stays at 0 until max length reached
if segment_length >= max_length:
phase = "shrinking"
elif phase == "shrinking":
# Shrinking phase: move tail forward at n3 LEDs per second
if utime.ticks_diff(current_time, last_tail_move) >= tail_delay:
tail = (tail + 1) % self.driver.num_leds
last_tail_move = current_time
# Check if we've reached min length
current_length = (head - tail) % self.driver.num_leds
if current_length == 0 and head != tail:
current_length = self.driver.num_leds
# For min_length = 0, we need at least 1 LED (the head)
if min_length == 0 and current_length <= 1:
phase = "off" # All LEDs off for 1 step
elif min_length > 0 and current_length <= min_length:
phase = "growing" # Cycle repeats
else: # phase == "off"
# Off phase: second color fills the ring for 1 step, then restart
tail = head # Reset tail to head position to start fresh
phase = "growing"
self.driver.n.write()
# Yield once per tick so other logic can run
yield

64
src/patterns/pulse.py Normal file
View File

@@ -0,0 +1,64 @@
import utime
class Pulse:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
self.driver.off()
# Get colors from preset
colors = preset.c
if not colors:
colors = [(255, 255, 255)]
color_index = 0
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
# Read current timing parameters from preset
attack_ms = max(0, int(preset.n1)) # Attack time in ms
hold_ms = max(0, int(preset.n2)) # Hold time in ms
decay_ms = max(0, int(preset.n3)) # Decay time in ms
delay_ms = max(0, int(preset.d))
total_ms = attack_ms + hold_ms + decay_ms + delay_ms
if total_ms <= 0:
total_ms = 1
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, cycle_start)
base_color = colors[color_index % len(colors)]
if elapsed < attack_ms and attack_ms > 0:
# Attack: fade 0 -> 1
factor = elapsed / attack_ms
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < attack_ms + hold_ms:
# Hold: full brightness
self.driver.fill(self.driver.apply_brightness(base_color, preset.b))
elif elapsed < attack_ms + hold_ms + decay_ms and decay_ms > 0:
# Decay: fade 1 -> 0
dec_elapsed = elapsed - attack_ms - hold_ms
factor = max(0.0, 1.0 - (dec_elapsed / decay_ms))
color = tuple(int(c * factor) for c in base_color)
self.driver.fill(self.driver.apply_brightness(color, preset.b))
elif elapsed < total_ms:
# Delay phase: LEDs off between pulses
self.driver.fill((0, 0, 0))
else:
# End of cycle, move to next color and restart timing
color_index += 1
cycle_start = now
if not preset.a:
break
# Skip drawing this tick, start next cycle
yield
continue
# Yield once per tick
yield

51
src/patterns/rainbow.py Normal file
View File

@@ -0,0 +1,51 @@
import utime
class Rainbow:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
elif pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
else:
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def run(self, preset):
step = self.driver.step % 256
step_amount = max(1, int(preset.n1)) # n1 controls step increment
# If auto is False, run a single step and then stop
if not preset.a:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(self._wheel(rc_index & 255), preset.b)
self.driver.n.write()
# Increment step by n1 for next manual call
self.driver.step = (step + step_amount) % 256
# Allow tick() to advance the generator once
yield
return
last_update = utime.ticks_ms()
while True:
current_time = utime.ticks_ms()
sleep_ms = max(1, int(preset.d)) # Get delay from preset
if utime.ticks_diff(current_time, last_update) >= sleep_ms:
for i in range(self.driver.num_leds):
rc_index = (i * 256 // self.driver.num_leds) + step
self.driver.n[i] = self.driver.apply_brightness(
self._wheel(rc_index & 255),
preset.b,
)
self.driver.n.write()
step = (step + step_amount) % 256
self.driver.step = step
last_update = current_time
# Yield once per tick so other logic can run
yield

View File

@@ -0,0 +1,57 @@
import utime
class Transition:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Transition between colors, blending over `delay` ms."""
colors = preset.c
if not colors:
self.driver.off()
yield
return
# Only one color: just keep it on
if len(colors) == 1:
while True:
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
yield
return
color_index = 0
start_time = utime.ticks_ms()
while True:
if not colors:
break
# Get current and next color based on live list
c1 = colors[color_index % len(colors)]
c2 = colors[(color_index + 1) % len(colors)]
duration = max(10, int(preset.d)) # At least 10ms
now = utime.ticks_ms()
elapsed = utime.ticks_diff(now, start_time)
if elapsed >= duration:
# End of this transition step
if not preset.a:
# One-shot: transition from first to second color only
self.driver.fill(self.driver.apply_brightness(c2, preset.b))
break
# Auto: move to next pair
color_index = (color_index + 1) % len(colors)
start_time = now
yield
continue
# Interpolate between c1 and c2
factor = elapsed / duration
interpolated = tuple(
int(c1[i] + (c2[i] - c1[i]) * factor) for i in range(3)
)
self.driver.fill(self.driver.apply_brightness(interpolated, preset.b))
yield

116
src/preset.py Normal file
View File

@@ -0,0 +1,116 @@
class Preset:
def __init__(self, data):
# Set default values for all preset attributes
self.p = "off"
self.d = 100
self.b = 127
self.c = [(255, 255, 255)]
self.a = True
self.n1 = 0
self.n2 = 0
self.n3 = 0
self.n4 = 0
self.n5 = 0
self.n6 = 0
# Override defaults with provided data
self.edit(data)
def edit(self, data=None):
if not data:
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():
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)
return True
@property
def pattern(self):
return self.p
@pattern.setter
def pattern(self, value):
self.p = value
@property
def delay(self):
return self.d
@delay.setter
def delay(self, value):
self.d = value
@property
def brightness(self):
return self.b
@brightness.setter
def brightness(self, value):
self.b = value
@property
def colors(self):
return self.c
@colors.setter
def colors(self, value):
self.c = value
@property
def auto(self):
return self.a
@auto.setter
def auto(self, value):
self.a = value
def to_dict(self):
return {
"p": self.p,
"d": self.d,
"b": self.b,
"c": self.c,
"a": self.a,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"n4": self.n4,
"n5": self.n5,
"n6": self.n6,
}

1
src/presets.json Normal file
View File

@@ -0,0 +1 @@
{"14": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 102, 0]], "b": 255, "n2": 1000, "n1": 2000, "p": "pulse", "n3": 2000, "d": 800}, "15": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 500}, "5": {"n5": 0, "n4": 1, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 5, "n1": 5, "p": "chase", "n3": 1, "d": 200}, "4": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 500}, "7": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[255, 165, 0], [128, 0, 128]], "b": 255, "n2": 10, "n1": 2, "p": "circle", "n3": 2, "d": 200}, "11": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "12": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 0, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "6": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[0, 255, 0]], "b": 255, "n2": 500, "n1": 1000, "p": "pulse", "n3": 1000, "d": 500}, "3": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 2, "p": "rainbow", "n3": 0, "d": 100}, "2": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 0, "n2": 0, "n1": 0, "p": "off", "n3": 0, "d": 100}, "1": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "10": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[230, 242, 255]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "13": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 255]], "b": 255, "n2": 0, "n1": 1, "p": "rainbow", "n3": 0, "d": 150}, "9": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 245, 230]], "b": 200, "n2": 0, "n1": 0, "p": "on", "n3": 0, "d": 100}, "8": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "blink", "n3": 0, "d": 1000}}

142
src/presets.py Normal file
View File

@@ -0,0 +1,142 @@
from machine import Pin
from neopixel import NeoPixel
from preset import Preset
from patterns import Blink, Rainbow, Pulse, Transition, Chase, Circle
from utils import convert_and_reorder_colors
import json
class Presets:
def __init__(self, pin, num_leds):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
self.step = 0
# Global brightness (0255), controlled via ESPNow {"b": <value>}
self.b = 255
self.generator = None
self.presets = {}
self.selected = None
# Register all pattern methods
self.patterns = {
"off": self.off,
"on": self.on,
"blink": Blink(self).run,
"rainbow": Rainbow(self).run,
"pulse": Pulse(self).run,
"transition": Transition(self).run,
"chase": Chase(self).run,
"circle": Circle(self).run,
}
def save(self):
"""Save the presets to a file."""
with open("presets.json", "w") as f:
json.dump({name: preset.to_dict() for name, preset in self.presets.items()}, f)
return True
def load(self, settings=None):
"""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:
with open("presets.json", "r") as f:
data = json.load(f)
except OSError:
# Create an empty presets file if missing
self.presets = {}
self.save()
return True
order = settings if settings is not None else "rgb"
self.presets = {}
for name, preset_data in data.items():
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
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)
if self.presets:
print("Loaded presets:")
#for name in sorted(self.presets.keys()):
# print(f" {name}: {self.presets[name].to_dict()}")
return True
def edit(self, name, data):
"""Create or update a preset with the given name."""
if name in self.presets:
# Update existing preset
self.presets[name].edit(data)
else:
# Create new preset
self.presets[name] = Preset(data)
return True
def delete(self, name):
if name in self.presets:
del self.presets[name]
return True
return False
def tick(self):
if self.generator is None:
return
try:
next(self.generator)
except StopIteration:
self.generator = None
except Exception as e:
print(f"Error in tick: {e}")
self.generator = None
def select(self, preset_name, step=None):
# Auto-create simple built-in presets for common names on first use
if preset_name not in self.presets and preset_name in ("on", "off"):
if preset_name == "on":
self.presets[preset_name] = Preset({"p": "on"})
else:
self.presets[preset_name] = Preset({"p": "off"})
if preset_name in self.presets:
preset = self.presets[preset_name]
if preset.p in self.patterns:
# Set step value if explicitly provided
if step is not None:
self.step = step
elif preset.p == "off" or self.selected != preset_name:
self.step = 0
self.generator = self.patterns[preset.p](preset)
self.selected = preset_name # Store the preset name, not the object
return True
# If preset doesn't exist or pattern not found, indicate failure
return False
def update_num_leds(self, pin, num_leds):
self.n = NeoPixel(Pin(pin, Pin.OUT), num_leds)
self.num_leds = num_leds
def apply_brightness(self, color, brightness_override=None):
# Combine per-preset brightness (override) with global brightness self.b
local = brightness_override if brightness_override is not None else 255
# Scale preset brightness by global brightness
effective_brightness = int(local * self.b / 255)
return tuple(int(c * effective_brightness / 255) for c in color)
def fill(self, color=None):
fill_color = color if color is not None else (0, 0, 0)
for i in range(self.num_leds):
self.n[i] = fill_color
self.n.write()
def off(self, preset=None):
self.fill((0, 0, 0))
def on(self, preset):
colors = preset.c
color = colors[0] if colors else (255, 255, 255)
self.fill(self.apply_brightness(color, preset.b))

View File

@@ -13,15 +13,14 @@ class Settings(dict):
def set_defaults(self): def set_defaults(self):
self["led_pin"] = 10 self["led_pin"] = 10
self["num_leds"] = 50 self["num_leds"] = 119
self["pattern"] = "on"
self["delay"] = 100
self["brightness"] = 10
self["color_order"] = "rgb" self["color_order"] = "rgb"
self["name"] = f"led-{ubinascii.hexlify(network.WLAN(network.AP_IF).config('mac')).decode()}" self["name"] = "a"
self["ap_password"] = ""
self["id"] = 0
self["debug"] = False self["debug"] = False
self["default"] = "on"
self["brightness"] = 32
def save(self): def save(self):
try: try:

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

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, duration_ms): def run_for(p, wdt, duration_ms):
@@ -19,7 +19,7 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
print("=" * 50) print("=" * 50)
@@ -29,11 +29,11 @@ def main():
# Test 1: Rainbow in AUTO mode (continuous) # Test 1: Rainbow in AUTO mode (continuous)
print("\nTest 1: Rainbow pattern in AUTO mode (should run continuously)") print("\nTest 1: Rainbow pattern in AUTO mode (should run continuously)")
p.edit("rainbow_auto", { p.edit("rainbow_auto", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 128, "b": 128,
"delay": 50, "d": 50,
"n1": 2, "n1": 2,
"auto": True "a": True,
}) })
p.select("rainbow_auto") p.select("rainbow_auto")
print("Running rainbow_auto for 3 seconds...") print("Running rainbow_auto for 3 seconds...")
@@ -43,11 +43,11 @@ def main():
# Test 2: Rainbow in MANUAL mode (one step per tick) # Test 2: Rainbow in MANUAL mode (one step per tick)
print("\nTest 2: Rainbow pattern in MANUAL mode (one step per tick)") print("\nTest 2: Rainbow pattern in MANUAL mode (one step per tick)")
p.edit("rainbow_manual", { p.edit("rainbow_manual", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 128, "b": 128,
"delay": 50, "d": 50,
"n1": 2, "n1": 2,
"auto": False "a": False,
}) })
p.select("rainbow_manual") p.select("rainbow_manual")
print("Calling tick() 5 times (should advance 5 steps)...") print("Calling tick() 5 times (should advance 5 steps)...")
@@ -65,14 +65,14 @@ def main():
# Test 3: Pulse in AUTO mode (continuous cycles) # Test 3: Pulse in AUTO mode (continuous cycles)
print("\nTest 3: Pulse pattern in AUTO mode (should pulse continuously)") print("\nTest 3: Pulse pattern in AUTO mode (should pulse continuously)")
p.edit("pulse_auto", { p.edit("pulse_auto", {
"pattern": "pulse", "p": "pulse",
"brightness": 128, "b": 128,
"delay": 100, "d": 100,
"n1": 500, # Attack "n1": 500, # Attack
"n2": 200, # Hold "n2": 200, # Hold
"n3": 500, # Decay "n3": 500, # Decay
"colors": [(255, 0, 0)], "c": [(255, 0, 0)],
"auto": True "a": True,
}) })
p.select("pulse_auto") p.select("pulse_auto")
print("Running pulse_auto for 3 seconds...") print("Running pulse_auto for 3 seconds...")
@@ -82,14 +82,14 @@ def main():
# Test 4: Pulse in MANUAL mode (one cycle then stop) # Test 4: Pulse in MANUAL mode (one cycle then stop)
print("\nTest 4: Pulse pattern in MANUAL mode (one cycle then stop)") print("\nTest 4: Pulse pattern in MANUAL mode (one cycle then stop)")
p.edit("pulse_manual", { p.edit("pulse_manual", {
"pattern": "pulse", "p": "pulse",
"brightness": 128, "b": 128,
"delay": 100, "d": 100,
"n1": 300, # Attack "n1": 300, # Attack
"n2": 200, # Hold "n2": 200, # Hold
"n3": 300, # Decay "n3": 300, # Decay
"colors": [(0, 255, 0)], "c": [(0, 255, 0)],
"auto": False "a": False,
}) })
p.select("pulse_manual") p.select("pulse_manual")
print("Running pulse_manual until generator stops...") print("Running pulse_manual until generator stops...")
@@ -108,11 +108,11 @@ def main():
# Test 5: Transition in AUTO mode (continuous transitions) # Test 5: Transition in AUTO mode (continuous transitions)
print("\nTest 5: Transition pattern in AUTO mode (continuous transitions)") print("\nTest 5: Transition pattern in AUTO mode (continuous transitions)")
p.edit("transition_auto", { p.edit("transition_auto", {
"pattern": "transition", "p": "transition",
"brightness": 128, "b": 128,
"delay": 500, "d": 500,
"colors": [(255, 0, 0), (0, 255, 0), (0, 0, 255)], "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
"auto": True "a": True,
}) })
p.select("transition_auto") p.select("transition_auto")
print("Running transition_auto for 3 seconds...") print("Running transition_auto for 3 seconds...")
@@ -122,11 +122,11 @@ def main():
# Test 6: Transition in MANUAL mode (one transition then stop) # Test 6: Transition in MANUAL mode (one transition then stop)
print("\nTest 6: Transition pattern in MANUAL mode (one transition then stop)") print("\nTest 6: Transition pattern in MANUAL mode (one transition then stop)")
p.edit("transition_manual", { p.edit("transition_manual", {
"pattern": "transition", "p": "transition",
"brightness": 128, "b": 128,
"delay": 500, "d": 500,
"colors": [(255, 0, 0), (0, 255, 0)], "c": [(255, 0, 0), (0, 255, 0)],
"auto": False "a": False,
}) })
p.select("transition_manual") p.select("transition_manual")
print("Running transition_manual until generator stops...") print("Running transition_manual until generator stops...")
@@ -145,11 +145,11 @@ def main():
# Test 7: Switching between auto and manual modes # Test 7: Switching between auto and manual modes
print("\nTest 7: Switching between auto and manual modes") print("\nTest 7: Switching between auto and manual modes")
p.edit("switch_test", { p.edit("switch_test", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 128, "b": 128,
"delay": 50, "d": 50,
"n1": 2, "n1": 2,
"auto": True "a": True,
}) })
p.select("switch_test") p.select("switch_test")
print("Running in auto mode for 1 second...") print("Running in auto mode for 1 second...")
@@ -157,7 +157,7 @@ def main():
# Switch to manual mode by editing the preset # Switch to manual mode by editing the preset
print("Switching to manual mode...") print("Switching to manual mode...")
p.edit("switch_test", {"auto": False}) p.edit("switch_test", {"a": False})
p.select("switch_test") # Re-select to apply changes p.select("switch_test") # Re-select to apply changes
print("Calling tick() 3 times in manual mode...") print("Calling tick() 3 times in manual mode...")
@@ -168,7 +168,7 @@ def main():
# Switch back to auto mode # Switch back to auto mode
print("Switching back to auto mode...") print("Switching back to auto mode...")
p.edit("switch_test", {"auto": True}) p.edit("switch_test", {"a": True})
p.select("switch_test") p.select("switch_test")
print("Running in auto mode for 1 second...") print("Running in auto mode for 1 second...")
run_for(p, wdt, 1000) run_for(p, wdt, 1000)
@@ -176,7 +176,7 @@ def main():
# Cleanup # Cleanup
print("\nCleaning up...") print("\nCleaning up...")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
p.tick() p.tick()
utime.sleep_ms(100) utime.sleep_ms(100)

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def main(): def main():
@@ -10,15 +10,15 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Create blink preset # Create blink preset (use short-key fields: p=pattern, b=brightness, d=delay, c=colors)
p.edit("test_blink", { p.edit("test_blink", {
"pattern": "blink", "p": "blink",
"brightness": 64, "b": 64,
"delay": 200, "d": 200,
"colors": [(255, 0, 0), (0, 0, 255)] "c": [(255, 0, 0), (0, 0, 255)],
}) })
p.select("test_blink") p.select("test_blink")

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, ms): def run_for(p, wdt, ms):
@@ -19,20 +19,20 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1) # Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)
print("Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)") print("Test 1: Basic chase (n1=5, n2=5, n3=1, n4=1)")
p.edit("chase1", { p.edit("chase1", {
"pattern": "chase", "p": "chase",
"brightness": 255, "b": 255,
"delay": 200, "d": 200,
"n1": 5, "n1": 5,
"n2": 5, "n2": 5,
"n3": 1, "n3": 1,
"n4": 1, "n4": 1,
"colors": [(255, 0, 0), (0, 255, 0)] "c": [(255, 0, 0), (0, 255, 0)],
}) })
p.select("chase1") p.select("chase1")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -40,13 +40,13 @@ def main():
# Test 2: Forward and backward (n3=2, n4=-1) # Test 2: Forward and backward (n3=2, n4=-1)
print("Test 2: Forward and backward (n3=2, n4=-1)") print("Test 2: Forward and backward (n3=2, n4=-1)")
p.edit("chase2", { p.edit("chase2", {
"pattern": "chase", "p": "chase",
"n1": 3, "n1": 3,
"n2": 3, "n2": 3,
"n3": 2, "n3": 2,
"n4": -1, "n4": -1,
"delay": 150, "d": 150,
"colors": [(0, 0, 255), (255, 255, 0)] "c": [(0, 0, 255), (255, 255, 0)],
}) })
p.select("chase2") p.select("chase2")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -54,13 +54,13 @@ def main():
# Test 3: Large segments (n1=10, n2=5) # Test 3: Large segments (n1=10, n2=5)
print("Test 3: Large segments (n1=10, n2=5, n3=3, n4=3)") print("Test 3: Large segments (n1=10, n2=5, n3=3, n4=3)")
p.edit("chase3", { p.edit("chase3", {
"pattern": "chase", "p": "chase",
"n1": 10, "n1": 10,
"n2": 5, "n2": 5,
"n3": 3, "n3": 3,
"n4": 3, "n4": 3,
"delay": 200, "d": 200,
"colors": [(255, 128, 0), (128, 0, 255)] "c": [(255, 128, 0), (128, 0, 255)],
}) })
p.select("chase3") p.select("chase3")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -68,13 +68,13 @@ def main():
# Test 4: Fast movement (n3=5, n4=5) # Test 4: Fast movement (n3=5, n4=5)
print("Test 4: Fast movement (n3=5, n4=5)") print("Test 4: Fast movement (n3=5, n4=5)")
p.edit("chase4", { p.edit("chase4", {
"pattern": "chase", "p": "chase",
"n1": 4, "n1": 4,
"n2": 4, "n2": 4,
"n3": 5, "n3": 5,
"n4": 5, "n4": 5,
"delay": 100, "d": 100,
"colors": [(255, 0, 255), (0, 255, 255)] "c": [(255, 0, 255), (0, 255, 255)],
}) })
p.select("chase4") p.select("chase4")
run_for(p, wdt, 2000) run_for(p, wdt, 2000)
@@ -82,13 +82,13 @@ def main():
# Test 5: Backward movement (n3=-2, n4=-2) # Test 5: Backward movement (n3=-2, n4=-2)
print("Test 5: Backward movement (n3=-2, n4=-2)") print("Test 5: Backward movement (n3=-2, n4=-2)")
p.edit("chase5", { p.edit("chase5", {
"pattern": "chase", "p": "chase",
"n1": 6, "n1": 6,
"n2": 4, "n2": 4,
"n3": -2, "n3": -2,
"n4": -2, "n4": -2,
"delay": 200, "d": 200,
"colors": [(255, 255, 255), (0, 0, 0)] "c": [(255, 255, 255), (0, 0, 0)],
}) })
p.select("chase5") p.select("chase5")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -96,13 +96,13 @@ def main():
# Test 6: Alternating forward/backward (n3=3, n4=-2) # Test 6: Alternating forward/backward (n3=3, n4=-2)
print("Test 6: Alternating forward/backward (n3=3, n4=-2)") print("Test 6: Alternating forward/backward (n3=3, n4=-2)")
p.edit("chase6", { p.edit("chase6", {
"pattern": "chase", "p": "chase",
"n1": 5, "n1": 5,
"n2": 5, "n2": 5,
"n3": 3, "n3": 3,
"n4": -2, "n4": -2,
"delay": 250, "d": 250,
"colors": [(255, 0, 0), (0, 255, 0)] "c": [(255, 0, 0), (0, 255, 0)],
}) })
p.select("chase6") p.select("chase6")
run_for(p, wdt, 4000) run_for(p, wdt, 4000)
@@ -110,14 +110,14 @@ def main():
# Test 7: Manual mode - advance one step per beat # Test 7: Manual mode - advance one step per beat
print("Test 7: Manual mode chase (auto=False, n3=2, n4=1)") print("Test 7: Manual mode chase (auto=False, n3=2, n4=1)")
p.edit("chase_manual", { p.edit("chase_manual", {
"pattern": "chase", "p": "chase",
"n1": 4, "n1": 4,
"n2": 4, "n2": 4,
"n3": 2, "n3": 2,
"n4": 1, "n4": 1,
"delay": 200, "d": 200,
"colors": [(255, 255, 0), (0, 255, 255)], "c": [(255, 255, 0), (0, 255, 255)],
"auto": False "a": False,
}) })
p.step = 0 # Reset step counter p.step = 0 # Reset step counter
print(" Advancing pattern with 10 beats (select + tick)...") print(" Advancing pattern with 10 beats (select + tick)...")
@@ -131,12 +131,12 @@ def main():
# Test 8: Verify step increments correctly in manual mode # Test 8: Verify step increments correctly in manual mode
print("Test 8: Verify step increments (auto=False)") print("Test 8: Verify step increments (auto=False)")
p.edit("chase_manual2", { p.edit("chase_manual2", {
"pattern": "chase", "p": "chase",
"n1": 3, "n1": 3,
"n2": 3, "n2": 3,
"n3": 1, "n3": 1,
"n4": 1, "n4": 1,
"auto": False "a": False,
}) })
p.step = 0 p.step = 0
initial_step = p.step initial_step = p.step
@@ -151,7 +151,7 @@ def main():
# Cleanup # Cleanup
print("Test complete, turning off") print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
run_for(p, wdt, 100) run_for(p, wdt, 100)

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, ms): def run_for(p, wdt, ms):
@@ -19,19 +19,19 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0) # Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)
print("Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)") print("Test 1: Basic circle (n1=50, n2=100, n3=200, n4=0)")
p.edit("circle1", { p.edit("circle1", {
"pattern": "circle", "p": "circle",
"brightness": 255, "b": 255,
"n1": 50, # Head moves 50 LEDs/second "n1": 50, # Head moves 50 LEDs/second
"n2": 100, # Max length 100 LEDs "n2": 100, # Max length 100 LEDs
"n3": 200, # Tail moves 200 LEDs/second "n3": 200, # Tail moves 200 LEDs/second
"n4": 0, # Min length 0 LEDs "n4": 0, # Min length 0 LEDs
"colors": [(255, 0, 0)] # Red "c": [(255, 0, 0)], # Red
}) })
p.select("circle1") p.select("circle1")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
@@ -39,12 +39,12 @@ def main():
# Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0) # Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)
print("Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)") print("Test 2: Slow growth, fast shrink (n1=20, n2=50, n3=100, n4=0)")
p.edit("circle2", { p.edit("circle2", {
"pattern": "circle", "p": "circle",
"n1": 20, "n1": 20,
"n2": 50, "n2": 50,
"n3": 100, "n3": 100,
"n4": 0, "n4": 0,
"colors": [(0, 255, 0)] # Green "c": [(0, 255, 0)], # Green
}) })
p.select("circle2") p.select("circle2")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
@@ -52,12 +52,12 @@ def main():
# Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0) # Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)
print("Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)") print("Test 3: Fast growth, slow shrink (n1=100, n2=30, n3=20, n4=0)")
p.edit("circle3", { p.edit("circle3", {
"pattern": "circle", "p": "circle",
"n1": 100, "n1": 100,
"n2": 30, "n2": 30,
"n3": 20, "n3": 20,
"n4": 0, "n4": 0,
"colors": [(0, 0, 255)] # Blue "c": [(0, 0, 255)], # Blue
}) })
p.select("circle3") p.select("circle3")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
@@ -65,12 +65,12 @@ def main():
# Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10) # Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)
print("Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)") print("Test 4: With minimum length (n1=50, n2=40, n3=100, n4=10)")
p.edit("circle4", { p.edit("circle4", {
"pattern": "circle", "p": "circle",
"n1": 50, "n1": 50,
"n2": 40, "n2": 40,
"n3": 100, "n3": 100,
"n4": 10, "n4": 10,
"colors": [(255, 255, 0)] # Yellow "c": [(255, 255, 0)], # Yellow
}) })
p.select("circle4") p.select("circle4")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
@@ -78,12 +78,12 @@ def main():
# Test 5: Very fast (n1=200, n2=20, n3=200, n4=0) # Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)
print("Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)") print("Test 5: Very fast (n1=200, n2=20, n3=200, n4=0)")
p.edit("circle5", { p.edit("circle5", {
"pattern": "circle", "p": "circle",
"n1": 200, "n1": 200,
"n2": 20, "n2": 20,
"n3": 200, "n3": 200,
"n4": 0, "n4": 0,
"colors": [(255, 0, 255)] # Magenta "c": [(255, 0, 255)], # Magenta
}) })
p.select("circle5") p.select("circle5")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -91,19 +91,19 @@ def main():
# Test 6: Very slow (n1=10, n2=25, n3=10, n4=0) # Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)
print("Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)") print("Test 6: Very slow (n1=10, n2=25, n3=10, n4=0)")
p.edit("circle6", { p.edit("circle6", {
"pattern": "circle", "p": "circle",
"n1": 10, "n1": 10,
"n2": 25, "n2": 25,
"n3": 10, "n3": 10,
"n4": 0, "n4": 0,
"colors": [(0, 255, 255)] # Cyan "c": [(0, 255, 255)], # Cyan
}) })
p.select("circle6") p.select("circle6")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
# Cleanup # Cleanup
print("Test complete, turning off") print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
run_for(p, wdt, 100) run_for(p, wdt, 100)

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def main(): def main():
@@ -10,11 +10,11 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Create an "off" preset # Create an "off" preset (use short-key field `p` for pattern)
p.edit("test_off", {"pattern": "off"}) p.edit("test_off", {"p": "off"})
p.select("test_off") p.select("test_off")
start = utime.ticks_ms() start = utime.ticks_ms()

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def main(): def main():
@@ -10,17 +10,19 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Create presets for on and off # Create presets for on and off using the short-key fields that Presets expects
# Preset fields:
# p = pattern name, b = brightness, d = delay, c = list of (r,g,b) colors
p.edit("test_on", { p.edit("test_on", {
"pattern": "on", "p": "on",
"brightness": 64, "b": 64,
"delay": 120, "d": 120,
"colors": [(255, 0, 0), (0, 0, 255)] "c": [(255, 0, 0), (0, 0, 255)],
}) })
p.edit("test_off", {"pattern": "off"}) p.edit("test_off", {"p": "off"})
# ON phase # ON phase
p.select("test_on") p.select("test_on")

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, ms): def run_for(p, wdt, ms):
@@ -19,20 +19,20 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Test 1: Simple single-color pulse # Test 1: Simple single-color pulse
print("Test 1: Single-color pulse (attack=500, hold=500, decay=500, delay=500)") print("Test 1: Single-color pulse (attack=500, hold=500, decay=500, delay=500)")
p.edit("pulse1", { p.edit("pulse1", {
"pattern": "pulse", "p": "pulse",
"brightness": 255, "b": 255,
"colors": [(255, 0, 0)], "c": [(255, 0, 0)],
"n1": 500, # attack ms "n1": 500, # attack ms
"n2": 500, # hold ms "n2": 500, # hold ms
"n3": 500, # decay ms "n3": 500, # decay ms
"delay": 500, # delay ms between pulses "d": 500, # delay ms between pulses
"auto": True "a": True,
}) })
p.select("pulse1") p.select("pulse1")
run_for(p, wdt, 5000) run_for(p, wdt, 5000)
@@ -40,12 +40,12 @@ def main():
# Test 2: Faster pulse # Test 2: Faster pulse
print("Test 2: Fast pulse (attack=100, hold=100, decay=100, delay=100)") print("Test 2: Fast pulse (attack=100, hold=100, decay=100, delay=100)")
p.edit("pulse2", { p.edit("pulse2", {
"pattern": "pulse", "p": "pulse",
"n1": 100, "n1": 100,
"n2": 100, "n2": 100,
"n3": 100, "n3": 100,
"delay": 100, "d": 100,
"colors": [(0, 255, 0)] "c": [(0, 255, 0)],
}) })
p.select("pulse2") p.select("pulse2")
run_for(p, wdt, 4000) run_for(p, wdt, 4000)
@@ -53,13 +53,13 @@ def main():
# Test 3: Multi-color pulse cycle # Test 3: Multi-color pulse cycle
print("Test 3: Multi-color pulse (red -> green -> blue)") print("Test 3: Multi-color pulse (red -> green -> blue)")
p.edit("pulse3", { p.edit("pulse3", {
"pattern": "pulse", "p": "pulse",
"n1": 300, "n1": 300,
"n2": 300, "n2": 300,
"n3": 300, "n3": 300,
"delay": 200, "d": 200,
"colors": [(255, 0, 0), (0, 255, 0), (0, 0, 255)], "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
"auto": True "a": True,
}) })
p.select("pulse3") p.select("pulse3")
run_for(p, wdt, 6000) run_for(p, wdt, 6000)
@@ -67,13 +67,13 @@ def main():
# Test 4: One-shot pulse (auto=False) # Test 4: One-shot pulse (auto=False)
print("Test 4: Single pulse, auto=False") print("Test 4: Single pulse, auto=False")
p.edit("pulse4", { p.edit("pulse4", {
"pattern": "pulse", "p": "pulse",
"n1": 400, "n1": 400,
"n2": 0, "n2": 0,
"n3": 400, "n3": 400,
"delay": 0, "d": 0,
"colors": [(255, 255, 255)], "c": [(255, 255, 255)],
"auto": False "a": False,
}) })
p.select("pulse4") p.select("pulse4")
# Run long enough to allow one full pulse cycle # Run long enough to allow one full pulse cycle
@@ -81,7 +81,7 @@ def main():
# Cleanup # Cleanup
print("Test complete, turning off") print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
run_for(p, wdt, 200) run_for(p, wdt, 200)

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, ms): def run_for(p, wdt, ms):
@@ -19,17 +19,17 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Test 1: Basic rainbow with auto=True (continuous) # Test 1: Basic rainbow with auto=True (continuous)
print("Test 1: Basic rainbow (auto=True, n1=1)") print("Test 1: Basic rainbow (auto=True, n1=1)")
p.edit("rainbow1", { p.edit("rainbow1", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 255, "b": 255,
"delay": 100, "d": 100,
"n1": 1, "n1": 1,
"auto": True "a": True,
}) })
p.select("rainbow1") p.select("rainbow1")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -37,10 +37,10 @@ def main():
# Test 2: Fast rainbow # Test 2: Fast rainbow
print("Test 2: Fast rainbow (low delay, n1=1)") print("Test 2: Fast rainbow (low delay, n1=1)")
p.edit("rainbow2", { p.edit("rainbow2", {
"pattern": "rainbow", "p": "rainbow",
"delay": 50, "d": 50,
"n1": 1, "n1": 1,
"auto": True "a": True,
}) })
p.select("rainbow2") p.select("rainbow2")
run_for(p, wdt, 2000) run_for(p, wdt, 2000)
@@ -48,10 +48,10 @@ def main():
# Test 3: Slow rainbow # Test 3: Slow rainbow
print("Test 3: Slow rainbow (high delay, n1=1)") print("Test 3: Slow rainbow (high delay, n1=1)")
p.edit("rainbow3", { p.edit("rainbow3", {
"pattern": "rainbow", "p": "rainbow",
"delay": 500, "d": 500,
"n1": 1, "n1": 1,
"auto": True "a": True,
}) })
p.select("rainbow3") p.select("rainbow3")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
@@ -59,11 +59,11 @@ def main():
# Test 4: Low brightness rainbow # Test 4: Low brightness rainbow
print("Test 4: Low brightness rainbow (n1=1)") print("Test 4: Low brightness rainbow (n1=1)")
p.edit("rainbow4", { p.edit("rainbow4", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 64, "b": 64,
"delay": 100, "d": 100,
"n1": 1, "n1": 1,
"auto": True "a": True,
}) })
p.select("rainbow4") p.select("rainbow4")
run_for(p, wdt, 2000) run_for(p, wdt, 2000)
@@ -71,11 +71,11 @@ def main():
# Test 5: Single-step rainbow (auto=False) # Test 5: Single-step rainbow (auto=False)
print("Test 5: Single-step rainbow (auto=False, n1=1)") print("Test 5: Single-step rainbow (auto=False, n1=1)")
p.edit("rainbow5", { p.edit("rainbow5", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 255, "b": 255,
"delay": 100, "d": 100,
"n1": 1, "n1": 1,
"auto": False "a": False,
}) })
p.step = 0 p.step = 0
for i in range(10): for i in range(10):
@@ -88,9 +88,9 @@ def main():
# Test 6: Verify step updates correctly # Test 6: Verify step updates correctly
print("Test 6: Verify step updates (auto=False, n1=1)") print("Test 6: Verify step updates (auto=False, n1=1)")
p.edit("rainbow6", { p.edit("rainbow6", {
"pattern": "rainbow", "p": "rainbow",
"n1": 1, "n1": 1,
"auto": False "a": False,
}) })
initial_step = p.step initial_step = p.step
p.select("rainbow6") p.select("rainbow6")
@@ -101,11 +101,11 @@ def main():
# Test 7: Fast step increment (n1=5) # Test 7: Fast step increment (n1=5)
print("Test 7: Fast rainbow (n1=5, auto=True)") print("Test 7: Fast rainbow (n1=5, auto=True)")
p.edit("rainbow7", { p.edit("rainbow7", {
"pattern": "rainbow", "p": "rainbow",
"brightness": 255, "b": 255,
"delay": 100, "d": 100,
"n1": 5, "n1": 5,
"auto": True "a": True,
}) })
p.select("rainbow7") p.select("rainbow7")
run_for(p, wdt, 2000) run_for(p, wdt, 2000)
@@ -113,9 +113,9 @@ def main():
# Test 8: Very fast step increment (n1=10) # Test 8: Very fast step increment (n1=10)
print("Test 8: Very fast rainbow (n1=10, auto=True)") print("Test 8: Very fast rainbow (n1=10, auto=True)")
p.edit("rainbow8", { p.edit("rainbow8", {
"pattern": "rainbow", "p": "rainbow",
"n1": 10, "n1": 10,
"auto": True "a": True,
}) })
p.select("rainbow8") p.select("rainbow8")
run_for(p, wdt, 2000) run_for(p, wdt, 2000)
@@ -123,9 +123,9 @@ def main():
# Test 9: Verify n1 controls step increment (auto=False) # Test 9: Verify n1 controls step increment (auto=False)
print("Test 9: Verify n1 step increment (auto=False, n1=5)") print("Test 9: Verify n1 step increment (auto=False, n1=5)")
p.edit("rainbow9", { p.edit("rainbow9", {
"pattern": "rainbow", "p": "rainbow",
"n1": 5, "n1": 5,
"auto": False "a": False,
}) })
p.step = 0 p.step = 0
initial_step = p.step initial_step = p.step
@@ -141,7 +141,7 @@ def main():
# Cleanup # Cleanup
print("Test complete, turning off") print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
run_for(p, wdt, 100) run_for(p, wdt, 100)

View File

@@ -2,7 +2,7 @@
import utime import utime
from machine import WDT from machine import WDT
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
def run_for(p, wdt, ms): def run_for(p, wdt, ms):
@@ -19,17 +19,17 @@ def main():
pin = s.get("led_pin", 10) pin = s.get("led_pin", 10)
num = s.get("num_leds", 30) num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num) p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000) wdt = WDT(timeout=10000)
# Test 1: Simple two-color transition # Test 1: Simple two-color transition
print("Test 1: Two-color transition (red <-> blue, delay=1000)") print("Test 1: Two-color transition (red <-> blue, delay=1000)")
p.edit("transition1", { p.edit("transition1", {
"pattern": "transition", "p": "transition",
"brightness": 255, "b": 255,
"delay": 1000, # transition duration "d": 1000, # transition duration
"colors": [(255, 0, 0), (0, 0, 255)], "c": [(255, 0, 0), (0, 0, 255)],
"auto": True "a": True,
}) })
p.select("transition1") p.select("transition1")
run_for(p, wdt, 6000) run_for(p, wdt, 6000)
@@ -37,10 +37,10 @@ def main():
# Test 2: Multi-color transition # Test 2: Multi-color transition
print("Test 2: Multi-color transition (red -> green -> blue -> white)") print("Test 2: Multi-color transition (red -> green -> blue -> white)")
p.edit("transition2", { p.edit("transition2", {
"pattern": "transition", "p": "transition",
"delay": 800, "d": 800,
"colors": [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)], "c": [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)],
"auto": True "a": True,
}) })
p.select("transition2") p.select("transition2")
run_for(p, wdt, 8000) run_for(p, wdt, 8000)
@@ -48,10 +48,10 @@ def main():
# Test 3: One-shot transition (auto=False) # Test 3: One-shot transition (auto=False)
print("Test 3: One-shot transition (auto=False)") print("Test 3: One-shot transition (auto=False)")
p.edit("transition3", { p.edit("transition3", {
"pattern": "transition", "p": "transition",
"delay": 1000, "d": 1000,
"colors": [(255, 0, 0), (0, 255, 0)], "c": [(255, 0, 0), (0, 255, 0)],
"auto": False "a": False,
}) })
p.select("transition3") p.select("transition3")
# Run long enough for a single transition step # Run long enough for a single transition step
@@ -60,17 +60,17 @@ def main():
# Test 4: Single-color behavior (should just stay on) # Test 4: Single-color behavior (should just stay on)
print("Test 4: Single-color transition (should hold color)") print("Test 4: Single-color transition (should hold color)")
p.edit("transition4", { p.edit("transition4", {
"pattern": "transition", "p": "transition",
"colors": [(0, 0, 255)], "c": [(0, 0, 255)],
"delay": 500, "d": 500,
"auto": True "a": True,
}) })
p.select("transition4") p.select("transition4")
run_for(p, wdt, 3000) run_for(p, wdt, 3000)
# Cleanup # Cleanup
print("Test complete, turning off") print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"}) p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off") p.select("cleanup_off")
run_for(p, wdt, 200) run_for(p, wdt, 200)

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Test ESPNow receive functionality - runs on MicroPython device.""" """Test ESPNow receive functionality - runs on MicroPython device."""
import json import json
import os
import utime import utime
from settings import Settings from settings import Settings
from patterns import Patterns from presets import Presets
from utils import convert_and_reorder_colors from utils import convert_and_reorder_colors
@@ -93,7 +94,7 @@ def test_version_check():
"""Test that messages with wrong version are rejected.""" """Test that messages with wrong version are rejected."""
print("Test 1: Version check") print("Test 1: Version check")
settings = Settings() settings = Settings()
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -119,7 +120,7 @@ def test_preset_creation():
"""Test preset creation from ESPNow messages.""" """Test preset creation from ESPNow messages."""
print("\nTest 2: Preset creation") print("\nTest 2: Preset creation")
settings = Settings() settings = Settings()
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -164,7 +165,7 @@ def test_color_conversion():
print("\nTest 3: Color conversion") print("\nTest 3: Color conversion")
settings = Settings() settings = Settings()
settings["color_order"] = "rgb" # Default RGB order settings["color_order"] = "rgb" # Default RGB order
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -190,7 +191,7 @@ def test_color_conversion():
# Test GRB order # Test GRB order
settings["color_order"] = "grb" settings["color_order"] = "grb"
patterns2 = Patterns(settings["led_pin"], settings["num_leds"]) patterns2 = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow2 = MockESPNow() mock_espnow2 = MockESPNow()
msg2 = { msg2 = {
"v": "1", "v": "1",
@@ -213,7 +214,7 @@ def test_preset_update():
"""Test that editing an existing preset updates it.""" """Test that editing an existing preset updates it."""
print("\nTest 4: Preset update") print("\nTest 4: Preset update")
settings = Settings() settings = Settings()
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -256,7 +257,7 @@ def test_select():
print("\nTest 5: Preset selection") print("\nTest 5: Preset selection")
settings = Settings() settings = Settings()
settings["name"] = "device1" settings["name"] = "device1"
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -291,7 +292,7 @@ def test_full_message():
print("\nTest 6: Full message (presets + select)") print("\nTest 6: Full message (presets + select)")
settings = Settings() settings = Settings()
settings["name"] = "test_device" settings["name"] = "test_device"
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -331,7 +332,7 @@ def test_switch_presets():
print("\nTest 7: Switch between presets") print("\nTest 7: Switch between presets")
settings = Settings() settings = Settings()
settings["name"] = "switch_device" settings["name"] = "switch_device"
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -427,7 +428,7 @@ def test_beat_functionality():
print("\nTest 8: Beat functionality") print("\nTest 8: Beat functionality")
settings = Settings() settings = Settings()
settings["name"] = "beat_device" settings["name"] = "beat_device"
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -551,7 +552,7 @@ def test_select_with_step():
print("\nTest 9: Select with step value") print("\nTest 9: Select with step value")
settings = Settings() settings = Settings()
settings["name"] = "step_device" settings["name"] = "step_device"
patterns = Patterns(settings["led_pin"], settings["num_leds"]) patterns = Presets(settings["led_pin"], settings["num_leds"])
mock_espnow = MockESPNow() mock_espnow = MockESPNow()
wdt = get_wdt() wdt = get_wdt()
@@ -602,7 +603,7 @@ def test_select_with_step():
print(" ✓ Step preserved when selecting same preset without step (tick advances it)") print(" ✓ Step preserved when selecting same preset without step (tick advances it)")
# Select different preset with step # Select different preset with step
patterns.edit("other_preset", {"pattern": "rainbow", "auto": False}) patterns.edit("other_preset", {"p": "rainbow", "a": False})
mock_espnow.clear() mock_espnow.clear()
msg4 = { msg4 = {
"v": "1", "v": "1",
@@ -621,6 +622,45 @@ def test_select_with_step():
print(" ✓ Step set correctly when switching presets") print(" ✓ Step set correctly when switching presets")
def test_preset_save_load():
"""Test saving and loading presets to/from JSON."""
print("\nTest 10: Preset save/load")
settings = Settings()
patterns = Presets(settings["led_pin"], settings["num_leds"])
patterns.edit("saved_preset", {
"p": "blink",
"d": 150,
"b": 200,
"c": [(1, 2, 3), (4, 5, 6)],
"a": False,
"n1": 1,
"n2": 2,
"n3": 3,
"n4": 4,
"n5": 5,
"n6": 6,
})
assert patterns.save(), "Save should return True"
reloaded = Presets(settings["led_pin"], settings["num_leds"])
assert reloaded.load(settings), "Load should return True"
preset = reloaded.presets.get("saved_preset")
assert preset is not None, "Preset should be loaded"
assert preset.p == "blink", "Pattern should be blink"
assert preset.d == 150, "Delay should be 150"
assert preset.b == 200, "Brightness should be 200"
assert preset.c == [(1, 2, 3), (4, 5, 6)], "Colors should be restored as tuples"
assert preset.a is False, "Auto should be False"
assert (preset.n1, preset.n2, preset.n3, preset.n4, preset.n5, preset.n6) == (1, 2, 3, 4, 5, 6), "n1-n6 should match"
try:
os.remove("presets.json")
except OSError:
pass
print(" ✓ Preset save/load works correctly")
def main(): def main():
"""Run all tests.""" """Run all tests."""
print("=" * 60) print("=" * 60)
@@ -637,6 +677,7 @@ def main():
test_switch_presets() test_switch_presets()
test_beat_functionality() test_beat_functionality()
test_select_with_step() test_select_with_step()
test_preset_save_load()
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("All tests passed! ✓") print("All tests passed! ✓")