59 Commits

Author SHA1 Message Date
3286c4002d chore(settings): update default num_leds and color_order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:56 +12:00
68eb547ec4 feat(espnow): add debug logging and channel diagnostics
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:00:54 +12:00
8403df531d feat(espnow): improve bridge transport and driver sync
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 00:38:09 +12:00
088fe161a8 fix(main): blocking espnow rx loop and pass peer host
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:46 +12:00
c9895df512 fix(presets): phase-lock blink and one tick on re-select
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:46 +12:00
39a84696c3 feat(espnow): ping request/response with jittered delay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:39 +12:00
c7560b2e87 fix(settings): default wifi channel to 5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:36 +12:00
ea21563900 fix(controller): apply select when presets not in message
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-25 21:55:30 +12:00
a97f6c7c2c feat(espnow): groups filter and v1 select list on driver
Apply group membership on RX, accept select as [preset_id, step?],
and fix identify/off plus presets layout for manual beat stepping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 01:44:21 +12:00
1fdb2c9441 fix(espnow): handle binary and JSON RX in simplified main
Use init_espnow for channel alignment; route wire CMD/GROUPS and JSON
v1 payloads to process_data from the poll loop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:45:13 +12:00
3e718f7432 feat(espnow): add wire transport and simplify broadcast main
Binary espnow_wire/espnow_transport modules plus a minimal main that
broadcasts a JSON hello and polls ESP-NOW while running presets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 22:44:39 +12:00
85490a3bd0 feat(deploy): add file_hashes.json manifest on device
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:51 +12:00
94266d5a7c feat(patterns): reverse animation direction via preset n8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:01 +12:00
55a97ac51c feat(patterns): merge pattern styles and add mode support
Consolidate legacy pattern ids into meteor, particles, sparkle, chase,
and colour_cycle with n6/mode style selection; add pattern_modes helper,
self-contained tests/all.py, and preset mode alias on wire.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:14:54 +12:00
794f1a2841 feat(patterns): add northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:32 +12:00
8f8bc894a9 feat(patterns): add icicles blizzard and rime winter effects
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:09:59 +12:00
2a768376d0 chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 16:13:59 +12:00
170a0e05ab feat(patterns): align manual and auto behaviour
Unify manual/auto timing semantics for key patterns, add preset background support, and improve runtime observability while keeping the driver responsive under beat-triggered selects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-09 20:07:58 +12:00
4879fcfe90 fix(patterns): use preset background fallback across animations
Align pattern background rendering to use preset.background_or(...) and update pulse/radiate single-step behaviour to preserve visible frames and step progression.
2026-05-09 14:28:05 +12:00
fbebe9f4f9 fix(patterns): correct non-blocking timing and blink off phase
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:52 +12:00
a79c6f4dd3 fix(patterns): remove blocking sleeps from pattern loops
Replace sleep-based timing in pattern generators with non-blocking tick checks so long delays do not block the main loop and risk watchdog resets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:37:33 +12:00
pi
2fcaf2f064 fix(driver): persist brightness when message includes save and b
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:23 +12:00
pi
3b38264b70 chore(wifi): log connecting while waiting for STA
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:29 +12:00
3ee89ce3b4 feat(driver): add HTTP routes, startup split, and binary envelope support
Wire controller messages through new modules (background tasks, runtime state,
startup) and add binary envelope handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:54:12 +12:00
74b4b495f9 feat(patterns): add expanded animation pack with smoke tests
Add a broad set of new pattern modules and matching pattern smoke scripts so the new effects can be validated directly on-device.
2026-04-23 20:10:01 +12:00
4575ef16ad test(led-driver): add espnow peer and ap pm0 scripts
Made-with: Cursor
2026-04-21 21:48:42 +12:00
a342187635 feat(patterns): add twinkle pattern defaults
Made-with: Cursor
2026-04-21 21:48:42 +12:00
428ed8b884 feat(led-driver): add preset clear command and runtime debug 2026-04-21 00:44:28 +12:00
a22702df4d feat(patterns): add radiate animation 2026-04-20 23:37:43 +12:00
5a8866add7 feat(esp32): pattern upload route and ws controller ip
Made-with: Cursor
2026-04-19 23:27:33 +12:00
a2cd2f8dc2 test(led-driver): add pattern smoke harness
Made-with: Cursor
2026-04-19 23:27:29 +12:00
c47725e31a feat(patterns): add colour cycle, flicker, and flame
Made-with: Cursor
2026-04-19 23:27:19 +12:00
22b1a8a6d6 fix(led-driver): phase-lock pattern timers
Made-with: Cursor
2026-04-19 21:41:18 +12:00
45a38c05b7 fix(led-driver): persist default preset updates 2026-04-15 00:03:21 +12:00
87bd0338bd fix(led-driver): stop loading patterns main and sync tick call 2026-04-14 23:13:16 +12:00
0a33f399e1 refactor(led-driver): simplify websocket runtime and test layout 2026-04-14 22:12:31 +12:00
pi
ded6e3d360 docs: align readme and driver api with tcp/wifi
Made-with: Cursor
2026-04-12 00:13:49 +12:00
pi
a64457a0d5 chore(led-driver): add http_poll client and UDP/mDNS test helpers
Made-with: Cursor
2026-04-11 15:19:07 +12:00
pi
fea4e69140 feat(led-driver): wifi default transport, lazy espnow import, dynamic patterns
Made-with: Cursor
2026-04-11 15:10:12 +12:00
pi
aaaf660e9d feat(driver): discover controller via udp and rediscover on reconnect
Made-with: Cursor
2026-04-06 21:28:00 +12:00
pi
cef9e00819 feat(tcp): include type in wifi hello line
Made-with: Cursor
2026-04-06 00:21:43 +12:00
pi
7e3aca491c refactor(led-driver): transport layout, fixed tcp port, server_ip settings
Made-with: Cursor
2026-04-05 20:59:30 +12:00
pi
7bfdcd9bee feat(led-driver): wifi tcp transport, hex mac in utf-8 hello
Made-with: Cursor
2026-04-05 20:20:47 +12:00
pi
dc19877132 fix(dev): run mpremote from PATH or python -m
Made-with: Cursor
2026-04-05 16:41:23 +12:00
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
112 changed files with 9478 additions and 1222 deletions

View File

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

259
Pipfile.lock generated
View File

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

View File

@@ -1,36 +1,52 @@
# LED Driver - MicroPython
# LED Driver MicroPython
MicroPython-based LED driver application for ESP32 microcontrollers.
MicroPython LED driver for ESP32: presets, patterns, **Wi-Fi** (TCP + UDP discovery) or **ESP-NOW** transport, optional HTTP polling, and dynamic pattern modules under `src/patterns/`.
## Prerequisites
- MicroPython firmware installed on ESP32
- MicroPython firmware on the ESP32
- USB cable for programming
- Python 3 with pipenv
- Python 3 with pipenv (on the host, for `dev.py` / tests)
## Setup
1. Install dependencies:
```bash
pipenv install
```
2. Deploy to device:
2. Deploy to the device:
```bash
pipenv run dev
```
## Project Structure
## Project layout
```
led-driver/
├── src/
│ ├── main.py # Main application code
│ ├── patterns.py # LED pattern implementations (includes Preset and Patterns classes)
│ ├── settings.py # Settings management
── p2p.py # Peer-to-peer communication
├── test/ # Pattern tests
├── web_app.py # Web interface
├── dev.py # Development tools
└── Pipfile # Python dependencies
│ ├── main.py # Entry: Wi-Fi/TCP or ESP-NOW path, process_data(), manifest OTA
│ ├── presets.py # Preset runtime + Presets class
│ ├── preset.py # Single preset helpers
── settings.py # settings.json
│ ├── hello.py # UDP discovery (port 8766) / hello payloads
│ ├── http_poll.py # Optional HTTP polling helper
│ ├── utils.py # Colour conversion / ordering
│ ├── presets.json # Default preset file (on device)
│ └── patterns/ # Pattern modules (.py), loaded dynamically
├── tests/ # Host-side helpers (e.g. udp_client.py, test_mdns.py)
├── test/ # On-device style pattern tests (all.py, patterns/)
├── dev.py # Deploy / sync to serial device
├── docs/API.md # Wire format (long keys); Pi app docs short keys
├── msg.json # Sample message
├── Pipfile
└── LICENSE
```
**Transport:** `settings.json` **`transport_type`** is typically **`wifi`** (TCP to the Pi on port **8765**, discovery on **8766**) or **`espnow`**. ESP-NOW code paths are loaded only when needed so a Wi-Fi-only image stays smaller.
## Further reading
- **`docs/API.md`** — JSON message fields as used in examples (`pattern`, `colors`, …). The Pi app may send **short keys** (`p`, `c`, …); behaviour matches once normalised on device.

8
bulk.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
PORT="${1:-/dev/ttyACM0}"
while true; do
ls "$PORT" && led-cli -p "$PORT" --erase --src --patterns && led-cli -p "$PORT" --reset -f
sleep 0.5
done

0
deploy.sh Normal file
View File

32
dev.py
View File

@@ -1,13 +1,24 @@
#!/usr/bin/env python3
import shutil
import subprocess
import serial
import sys
from pathlib import Path
def mpremote_base():
"""mpremote on PATH, or same interpreter as this script (e.g. pipenv venv)."""
exe = shutil.which("mpremote")
if exe:
return [exe]
return [sys.executable, "-m", "mpremote"]
print(sys.argv)
# 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
if len(sys.argv) > 1 and sys.argv[1] not in commands:
port = sys.argv[1]
@@ -18,17 +29,20 @@ for cmd in sys.argv[1:]:
match cmd:
case "src":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", ".", ":" ], cwd="src")
subprocess.call(
[*mpremote_base(), "connect", port, "fs", "cp", "-r", ".", ":"],
cwd="src",
)
else:
print("Error: Port required for 'src' command")
case "lib":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "lib", ":" ])
subprocess.call([*mpremote_base(), "connect", port, "fs", "cp", "-r", "lib", ":"])
else:
print("Error: Port required for 'lib' command")
case "ls":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "ls", ":" ])
subprocess.call([*mpremote_base(), "connect", port, "fs", "ls", ":"])
else:
print("Error: Port required for 'ls' command")
case "reset":
@@ -48,6 +62,14 @@ for cmd in sys.argv[1:]:
print("Error: Port required for 'follow' command")
case "db":
if port:
subprocess.call(["mpremote", "connect", port, "fs", "cp", "-r", "db", ":" ])
subprocess.call([*mpremote_base(), "connect", port, "fs", "cp", "-r", "db", ":"])
else:
print("Error: Port required for 'db' command")
case "test":
if port:
# Single self-contained suite (tests/all.py); requires ``src`` on device first.
subprocess.call(
[*mpremote_base(), "connect", port, "run", "tests/all.py"]
)
else:
print("Error: Port required for 'test' command")

View File

@@ -1,10 +1,10 @@
# LED Driver ESPNow API Documentation
# LED Driver API (message format)
This document describes the ESPNow message format for controlling LED driver devices.
This document describes the **JSON message format** for controlling LED driver devices. The same object is accepted from **ESP-NOW** (when that transport is enabled) and as **one JSON value per line** over **TCP** in **Wi-Fi** mode (see `src/main.py` on the device).
## Message Format
All messages are JSON objects sent via ESPNow with the following structure:
All messages are JSON objects with the following structure:
```json
{
@@ -48,17 +48,17 @@ Presets define LED patterns with their configuration. Each preset has a name and
- **`pattern`** (required): Pattern type. Options:
- `"off"` - Turn off all LEDs
- `"on"` - Solid color
- `"on"` - Solid colour
- `"blink"` - Blinking pattern
- `"rainbow"` - Rainbow color cycle
- `"rainbow"` - Rainbow colour cycle
- `"pulse"` - Pulse/fade pattern
- `"transition"` - Color transition
- `"transition"` - Colour transition
- `"chase"` - Chasing pattern
- `"circle"` - Circle loading pattern
- **`colors`** (optional): Array of hex color strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colors are automatically converted from hex to RGB and reordered based on device color order setting
- Supports multiple colors for patterns that use them
- **`colors`** (optional): Array of hex colour strings (e.g., `"#FF0000"` for red). Default: `["#FFFFFF"]`
- Colours are automatically converted from hex to RGB and reordered based on device colour order setting
- Supports multiple colours for patterns that use them
- **`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
#### 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
- **`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
#### Chase
- **`n1`**: Number of LEDs with first color
- **`n2`**: Number of LEDs with second color
- **`n1`**: Number of LEDs with first colour
- **`n2`**: Number of LEDs with second colour
- **`n3`**: Movement amount on even 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
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
## 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
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
5. **Color format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
5. **Colour format**: Always use hex strings (`"#RRGGBB"`), conversion is automatic
## Error Handling
- Invalid version: Message is ignored
- Missing preset: 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)
## Notes
- Colors are automatically converted from hex strings to RGB tuples
- Color order reordering happens automatically based on device settings
- Colours are automatically converted from hex strings to RGB tuples
- Colour order reordering happens automatically based on device settings
- Step counter wraps around (0-255 for rainbow, unbounded for others)
- Manual mode patterns stop after one step/cycle, waiting for next beat
- Auto mode patterns run continuously until changed

51
docs/pattern-contract.md Normal file
View File

@@ -0,0 +1,51 @@
# Pattern Contract (Important)
Pattern classes are loaded dynamically by `Presets._load_dynamic_patterns()`.
Patterns must follow this contract exactly.
## Required class shape
- File name is the pattern id (for example `blink.py` -> pattern name `blink`).
- Module exports a class with:
- `__init__(self, driver)` where `driver` is the `Presets` instance.
- `run(self, preset)` that returns a generator.
`Presets` binds patterns like this:
- `pattern_class(self).run`
- then calls `self.patterns[preset.p](preset)` and stores that generator.
- every frame, `Presets.tick()` does `next(self.generator)`.
## `run()` generator rules
- `run()` must `yield` frequently (normally once per tick loop).
- Do not block inside `run()`:
- no `sleep()` / `sleep_ms()` / long loops without `yield`.
- no network or file I/O.
- Use time checks (`utime.ticks_ms()` + `utime.ticks_diff(...)`) to schedule updates.
- Keep pattern state inside local variables in `run()` (or object fields if needed).
## Drawing and brightness
- Use `self.driver.apply_brightness(color, preset.b)` for per-preset brightness.
- Write pixels through `self.driver.n[...]` / `self.driver.n.fill(...)`.
- Flush frame with `self.driver.n.write()`.
- If a pattern needs to clear, use black `(0, 0, 0)`.
## Step semantics
- `self.driver.step` is shared pattern state managed by `Presets.select(...)` and patterns.
- Patterns that use step-based progression should update `self.driver.step` themselves.
- `select(..., step=...)` may set an explicit starting step.
## Error handling
- Let unexpected errors raise inside the generator.
- `Presets.tick()` catches exceptions, logs, and stops the active generator.
- Pattern code should not swallow broad exceptions unless there is a clear recovery path.
## Built-ins
- `off` and `on` are built-in methods on `Presets`, not loaded from this folder.
- `__init__.py` is ignored by dynamic loader.

118
docs/patterns.md Normal file
View File

@@ -0,0 +1,118 @@
# Patterns and presets on the LED driver
This document describes **how patterns are wired**, how **presets** map to patterns, and what each **shipped pattern** expects. For the JSON wire format (`v`: `"1"`, `presets`, `select`, short keys `p` / `c` / `b`, etc.), see [API.md](API.md).
## End-to-end control
1. The controller sends a **v1 JSON** object (ESP-NOW, serial bridge, or one line per message over TCP WebSocket in Wi-Fi mode).
2. `controller_messages.process_data()` parses it and applies fields in a fixed order (see `src/controller_messages.py`):
- `device_config` — name, LED count, colour order, startup mode; may reload `presets.json` and re-select the previous preset.
- `b`**global** output brightness (0255), stored in settings and in `presets.b`.
- `presets` — merge definitions into the in-memory preset table (`Presets.edit()` per id).
- `clear_presets` — optional wipe of all presets.
- `select` — pick the active preset (and optional step) for **this** device (matched by `settings["name"]`).
- `default` — update saved default preset when `targets` includes this device.
- `manifest` — pattern OTA: fetch pattern `.py` files and `reload_patterns()`.
- `save` — persist presets and/or settings when combined with the relevant fields.
3. The main loop calls `presets.tick()` so the active pattern **generator** advances one frame per iteration.
## Presets
- **Class:** `src/preset.py``Preset` holds the pattern configuration.
- **Short keys** (what the driver uses internally after `apply_presets` normalisation):
| Key | Meaning | Default |
|-----|---------|--------|
| `p` | Pattern id (string), must match a registered pattern | `"off"` |
| `c` | Colours as RGB tuples (after colour-order conversion) | `[(255,255,255)]` |
| `d` | Delay (ms); meaning is pattern-specific | `100` |
| `b` | Preset brightness 0255 (combined with global `presets.b`) | `127` |
| `a` | Auto: continuous animation; `false` = manual / beat-stepped where supported | `True` |
| `bg` | Background colour (hex string or RGB tuple on device) | `(0,0,0)` |
| `n1``n6` | Pattern-specific integers | `0` |
Long aliases from the controller (`pattern`, `colors`, `delay`, `brightness`, `auto`, `background`) are converted in `Preset.edit()`.
- **Persistence:** `presets.json` on flash; **`MAX_PRESETS` = 32** (exceptions for auto-created `"on"` / `"off"`).
- **Activation:** `Presets.select(preset_name, step=None)` loads the preset, looks up **`preset.p`** in the pattern registry, and sets `generator = patterns[preset.p](preset)`, then runs one `tick()` so the first frame appears.
## Brightness
- **Global:** `presets.b` from message `{"v":"1","b":…}` scales every output channel.
- **Per preset:** `preset.b`; combined in `Presets.apply_brightness(colour, preset.b)` as
`effective = round(preset_channel * presets.b / 255)` with preset level applied first conceptually (`apply_brightness` takes the presets `b` as the override for that colour).
## Pattern registry
Built in `Presets.reload_patterns()` (`src/presets.py`):
1. **Built-ins:** `"off"` and `"on"` — methods on the `Presets` instance (not separate files).
2. **Dynamic modules:** Every `patterns/*.py` on flash (except `__init__.py`), imported as `patterns.<basename>`. The loader takes the **first class** in the module that defines **`run`**, instantiates it with `Presets(self)` (the driver / NeoPixel wrapper), and registers:
```text
patterns[basename] = PatternClass(driver).run
```
So the **`p` field must equal the file basename without `.py`** (e.g. file `radiate.py` ⇒ pattern `"radiate"`).
### Adding or updating patterns on device
- **OTA:** v1 message with `"manifest"` (URL or inline JSON listing `files` with `name`, `url` or `code`) — see `apply_patterns_ota()` in `controller_messages.py`.
- **HTTP:** `POST /patterns/upload` on the device (`src/main.py`) with a safe `.py` filename; optional reload of the registry.
After new files land in `patterns/`, call `presets.reload_patterns()` (done automatically by OTA and upload when configured).
## Auto vs manual (`a`)
- **`a: true` (auto):** The main loop keeps calling `tick()`; the generator runs continuously (subject to internal `yield` timing / `utime`).
- **`a: false` (manual):** Intended for patterns that advance **once per explicit `select`** (or per beat routing from the controller). The driver does **not** call `select()` again when editing a manual preset-only push — manual steps are driven by incoming `select` messages.
Special case in `Presets.select()`: for **manual chase**, if the same preset is re-selected mid-generator, pending frames may be flushed so step indices stay aligned with beats.
## Built-in patterns
### `off`
- **Registration:** built-in method `Presets.off`.
- **Behaviour:** fills the strip with black (after generator setup, `tick` completes immediately).
- **Parameters:** ignores preset colours for the strip; optional `preset` argument unused for pixels.
### `on`
- **Registration:** built-in method `Presets.on`.
- **Behaviour:** solid fill with `preset.c[0]` (or white if no colours), via `apply_brightness(..., preset.b)`.
- **Parameters:** `c`, `b`; `d` / `n*` not used.
## Dynamic pattern: `radiate`
- **File:** `src/patterns/radiate.py`
- **Class:** `Radiate` — `run(self, preset)` is a **generator** (must `yield` each frame).
- **Pattern key:** `p` = `"radiate"`
Concept: repeating **nodes** along the strip every **`n1`** LEDs; from each node a lit region expands outward then contracts (timed by **`n2`** / **`n3`**). In **auto**, a new pulse train starts every **`d`** ms and the active colour index advances. In **manual**, a **single** out-and-back cycle runs, then the generator ends (next colour on the next `select`).
| Field | Role |
|-------|------|
| `n1` | Node spacing in LEDs (`>= 1`; half-spacing used for symmetry) |
| `n2` | Outbound travel time (ms), `>= 1` |
| `n3` | Return travel time (ms), `>= 1` |
| `d` | Auto only: interval (ms) between re-triggers; `>= 1` |
| `c` | Colour list; cycles per retrigger / per manual cycle |
| `bg` | Off state / gap colour (via `preset.background_or`) |
| `b` | Preset brightness |
| `a` | `true` = repeating pulses on a timer; `false` = one shot per select |
Debug: if `presets.debug` is true (from settings), periodic logs print timing and lit LED counts.
## Other pattern names (`blink`, `rainbow`, `pulse`, …)
Those pattern **ids** are valid on the **wire** and in **led-controller** `db/pattern.json`, but they are **not** all present in this repositorys `src/patterns/` tree. On a real device they normally appear as **additional** `patterns/*.py` files delivered by OTA or upload. For the intended **`n1``n6`** semantics on the wire, use [API.md](API.md) **Pattern-Specific Parameters**; the implementation must match that contract in each modules `run(preset)` generator.
## Quick reference: files
| File | Role |
|------|------|
| `src/preset.py` | Preset field model and aliases |
| `src/presets.py` | Registry, `select`, `tick`, `off` / `on`, dynamic load |
| `src/controller_messages.py` | Parse v1 JSON, apply presets/select/brightness/OTA |
| `src/patterns/*.py` | One pattern module per dynamic id (basename = `p`) |

2
lib/microdot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

8
lib/microdot/helpers.py Normal file
View File

@@ -0,0 +1,8 @@
try:
from functools import wraps
except ImportError: # pragma: no cover
# MicroPython does not currently implement functools.wraps
def wraps(wrapped):
def _(wrapper):
return wrapper
return _

1450
lib/microdot/microdot.py Normal file

File diff suppressed because it is too large Load Diff

225
lib/microdot/session.py Normal file
View File

@@ -0,0 +1,225 @@
try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler
from microdot.helpers import wraps
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""Session handling
:param app: The application instance.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None
def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None:
self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
request.g._session = SessionDict(request, self.decode(session))
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = self.encode(session)
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session,
**self.cookie_options)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.delete_cookie('session', **self.cookie_options)
return response
def encode(self, payload, secret_key=None):
"""Encode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None):
"""Decode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
return wrapper

70
lib/microdot/utemplate.py Normal file
View File

@@ -0,0 +1,70 @@
from utemplate import recompile
_loader = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
@classmethod
def initialize(cls, template_dir='templates',
loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default
is the ``recompile.Loader`` class, which
automatically recompiles templates when they
change.
"""
global _loader
_loader = loader_class(None, template_dir)
def __init__(self, template):
if _loader is None: # pragma: no cover
self.initialize()
#: The name of the template
self.name = template
self.template = _loader.load(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return ''.join(self.generate(*args, **kwargs))
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
class sync_to_async_iter():
def __init__(self, iter):
self.iter = iter
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration
return sync_to_async_iter(self.generate(*args, **kwargs))
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
response = ''
async for chunk in self.generate_async(*args, **kwargs):
response += chunk
return response

231
lib/microdot/websocket.py Normal file
View File

@@ -0,0 +1,231 @@
import binascii
import hashlib
from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
from microdot.helpers import wraps
class WebSocketError(Exception):
"""Exception raised when an error occurs in a WebSocket connection."""
pass
class WebSocket:
"""A WebSocket connection object.
An instance of this class is sent to handler functions to manage the
WebSocket connection.
"""
CONT = 0
TEXT = 1
BINARY = 2
CLOSE = 8
PING = 9
PONG = 10
#: Specify the maximum message size that can be received when calling the
#: ``receive()`` method. Messages with payloads that are larger than this
#: size will be rejected and the connection closed. Set to 0 to disable
#: the size check (be aware of potential security issues if you do this),
#: or to -1 to use the value set in
#: ``Request.max_body_length``. The default is -1.
#:
#: Example::
#:
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
max_message_length = -1
def __init__(self, request):
self.request = request
self.closed = False
async def handshake(self):
response = self._handshake_response()
await self.request.sock[1].awrite(
b'HTTP/1.1 101 Switching Protocols\r\n')
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self):
"""Receive a message from the client."""
while True:
opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover
await self.send(data, send_opcode)
elif data: # pragma: no branch
return data
async def send(self, data, opcode=None):
"""Send a message to the client.
:param data: the data to send, given as a string or bytes.
:param opcode: a custom frame opcode to use. If not given, the opcode
is ``TEXT`` or ``BINARY`` depending on the type of the
data.
"""
frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data)
await self.request.sock[1].awrite(frame)
async def close(self):
"""Close the websocket connection."""
if not self.closed: # pragma: no cover
self.closed = True
await self.send(b'', self.CLOSE)
def _handshake_response(self):
connection = False
upgrade = False
websocket_key = None
for header, value in self.request.headers.items():
h = header.lower()
if h == 'connection':
connection = True
if 'upgrade' not in value.lower():
return self.request.app.abort(400)
elif h == 'upgrade':
upgrade = True
if not value.lower() == 'websocket':
return self.request.app.abort(400)
elif h == 'sec-websocket-key':
websocket_key = value
if not connection or not upgrade or not websocket_key:
return self.request.app.abort(400)
d = hashlib.sha1(websocket_key.encode())
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
return binascii.b2a_base64(d.digest())[:-1]
@classmethod
def _parse_frame_header(cls, header):
fin = header[0] & 0x80
opcode = header[0] & 0x0f
if fin == 0 or opcode == cls.CONT: # pragma: no cover
raise WebSocketError('Continuation frames not supported')
has_mask = header[1] & 0x80
length = header[1] & 0x7f
if length == 126:
length = -2
elif length == 127:
length = -8
return fin, opcode, has_mask, length
def _process_websocket_frame(self, opcode, payload):
if opcode == self.TEXT:
payload = payload.decode()
elif opcode == self.BINARY:
pass
elif opcode == self.CLOSE:
raise WebSocketError('Websocket connection closed')
elif opcode == self.PING:
return self.PONG, payload
elif opcode == self.PONG: # pragma: no branch
return None, None
return None, payload
@classmethod
def _encode_websocket_frame(cls, opcode, payload):
frame = bytearray()
frame.append(0x80 | opcode)
if opcode == cls.TEXT:
payload = payload.encode()
if len(payload) < 126:
frame.append(len(payload))
elif len(payload) < (1 << 16):
frame.append(126)
frame.extend(len(payload).to_bytes(2, 'big'))
else:
frame.append(127)
frame.extend(len(payload).to_bytes(8, 'big'))
frame.extend(payload)
return frame
async def _read_frame(self):
header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover
raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not authenticate_user(request):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def websocket_wrapper(f, upgrade_function):
@wraps(f)
async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request)
try:
await f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise
except WebSocketError:
pass
except Exception as exc:
print_exception(exc)
finally: # pragma: no cover
try:
await ws.close()
except Exception:
pass
return Response.already_handled
return wrapper
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
return websocket_wrapper(f, websocket_upgrade)

View File

14
lib/utemplate/compiled.py Normal file
View File

@@ -0,0 +1,14 @@
class Loader:
def __init__(self, pkg, dir):
if dir == ".":
dir = ""
else:
dir = dir.replace("/", ".") + "."
if pkg and pkg != "__main__":
dir = pkg + "." + dir
self.p = dir
def load(self, name):
name = name.replace(".", "_")
return __import__(self.p + name, None, None, (name,)).render

View File

@@ -0,0 +1,21 @@
# (c) 2014-2020 Paul Sokolovsky. MIT license.
try:
from uos import stat, remove
except:
from os import stat, remove
from . import source
class Loader(source.Loader):
def load(self, name):
o_path = self.pkg_path + self.compiled_path(name)
i_path = self.pkg_path + self.dir + "/" + name
try:
o_stat = stat(o_path)
i_stat = stat(i_path)
if i_stat[8] > o_stat[8]:
# input file is newer, remove output to force recompile
remove(o_path)
finally:
return super().load(name)

188
lib/utemplate/source.py Normal file
View File

@@ -0,0 +1,188 @@
# (c) 2014-2019 Paul Sokolovsky. MIT license.
from . import compiled
class Compiler:
START_CHAR = "{"
STMNT = "%"
STMNT_END = "%}"
EXPR = "{"
EXPR_END = "}}"
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
self.file_in = file_in
self.file_out = file_out
self.loader = loader
self.seq = seq
self._indent = indent
self.stack = []
self.in_literal = False
self.flushed_header = False
self.args = "*a, **d"
def indent(self, adjust=0):
if not self.flushed_header:
self.flushed_header = True
self.indent()
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
self.stack.append("def")
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
def literal(self, s):
if not s:
return
if not self.in_literal:
self.indent()
self.file_out.write('yield """')
self.in_literal = True
self.file_out.write(s.replace('"', '\\"'))
def close_literal(self):
if self.in_literal:
self.file_out.write('"""\n')
self.in_literal = False
def render_expr(self, e):
self.indent()
self.file_out.write('yield str(' + e + ')\n')
def parse_statement(self, stmt):
tokens = stmt.split(None, 1)
if tokens[0] == "args":
if len(tokens) > 1:
self.args = tokens[1]
else:
self.args = ""
elif tokens[0] == "set":
self.indent()
self.file_out.write(stmt[3:].strip() + "\n")
elif tokens[0] == "include":
if not self.flushed_header:
# If there was no other output, we still need a header now
self.indent()
tokens = tokens[1].split(None, 1)
args = ""
if len(tokens) > 1:
args = tokens[1]
if tokens[0][0] == "{":
self.indent()
# "1" as fromlist param is uPy hack
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
self.indent()
self.file_out.write("yield from _.render(%s)\n" % args)
return
with self.loader.input_open(tokens[0][1:-1]) as inc:
self.seq += 1
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
inc_id = self.seq
self.seq = c.compile()
self.indent()
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
elif len(tokens) > 1:
if tokens[0] == "elif":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write(stmt + ":\n")
else:
self.indent()
self.file_out.write(stmt + ":\n")
self.stack.append(tokens[0])
else:
if stmt.startswith("end"):
assert self.stack[-1] == stmt[3:]
self.stack.pop(-1)
elif stmt == "else":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write("else:\n")
else:
assert False
def parse_line(self, l):
while l:
start = l.find(self.START_CHAR)
if start == -1:
self.literal(l)
return
self.literal(l[:start])
self.close_literal()
sel = l[start + 1]
#print("*%s=%s=" % (sel, EXPR))
if sel == self.STMNT:
end = l.find(self.STMNT_END)
assert end > 0
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
self.parse_statement(stmt)
end += len(self.STMNT_END)
l = l[end:]
if not self.in_literal and l == "\n":
break
elif sel == self.EXPR:
# print("EXPR")
end = l.find(self.EXPR_END)
assert end > 0
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
self.render_expr(expr)
end += len(self.EXPR_END)
l = l[end:]
else:
self.literal(l[start])
l = l[start + 1:]
def header(self):
self.file_out.write("# Autogenerated file\n")
def compile(self):
self.header()
for l in self.file_in:
self.parse_line(l)
self.close_literal()
return self.seq
class Loader(compiled.Loader):
def __init__(self, pkg, dir):
super().__init__(pkg, dir)
self.dir = dir
if pkg == "__main__":
# if pkg isn't really a package, don't bother to use it
# it means we're running from "filesystem directory", not
# from a package.
pkg = None
self.pkg_path = ""
if pkg:
p = __import__(pkg)
if isinstance(p.__path__, str):
# uPy
self.pkg_path = p.__path__
else:
# CPy
self.pkg_path = p.__path__[0]
self.pkg_path += "/"
def input_open(self, template):
path = self.pkg_path + self.dir + "/" + template
return open(path)
def compiled_path(self, template):
return self.dir + "/" + template.replace(".", "_") + ".py"
def load(self, name):
try:
return super().load(name)
except (OSError, ImportError):
pass
compiled_path = self.pkg_path + self.compiled_path(name)
f_in = self.input_open(name)
f_out = open(compiled_path, "w")
c = Compiler(f_in, f_out, loader=self)
c.compile()
f_in.close()
f_out.close()
return super().load(name)

1
presets.json Normal file
View File

@@ -0,0 +1 @@
{"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}, "40": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 255, 0]], "b": 255, "n2": 2600, "n1": 35, "p": "flame", "n3": 0, "d": 50}, "41": {"n5": 0, "n4": 5, "a": true, "n6": 0, "c": [[120, 200, 255], [80, 140, 255], [180, 120, 255], [100, 220, 232], [160, 200, 255]], "b": 255, "n2": 10, "n1": 72, "p": "twinkle", "n3": 5, "d": 500}, "42": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[166, 0, 255], [0, 10, 10]], "b": 255, "n2": 900, "n1": 30, "p": "radiate", "n3": 4000, "d": 5000}, "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}, "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}, "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}, "38": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 0, 0], [0, 0, 255]], "b": 255, "n2": 0, "n1": 1, "p": "colour_cycle", "n3": 0, "d": 100}, "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}, "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}, "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}, "39": {"n5": 0, "n4": 0, "a": true, "n6": 0, "c": [[255, 184, 77]], "b": 255, "n2": 0, "n1": 30, "p": "flicker", "n3": 0, "d": 80}, "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}, "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], [255, 255, 255], [0, 0, 255], [255, 255, 0]], "b": 255, "n2": 0, "n1": 0, "p": "transition", "n3": 0, "d": 5000}, "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}}

17
src/background_tasks.py Normal file
View File

@@ -0,0 +1,17 @@
import asyncio
import utime
from mem_stats import print_mem
async def presets_loop(presets, wdt):
last_mem_log = utime.ticks_ms()
while True:
presets.tick()
wdt.feed()
if bool(getattr(presets, "debug", False)):
now = utime.ticks_ms()
if utime.ticks_diff(now, last_mem_log) >= 5000:
print_mem("runtime")
last_mem_log = now
await asyncio.sleep(0)

209
src/binary_envelope.py Normal file
View File

@@ -0,0 +1,209 @@
"""Decode compact binary controller envelopes — v2 native binary, v1 legacy JSON blobs."""
import json
import struct
BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5
def _brightness_0_255_from_wire(wire):
w = max(0, min(127, int(wire)))
return min(255, (w * 255) // 127)
def _decode_preset_record(buf, off):
nl = buf[off]
off += 1
name = buf[off : off + nl].decode("utf-8")
off += nl
pl = buf[off]
off += 1
pattern = buf[off : off + pl].decode("utf-8")
off += pl
nc = buf[off]
off += 1
colors = []
for _ in range(nc):
r, g, b = buf[off], buf[off + 1], buf[off + 2]
off += 3
colors.append("#%02x%02x%02x" % (r, g, b))
if off + 16 > len(buf):
raise ValueError("truncated")
delay, br, auto, n1, n2, n3, n4, n5, n6 = struct.unpack_from(
"<HBBhhhhhh", buf, off
)
off += 16
preset = {
"p": pattern,
"c": colors,
"d": delay,
"b": br,
"a": bool(auto),
"n1": n1,
"n2": n2,
"n3": n3,
"n4": n4,
"n5": n5,
"n6": n6,
}
return name, preset, off
def _decode_presets_blob(chunk):
if not chunk:
return {}
off = 0
count = chunk[off]
off += 1
out = {}
for _ in range(count):
name, preset, off = _decode_preset_record(chunk, off)
out[name] = preset
if off != len(chunk):
raise ValueError("presets blob mismatch")
return out
def _decode_select_blob(chunk):
if not chunk:
return {}
off = 0
count = chunk[off]
off += 1
out = {}
for _ in range(count):
dl = chunk[off]
off += 1
device = chunk[off : off + dl].decode("utf-8")
off += dl
pl = chunk[off]
off += 1
pname = chunk[off : off + pl].decode("utf-8")
off += pl
has_step = chunk[off]
off += 1
if has_step:
step = struct.unpack_from("<H", chunk, off)[0]
off += 2
out[device] = [pname, step]
else:
out[device] = [pname]
if off != len(chunk):
raise ValueError("select blob mismatch")
return out
def _decode_default_blob(chunk):
if not chunk:
return "", []
off = 0
nl = chunk[off]
off += 1
default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
off += nl
nt = chunk[off]
off += 1
targets = []
for _ in range(nt):
tl = chunk[off]
off += 1
targets.append(chunk[off : off + tl].decode("utf-8"))
off += tl
if off != len(chunk):
raise ValueError("default blob mismatch")
return default_name, targets
def parse_binary_envelope_v2(buf):
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_2:
return None
lp = buf[2]
ls = buf[3]
ld = buf[4]
need = HEADER_LEN + lp + ls + ld
if len(buf) != need:
return None
off = HEADER_LEN
presets_chunk = buf[off : off + lp]
off += lp
select_chunk = buf[off : off + ls]
off += ls
default_chunk = buf[off : off + ld]
data = {"v": "1"}
br = buf[1]
if br < 128:
data["b"] = _brightness_0_255_from_wire(br)
try:
if lp:
data["presets"] = _decode_presets_blob(presets_chunk)
if ls:
data["select"] = _decode_select_blob(select_chunk)
if ld:
dname, targets = _decode_default_blob(default_chunk)
data["default"] = dname
data["targets"] = targets
except (ValueError, UnicodeError, TypeError, struct.error):
return None
return data
def parse_binary_envelope_v1(buf):
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_1:
return None
lp = buf[2]
ls = buf[3]
ld = buf[4]
need = HEADER_LEN + lp + ls + ld
if len(buf) != need:
return None
off = HEADER_LEN
presets_chunk = buf[off : off + lp]
off += lp
select_chunk = buf[off : off + ls]
off += ls
default_chunk = buf[off : off + ld]
data = {"v": "1"}
br = buf[1]
if br < 128:
data["b"] = _brightness_0_255_from_wire(br)
if lp:
try:
data["presets"] = json.loads(presets_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if ls:
try:
data["select"] = json.loads(select_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if ld:
try:
extra = json.loads(default_chunk.decode("utf-8"))
except (ValueError, UnicodeError):
return None
if isinstance(extra, dict):
for k, v in extra.items():
data[k] = v
return data
def parse_binary_envelope(buf):
d = parse_binary_envelope_v2(buf)
if d is not None:
return d
return parse_binary_envelope_v1(buf)

462
src/controller_messages.py Normal file
View File

@@ -0,0 +1,462 @@
"""Parse controller JSON (v1) and apply brightness, presets, OTA patterns, etc."""
import json
import socket
import network
import ubinascii
import device_groups as dg
from v1_wire import expand_v1
from binary_envelope import parse_binary_envelope
from utils import convert_and_reorder_colors
try:
import uos as os
except ImportError:
import os
def _log_rx(payload) -> None:
"""Serial log when led-controller sends a message into ``process_data``."""
try:
if isinstance(payload, (bytes, bytearray)):
n = len(payload)
if n == 0:
print("rx 0 B")
return
cap = 160
chunk = payload if n <= cap else payload[:cap]
try:
txt = bytes(chunk).decode("utf-8")
except Exception:
txt = str(chunk)
if n > cap:
txt = txt + "..."
print("rx", n, "B", txt)
else:
s = str(payload)
cap = 200
if len(s) <= cap:
print("rx", len(s), "C", s)
else:
print("rx", len(s), "C", s[:cap] + "...")
except Exception:
print("rx (logging failed)")
def process_data(payload, settings, presets, controller_ip=None, save=False):
"""Read one controller message; binary v2 envelope or JSON v1, then apply fields."""
_log_rx(payload)
data = None
if isinstance(payload, (bytes, bytearray)):
data = parse_binary_envelope(payload)
if data is None:
try:
data = json.loads(payload)
except (ValueError, TypeError):
return
else:
try:
data = json.loads(payload)
except (ValueError, TypeError):
return
if data.get("v", "") != "1":
return
data = expand_v1(data)
if save:
data["save"] = True
set_groups = bool(data.get("set_groups"))
groups = data.get("groups")
if set_groups and isinstance(groups, list):
dg.groups_replace(groups, settings)
print("groups set", dg.list_groups())
elif isinstance(groups, list) and groups:
if not any(dg.in_group(str(g)) for g in groups):
print("ignored: not in groups", groups)
return
if "device_config" in data:
apply_device_config(data, settings, presets)
if "b" in data:
apply_brightness(data, settings, presets)
if "presets" in data:
apply_presets(data, settings, presets)
if "clear_presets" in data:
apply_clear_presets(data, presets)
if ("select" in data or "s" in data) and "presets" not in data:
apply_select(data, settings, presets)
if "default" in data:
apply_default(data, settings, presets)
if "manifest" in data:
apply_patterns_ota(data, presets, controller_ip=controller_ip)
if "save" in data and ("presets" in data or "default" in data):
presets.save()
if "save" in data and "clear_presets" in data:
presets.save()
if "save" in data and "b" in data:
settings.save()
if "save" in data and "device_config" in data:
settings.save()
_flush_pending_select(settings, presets)
_VALID_DEVICE_COLOR_ORDERS = frozenset({"rgb", "rbg", "grb", "gbr", "brg", "bgr"})
_STARTUP_MODES = frozenset({"default", "last", "off"})
_MAX_DEVICE_LEDS = 2048
def apply_startup_pattern(settings, presets):
"""Apply power-on behaviour from ``startup_mode`` (default / last / off)."""
mode = str(settings.get("startup_mode", "default")).lower().strip()
if mode not in _STARTUP_MODES:
mode = "default"
if mode == "off":
if presets.select("off"):
return
presets.fill((0, 0, 0))
return
if mode == "last":
lp = settings.get("last_preset") or ""
if isinstance(lp, str) and lp.strip() and lp.strip() in presets.presets:
if presets.select(lp.strip()):
return
dp = settings.get("default", "")
if dp and dp in presets.presets:
if not presets.select(dp):
print("Startup preset failed (invalid pattern?):", dp)
def apply_device_config(data, settings, presets):
"""Apply fields from v1 ``device_config``; reload presets when strip length or colour order changes."""
dc = data.get("device_config")
if not isinstance(dc, dict):
return
strip_changed = False
meta_changed = False
if "name" in dc:
n = dc["name"]
if isinstance(n, str) and n.strip():
settings["name"] = n.strip()
meta_changed = True
if "num_leds" in dc:
try:
n = int(dc["num_leds"])
if 1 <= n <= _MAX_DEVICE_LEDS:
settings["num_leds"] = n
presets.update_num_leds(settings["led_pin"], n)
strip_changed = True
except (TypeError, ValueError):
pass
if "color_order" in dc:
co = str(dc["color_order"]).lower().strip()
if co in _VALID_DEVICE_COLOR_ORDERS:
settings["color_order"] = co
settings.color_order = settings.get_color_order(co)
strip_changed = True
if "startup_mode" in dc:
sm = str(dc["startup_mode"]).lower().strip()
if sm in _STARTUP_MODES:
settings["startup_mode"] = sm
meta_changed = True
if not strip_changed and not meta_changed:
return
if strip_changed:
prev = presets.selected
try:
presets.load(settings)
except Exception as e:
print("device_config: presets.load failed:", e)
if prev and prev in presets.presets:
presets.select(prev)
elif settings.get("default") and settings["default"] in presets.presets:
presets.select(settings["default"])
def record_last_preset(settings, preset_name):
"""Persist the last selected preset id (single entry in flash)."""
if not isinstance(preset_name, str) or not preset_name:
return
settings["last_preset"] = preset_name.strip()
settings.save()
def apply_brightness(data, settings, presets):
try:
presets.b = max(0, min(255, int(data["b"])))
settings["brightness"] = presets.b
except (TypeError, ValueError):
pass
_pending_select = None
def _run_select(presets, settings, preset_name, step=None):
if presets.select(preset_name, step=step):
record_last_preset(settings, preset_name)
return True
return False
def _flush_pending_select(settings, presets):
global _pending_select
if _pending_select is None:
return
preset_name, step = _pending_select
if preset_name not in presets.presets and preset_name not in ("on", "off"):
return
_pending_select = None
if not _run_select(presets, settings, preset_name, step):
print("select failed (pending):", preset_name)
def apply_presets(data, settings, presets):
global _pending_select
presets_map = data["presets"]
for id, preset_data in presets_map.items():
if not preset_data:
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], settings
)
except (TypeError, ValueError, KeyError) as err:
print("preset color convert failed:", id, err)
if "bg" in preset_data:
try:
bg_color = convert_and_reorder_colors([preset_data["bg"]], settings)
if bg_color:
preset_data["bg"] = bg_color[0]
except (TypeError, ValueError, KeyError):
pass
presets.edit(id, preset_data)
# Same message often carries select; apply now while presets are loaded.
if "select" in data or "s" in data:
apply_select(data, settings, presets)
else:
_flush_pending_select(settings, presets)
def _select_list_for_this_device(select_val, settings):
"""Resolve select to ``[preset_id, step?]`` (wire list or legacy name map)."""
if isinstance(select_val, list) and select_val:
return select_val
if isinstance(select_val, str) and str(select_val).strip():
return [str(select_val).strip()]
if not isinstance(select_val, dict) or not select_val:
return None
if "preset" in select_val:
preset_name = select_val.get("preset")
if preset_name is None:
return None
out = [str(preset_name)]
if "step" in select_val:
out.append(select_val["step"])
return out
device_name = str(settings.get("name") or "").strip()
select_list = select_val.get(device_name)
if select_list:
return select_list
try:
sta = network.WLAN(network.STA_IF)
mac_hex = ubinascii.hexlify(sta.config("mac")).decode().lower()
except Exception:
mac_hex = ""
if mac_hex:
for key in select_val:
k = str(key).lower().replace(":", "").replace("-", "")
if mac_hex in k:
return select_val[key]
if len(select_val) == 1:
return next(iter(select_val.values()))
return None
def apply_select(data, settings, presets):
global _pending_select
select_val = data.get("select")
if select_val is None:
select_val = data.get("s")
select_list = _select_list_for_this_device(select_val, settings)
if not select_list:
print("select ignored:", repr(select_val))
return
preset_name = str(select_list[0]).strip()
if not preset_name:
return
step = select_list[1] if len(select_list) > 1 else None
if preset_name not in presets.presets and preset_name not in ("on", "off"):
try:
presets.load(settings)
except Exception:
pass
if preset_name not in presets.presets and preset_name not in ("on", "off"):
_pending_select = (preset_name, step)
print("select deferred (preset not loaded yet):", preset_name)
return
if _run_select(presets, settings, preset_name, step):
_pending_select = None
else:
print("select failed:", preset_name)
def apply_clear_presets(data, presets):
clear_value = data.get("clear_presets")
if isinstance(clear_value, bool):
should_clear = clear_value
elif isinstance(clear_value, int):
should_clear = bool(clear_value)
elif isinstance(clear_value, str):
should_clear = clear_value.lower() in ("true", "1", "yes", "on")
else:
should_clear = False
if not should_clear:
return
presets.delete_all()
def apply_default(data, settings, presets):
targets = data.get("targets") or []
default_name = data["default"]
if (
settings["name"] in targets
and isinstance(default_name, str)
and default_name in presets.presets
):
settings["default"] = default_name
settings.save()
def _parse_http_url(url):
"""Parse http://host[:port]/path into (host, port, path)."""
if not isinstance(url, str):
raise ValueError("url must be a string")
if not url.startswith("http://"):
raise ValueError("only http:// URLs are supported")
remainder = url[7:]
slash_idx = remainder.find("/")
if slash_idx == -1:
host_port = remainder
path = "/"
else:
host_port = remainder[:slash_idx]
path = remainder[slash_idx:]
if ":" in host_port:
host, port_s = host_port.rsplit(":", 1)
port = int(port_s)
else:
host = host_port
port = 80
if not host:
raise ValueError("missing host")
return host, port, path
def _http_get_raw(url, timeout_s=10.0):
host, port, path = _parse_http_url(url)
req = (
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
).encode("utf-8")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(timeout_s)
sock.connect((host, int(port)))
sock.send(req)
data = b""
while True:
chunk = sock.recv(1024)
if not chunk:
break
data += chunk
finally:
try:
sock.close()
except Exception:
pass
sep = b"\r\n\r\n"
if sep not in data:
raise OSError("invalid HTTP response")
head, body = data.split(sep, 1)
status_line = head.split(b"\r\n", 1)[0]
if b" 200 " not in status_line:
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
return body
def _http_get_json(url, timeout_s=10.0):
body = _http_get_raw(url, timeout_s=timeout_s)
return json.loads(body.decode("utf-8"))
def _http_get_text(url, timeout_s=10.0, controller_ip=None):
# Support relative URLs from controller messages.
if isinstance(url, str) and url.startswith("/"):
if not controller_ip:
raise OSError("controller IP unavailable for relative URL")
url = "http://%s%s" % (controller_ip, url)
try:
body = _http_get_raw(url, timeout_s=timeout_s)
return body.decode("utf-8")
except Exception:
# Fallback for mDNS/unresolvable host: retry against current controller IP.
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
raise
_host, _port, path = _parse_http_url(url)
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
body = _http_get_raw(fallback, timeout_s=timeout_s)
return body.decode("utf-8")
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def apply_patterns_ota(data, presets, controller_ip=None):
manifest_payload = data.get("manifest")
if not manifest_payload:
return
try:
if isinstance(manifest_payload, dict):
manifest = manifest_payload
elif isinstance(manifest_payload, str):
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
else:
print("patterns_ota: invalid manifest payload type")
return
files = manifest.get("files", [])
if not isinstance(files, list) or not files:
print("patterns_ota: no files in manifest")
return
try:
os.mkdir("patterns")
except OSError:
pass
updated = 0
for item in files:
if not isinstance(item, dict):
continue
name = item.get("name")
url = item.get("url")
inline_code = item.get("code")
if not _safe_pattern_filename(name):
continue
if isinstance(inline_code, str):
code = inline_code
elif isinstance(url, str):
code = _http_get_text(url, timeout_s=20.0, controller_ip=controller_ip)
else:
continue
with open("patterns/" + name, "w") as f:
f.write(code)
updated += 1
if updated > 0:
presets.reload_patterns()
except Exception as e:
print("patterns_ota failed:", e)

28
src/device_groups.py Normal file
View File

@@ -0,0 +1,28 @@
"""Group membership for GROUP_CMD filtering; persisted in settings.json."""
_groups = []
def load_from_settings(settings):
global _groups
g = settings.get("groups") if settings is not None else None
if isinstance(g, list):
_groups = [str(x) for x in g if str(x).strip()]
else:
_groups = []
def groups_replace(group_ids, settings=None, *, persist=True):
global _groups
_groups = [str(g) for g in group_ids]
if persist and settings is not None:
settings["groups"] = list(_groups)
settings.save()
def in_group(group_id):
return str(group_id) in _groups
def list_groups():
return list(_groups)

187
src/espnow_transport.py Normal file
View File

@@ -0,0 +1,187 @@
"""ESP-NOW receive loop and boot announce."""
import asyncio
import urandom
import ubinascii
import espnow
import network
import device_groups as dg
from espnow_wire import (
BROADCAST_MAC,
MSG_ANNOUNCE,
MSG_CMD,
MSG_GROUP_CMD,
MSG_GROUPS,
MSG_PING_REQ,
cmd_envelope,
pack_announce,
pack_ping_rsp,
parse_group_cmd,
parse_groups,
parse_ping_req,
wire_msg_type,
)
from controller_messages import process_data
from settings import WIFI_CHANNEL_DEFAULT
_PING_DELAY_MS_MIN = 50
_PING_DELAY_MS_MAX = 500
_esp = None
_groups_received = False
_debug = False
def _dlog(*parts):
if _debug:
print(*parts)
def init_espnow(settings):
global _esp, _debug
_debug = bool(settings.get("debug", False))
try:
ch = int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))
except (TypeError, ValueError):
ch = WIFI_CHANNEL_DEFAULT
ch = max(1, min(11, ch))
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception as e:
print("espnow sta channel set failed:", e)
_esp = espnow.ESPNow()
_esp.active(True)
try:
_esp.add_peer(BROADCAST_MAC)
_dlog("espnow add bcast ok")
except Exception as e:
print("espnow add bcast failed:", e)
try:
actual_ch = sta.config("channel")
except Exception:
actual_ch = "?"
print("espnow init ch", ch, "sta_ch", actual_ch, "debug", _debug)
return _esp
def _send_ping_rsp(host, settings, ping_id, delay_ms):
import utime
utime.sleep_ms(delay_ms)
if _esp is None or not host or len(host) != 6:
return
pkt = pack_ping_rsp(ping_id, settings.get("name", "led"))
try:
try:
_esp.add_peer(host)
_dlog("espnow ping add_peer ok", ubinascii.hexlify(host).decode())
except Exception as e:
_dlog("espnow ping add_peer skip", repr(e))
_esp.send(host, pkt)
print("espnow ping rsp", ping_id, delay_ms, "ms", ubinascii.hexlify(host).decode())
except Exception as e:
print("espnow ping rsp failed:", e, "host", ubinascii.hexlify(host).decode(), "len", len(pkt))
async def _send_ping_rsp_delayed(host, settings, ping_id):
span = _PING_DELAY_MS_MAX - _PING_DELAY_MS_MIN
delay_ms = _PING_DELAY_MS_MIN + (urandom.getrandbits(10) % (span + 1))
await asyncio.sleep(delay_ms / 1000)
_send_ping_rsp(host, settings, ping_id, delay_ms)
def _schedule_ping_rsp(host, settings, ping_id):
span = _PING_DELAY_MS_MAX - _PING_DELAY_MS_MIN
delay_ms = _PING_DELAY_MS_MIN + (urandom.getrandbits(10) % (span + 1))
try:
import _thread
_thread.start_new_thread(_send_ping_rsp, (host, settings, ping_id, delay_ms))
except ImportError:
asyncio.create_task(_send_ping_rsp_delayed(host, settings, ping_id))
def send_boot_announce(settings):
if _esp is None:
return
pkt = pack_announce(
settings.get("name", "led"),
settings.get("num_leds", 1),
color_order=settings.get("color_order", "rgb"),
startup_mode=settings.get("startup_mode", "default"),
brightness=settings.get("brightness", 32),
)
try:
_esp.send(BROADCAST_MAC, pkt)
print("espnow announce", len(pkt), "B")
except Exception as e:
print("espnow announce failed:", e)
def _handle_packet(host, pkt, settings, presets):
global _groups_received
mt = wire_msg_type(pkt)
if mt == MSG_GROUPS:
ids = parse_groups(pkt)
if ids is not None:
dg.groups_replace(ids, settings)
_groups_received = True
print("groups", ids)
return
if mt == MSG_GROUP_CMD:
parsed = parse_group_cmd(pkt)
if parsed is None:
return
gid, env = parsed
if not dg.in_group(gid):
return
from espnow_wire import _envelope_size
need = _envelope_size(env)
save = len(env) > need and env[need] == 1
body = env[:need] if save else env
if body:
process_data(body, settings, presets, save=save)
return
if mt == MSG_CMD:
env, save = cmd_envelope(pkt)
if env:
process_data(env, settings, presets, save=save)
return
if mt == MSG_PING_REQ:
ping_id = parse_ping_req(pkt)
if ping_id is not None and host and len(host) == 6:
_schedule_ping_rsp(host, settings, ping_id)
return
if mt == MSG_ANNOUNCE:
return
async def espnow_receive_loop(settings, presets, wdt=None):
global _groups_received
while True:
if _esp is None:
await asyncio.sleep(0.1)
continue
host, msg = _esp.recv(0)
if not host:
if not _groups_received:
await asyncio.sleep(5)
send_boot_announce(settings)
else:
await asyncio.sleep(0.02)
if wdt:
wdt.feed()
continue
try:
_handle_packet(host, msg, settings, presets)
except Exception as e:
print("espnow rx error:", e)
if wdt:
wdt.feed()

135
src/espnow_wire.py Normal file
View File

@@ -0,0 +1,135 @@
"""ESP-NOW wire format (MicroPython). See docs/espnow-binary-protocol.md in led-controller."""
import struct
WIRE_MAGIC = 0x4C
MAX_ESPNOW_PAYLOAD = 250
MSG_ANNOUNCE = 0x01
MSG_GROUPS = 0x02
MSG_CMD = 0x03
MSG_GROUP_CMD = 0x04
MSG_PING_REQ = 0x05
MSG_PING_RSP = 0x06
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
COLOR_ORDER_TO_ENUM = {
"rgb": 0,
"rbg": 1,
"grb": 2,
"gbr": 3,
"brg": 4,
"bgr": 5,
}
STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2}
def _pack_header(msg_type, body):
pkt = bytes([WIRE_MAGIC, msg_type]) + body
if len(pkt) > MAX_ESPNOW_PAYLOAD:
raise ValueError("packet too large")
return pkt
def pack_announce(
name,
num_leds,
color_order="rgb",
startup_mode="default",
brightness=32,
device_type=0,
):
name_b = name.encode("utf-8")
co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0)
sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0)
body = (
bytes([len(name_b)])
+ name_b
+ struct.pack("<H", int(num_leds))
+ bytes([co & 7, sm & 3, max(0, min(255, int(brightness))), device_type & 255])
)
return _pack_header(MSG_ANNOUNCE, body)
def parse_groups(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_GROUPS:
return None
body = payload[2:]
else:
body = payload
if not body:
return []
off = 0
count = body[off]
off += 1
out = []
for _ in range(count):
gl = body[off]
off += 1
out.append(body[off : off + gl].decode("utf-8"))
off += gl
return out
def parse_group_cmd(payload):
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD:
return None
body = payload[2:]
gl = body[0]
gid = body[1 : 1 + gl].decode("utf-8")
env = body[1 + gl :]
return gid, env
HEADER_LEN = 5
def _envelope_size(env):
if len(env) < HEADER_LEN:
return len(env)
lp, ls, ld = env[2], env[3], env[4]
return HEADER_LEN + lp + ls + ld
def cmd_envelope(payload):
if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD:
return None, False
env = payload[2:]
if not env:
return None, False
need = _envelope_size(env)
if need > len(env):
return None, False
save = len(env) > need and env[need] == 1
return env[:need], save
def pack_ping_req(ping_id):
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF)
return _pack_header(MSG_PING_REQ, body)
def parse_ping_req(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
if payload[1] != MSG_PING_REQ:
return None
body = payload[2:]
else:
body = payload
if len(body) < 4:
return None
return struct.unpack("<I", body[:4])[0]
def pack_ping_rsp(ping_id, name):
name_b = name.encode("utf-8")
body = struct.pack("<I", int(ping_id) & 0xFFFFFFFF) + bytes([len(name_b)]) + name_b
return _pack_header(MSG_PING_RSP, body)
def wire_msg_type(payload):
if len(payload) >= 2 and payload[0] == WIRE_MAGIC:
return payload[1]
return None

101
src/file_hashes.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Deploy hash manifest at flash root (file_hashes.json).
Updated by led-cli after directory uploads; used to skip unchanged files on
the next deploy. Format: {"version": 1, "algorithm": "sha256", "files": {...}}
"""
import json
import os
MANIFEST_VERSION = 1
MANIFEST_FILENAME = "file_hashes.json"
HASH_ALGO = "sha256"
_SKIP_NAMES = frozenset({MANIFEST_FILENAME, "__pycache__"})
_SKIP_SUFFIXES = (".pyc", ".pyo")
def _normalize_path(path):
return path.replace("\\", "/").lstrip("/")
def load():
"""Return path -> sha256 hex map, or {} if missing or invalid."""
try:
with open(MANIFEST_FILENAME, "r") as f:
doc = json.load(f)
except OSError:
return {}
if not isinstance(doc, dict):
return {}
files = doc.get("files")
return files if isinstance(files, dict) else {}
def save(files):
"""Write manifest (path keys use forward slashes, no leading slash)."""
if not isinstance(files, dict):
files = {}
doc = {
"version": MANIFEST_VERSION,
"algorithm": HASH_ALGO,
"files": files,
}
with open(MANIFEST_FILENAME, "w") as f:
json.dump(doc, f)
def _hash_file(path):
import hashlib
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(256)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def _walk_dir(base, prefix, out):
try:
names = os.listdir(base)
except OSError:
return
for name in names:
if name in _SKIP_NAMES or name.endswith(_SKIP_SUFFIXES):
continue
full = base + "/" + name if base else name
key = _normalize_path((prefix + "/" + name) if prefix else name)
try:
mode = os.stat(full)[0]
except OSError:
continue
if mode & 0x4000:
_walk_dir(full, key, out)
else:
out[key] = _hash_file(full)
def rebuild():
"""Rebuild manifest from root .py files plus patterns/ and lib/ trees."""
files = {}
try:
for name in os.listdir("."):
if name in _SKIP_NAMES or name.endswith(_SKIP_SUFFIXES):
continue
try:
mode = os.stat(name)[0]
except OSError:
continue
if mode & 0x4000:
if name in ("patterns", "lib"):
_walk_dir(name, name, files)
else:
files[_normalize_path(name)] = _hash_file(name)
except OSError:
pass
save(files)
return files

174
src/hello.py Normal file
View File

@@ -0,0 +1,174 @@
"""LED hello JSON line and UDP broadcast on port 8766.
Used so led-controller can register the device (name, MAC, IP) when ``wait_reply`` is
false; the controller may then connect to the device's WebSocket. With
``wait_reply`` true, blocks for an echo and returns the controller IP (legacy discovery).
Wi-Fi must already be connected; this module does not use Settings or call connect().
"""
import json
import socket
import ubinascii
import network
# Match led-controller/tests/udp_server.py
DISCOVERY_UDP_PORT = 8766
DEFAULT_RECV_TIMEOUT_S = 3
def pack_hello_dict(sta, device_name=""):
"""Same fields as main HTTP/ESP-NOW hello."""
mac = sta.config("mac")
return {
"v": "1",
"device_name": device_name,
"mac": ubinascii.hexlify(mac).decode().lower(),
"type": "led",
}
def pack_hello_bytes(sta, device_name=""):
return json.dumps(pack_hello_dict(sta, device_name)).encode("utf-8")
def pack_hello_line(sta, device_name=""):
"""JSON hello + newline (HTTP/UDP discovery payloads)."""
return pack_hello_bytes(sta, device_name) + b"\n"
def ipv4_broadcast(ip, netmask):
"""Directed broadcast (e.g. 192.168.1.0/24 -> 192.168.1.255)."""
ia = [int(x) for x in ip.split(".")]
im = [int(x) for x in netmask.split(".")]
if len(ia) != 4 or len(im) != 4:
return None
# STA often reports 255.255.255.255; "broadcast" would equal the host IP — useless for LAN.
if netmask == "255.255.255.255":
return None
bcast = ".".join(str(ia[i] | (255 - im[i])) for i in range(4))
if bcast == ip:
return None
return bcast
def udp_discovery_targets(ip, mask):
"""(directed_broadcast, port) then limited broadcast."""
out = [("255.255.255.255", DISCOVERY_UDP_PORT)]
b = ipv4_broadcast(ip, mask)
if b:
out.insert(0, (b, DISCOVERY_UDP_PORT))
return out
def _udp_discovery_targets_single(ip, mask):
"""One destination: subnet broadcast if known, else limited broadcast."""
b = ipv4_broadcast(ip, mask)
if b:
return [(b, DISCOVERY_UDP_PORT)]
return [("255.255.255.255", DISCOVERY_UDP_PORT)]
def broadcast_hello_udp(
sta,
device_name="",
*,
wait_reply=True,
recv_timeout_s=DEFAULT_RECV_TIMEOUT_S,
wdt=None,
dual_destinations=True,
):
"""
Send pack_hello_line on DISCOVERY_UDP_PORT.
STA must already be connected with a valid IPv4 (caller brings up Wi-Fi).
If dual_destinations (default), send subnet broadcast then 255.255.255.255 so
discovery works on awkward APs — the controller may receive two packets.
If dual_destinations is False, send only one (subnet broadcast or limited),
e.g. after TCP connect so the Pi does not run duplicate resync handlers.
If wait_reply, wait for first UDP echo. Returns controller IP string or None.
"""
ip, mask, _gw, _dns = sta.ifconfig()
msg = pack_hello_line(sta, device_name)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except (AttributeError, OSError) as e:
print("SO_BROADCAST not set:", e)
try:
sock.bind((ip, 0))
except (AttributeError, OSError, TypeError) as e:
try:
sock.bind(("0.0.0.0", 0))
except (AttributeError, OSError) as e2:
print("bind skipped:", e, e2)
if wait_reply:
try:
sock.settimeout(recv_timeout_s)
except (AttributeError, OSError):
pass
discovered = None
targets = (
udp_discovery_targets(ip, mask)
if dual_destinations
else _udp_discovery_targets_single(ip, mask)
)
for dest_ip, dest_port in targets:
if wdt is not None:
wdt.feed()
target = (dest_ip, dest_port)
try:
sock.sendto(msg, target)
except OSError as e:
print("sendto failed:", e)
continue
if not wait_reply:
continue
if wdt is not None:
wdt.feed()
try:
_data, addr = sock.recvfrom(2048)
remote_ip = addr[0]
discovered = remote_ip
break
except OSError:
pass
sock.close()
return discovered
def discover_controller_udp(device_name="", wdt=None):
"""
Broadcast hello; return controller IP from first UDP echo, or None.
STA must already be connected.
device_name: logical name in the JSON (caller supplies, e.g. from Settings elsewhere).
wdt: optional WDT to feed during waits.
"""
sta = network.WLAN(network.STA_IF)
if not sta.isconnected():
print("hello: STA not connected — connect Wi-Fi before discovery.")
raise SystemExit(1)
ip, mask, _g, _d = sta.ifconfig()
if ip == "0.0.0.0":
print("hello: STA has no IP address.")
raise SystemExit(1)
discovered = broadcast_hello_udp(
sta,
device_name,
wait_reply=True,
wdt=wdt,
)
return discovered
if __name__ == "__main__":
if not discover_controller_udp():
raise SystemExit(1)

107
src/http_routes.py Normal file
View File

@@ -0,0 +1,107 @@
import json
from controller_messages import process_data
from microdot.websocket import WebSocketError, with_websocket
try:
import uos as os
except ImportError:
import os
def _safe_pattern_filename(name):
if not isinstance(name, str):
return False
if not name.endswith(".py"):
return False
if "/" in name or "\\" in name or ".." in name:
return False
return True
def register_routes(app, settings, presets, runtime_state):
@app.route("/ws")
@with_websocket
async def ws_handler(request, ws):
runtime_state.ws_connected()
controller_ip = None
try:
client_addr = getattr(request, "client_addr", None)
if isinstance(client_addr, (tuple, list)) and client_addr:
controller_ip = client_addr[0]
elif isinstance(client_addr, str):
controller_ip = client_addr
except Exception:
controller_ip = None
try:
while True:
data = await ws.receive()
if not data:
break
process_data(data, settings, presets, controller_ip=controller_ip)
except WebSocketError as e:
print("WS client disconnected:", e)
except OSError as e:
print("WS client dropped (OSError):", e)
finally:
runtime_state.ws_disconnected()
@app.post("/patterns/upload")
async def upload_pattern(request):
"""Receive one pattern file body from led-controller and reload patterns."""
raw_name = request.args.get("name")
reload_raw = request.args.get("reload", "1")
reload_patterns = str(reload_raw).strip().lower() not in ("0", "false", "no", "off")
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {"Content-Type": "application/json"}
body = request.body
if not isinstance(body, (bytes, bytearray)) or not body:
return json.dumps({"error": "code is required"}), 400, {"Content-Type": "application/json"}
try:
code = body.decode("utf-8")
except UnicodeError:
return json.dumps({"error": "body must be utf-8 text"}), 400, {"Content-Type": "application/json"}
if not code.strip():
return json.dumps({"error": "code is required"}), 400, {"Content-Type": "application/json"}
name = raw_name.strip()
if not name.endswith(".py"):
name += ".py"
if not _safe_pattern_filename(name) or name in ("__init__.py", "main.py"):
return json.dumps({"error": "invalid pattern filename"}), 400, {"Content-Type": "application/json"}
try:
os.mkdir("patterns")
except OSError:
pass
path = "patterns/" + name
try:
with open(path, "w") as f:
f.write(code)
if reload_patterns:
presets.reload_patterns()
except OSError as e:
print("patterns/upload failed:", e)
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return json.dumps(
{
"message": "pattern uploaded",
"name": name,
"reloaded": reload_patterns,
}
), 201, {"Content-Type": "application/json"}
@app.post("/presets/upload")
async def upload_presets(request):
"""Receive v1 JSON with ``presets`` and apply/save on the driver."""
body = request.body
if not isinstance(body, (bytes, bytearray)) or not body:
return json.dumps({"error": "body is required"}), 400, {"Content-Type": "application/json"}
try:
process_data(body, settings, presets)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return json.dumps({"message": "presets applied"}), 200, {"Content-Type": "application/json"}

View File

@@ -1,46 +1,70 @@
import print_timestamp # noqa: F401
from settings import Settings
from machine import WDT
from espnow import ESPNow
import network
from patterns import Patterns
from utils import convert_and_reorder_colors
import machine
import asyncio
import gc
import json
import network
import espnow
import device_groups as dg
from presets import Presets
from controller_messages import apply_startup_pattern, process_data
from espnow_transport import _handle_packet, init_espnow
from espnow_wire import BROADCAST_MAC, WIRE_MAGIC
settings = Settings()
print(settings)
patterns = Patterns(settings["led_pin"], settings["num_leds"], selected=settings["pattern"])
wdt = WDT(timeout=10000)
wdt = machine.WDT(timeout=10000)
wdt.feed()
sta_if = network.WLAN(network.STA_IF)
sta_if.active(True)
sta_if.disconnect()
sta_if.config(channel=1)
e = ESPNow()
e.active(True)
machine.freq(160000000)
settings = Settings()
dg.load_from_settings(settings)
print(settings)
gc.collect()
presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
presets.b = settings.get("brightness", 255)
presets.debug = bool(settings.get("debug", False))
gc.collect()
apply_startup_pattern(settings, presets)
esp = init_espnow(settings)
print(network.WLAN(network.STA_IF).config("channel"))
hello = json.dumps({
"v": "1",
"name": settings.get("name", "led"),
"type": "led",
})
print(hello)
esp.send(BROADCAST_MAC, hello)
print("espnow hello", len(hello), "B")
def _on_espnow_message(host, msg):
if not msg:
return
if msg[0] == WIRE_MAGIC:
_handle_packet(host, msg, settings, presets)
return
if msg[0:1] == b"{":
process_data(msg, settings, presets)
while True:
wdt.feed()
patterns.tick()
if e.any():
host, msg = e.recv()
data = json.loads(msg)
if data["v"] != "1":
while esp.any():
host, msg = esp.recv(0)
if not host or not msg:
continue
if "presets" in data:
for name, preset_data in data["presets"].items():
# Convert hex color strings to RGB tuples and reorder based on device color order
if "colors" in preset_data:
preset_data["colors"] = convert_and_reorder_colors(preset_data["colors"], settings)
patterns.edit(name, preset_data)
if settings.get("name") in data.get("select", {}):
select_list = data["select"][settings.get("name")]
# Select value is always a list: ["preset_name"] or ["preset_name", step]
if select_list:
preset_name = select_list[0]
step = select_list[1] if len(select_list) > 1 else None
patterns.select(preset_name, step=step)
print(host, len(msg), "B")
try:
_on_espnow_message(host, msg)
print(msg)
except Exception as e:
print("espnow rx error:", e)
presets.tick()

34
src/mem_stats.py Normal file
View File

@@ -0,0 +1,34 @@
"""GC / heap snapshot helpers for debug logging."""
import gc
def snapshot():
"""Return a dict of memory stats after ``gc.collect()``."""
gc.collect()
out = {
"free": gc.mem_free(),
"alloc": gc.mem_alloc(),
}
try:
import esp32
blocks = esp32.idf_heap_info(esp32.HEAP_DATA)
if blocks:
block = blocks[0]
if isinstance(block, dict):
if "total_free_bytes" in block:
out["idf_free"] = block["total_free_bytes"]
largest = block.get("largest_free_block")
if largest is None:
largest = block.get("largest_free_block_in_bytes")
if largest is not None:
out["idf_largest"] = largest
except Exception:
pass
return out
def print_mem(label):
"""Print one timestamped memory line (via ``print_timestamp`` when installed)."""
print("mem %s:" % label, snapshot())

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

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

@@ -0,0 +1,5 @@
"""Pattern modules are registered only via Presets._load_dynamic_patterns().
This file is ignored as a pattern (see presets.py). Keep it free of imports so
adding a pattern does not require editing this package.
"""

95
src/patterns/aurora.py Normal file
View File

@@ -0,0 +1,95 @@
import math
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"northern_wave": 1}
class Aurora:
def __init__(self, driver):
self.driver = driver
def _run_bands(self, preset, colors):
bands = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
shimmer = max(0, min(255, int(preset.n2) if int(preset.n2) > 0 else 40))
phase = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
for i in range(self.driver.num_leds):
idx = (
(i * bands) // max(1, self.driver.num_leds) + (phase // 32)
) % len(colors)
c = self.driver.apply_brightness(colors[idx], preset.b)
w = 255 - abs(128 - ((i * 8 + phase) & 255)) * 2
w = max(0, min(255, w + shimmer))
self.driver.n[self.driver.led_i(preset, i)] = (
(c[0] * w) // 255,
(c[1] * w) // 255,
(c[2] * w) // 255,
)
self.driver.n.write()
phase = (phase + self.driver.signed(preset, 1)) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield
def _run_northern(self, preset, colors):
period = max(4, int(preset.n1) if int(preset.n1) > 0 else 20)
contrast = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 200))
drift = max(1, int(preset.n3) if int(preset.n3) > 0 else 2)
phase = 0
last = utime.ticks_ms()
ncols = len(colors)
if ncols < 2:
colors = list(colors) + [(120, 180, 255)]
ncols = len(colors)
twopi = 6.2831853
def lerp3(a, b, f):
return (
a[0] + ((b[0] - a[0]) * f) // 255,
a[1] + ((b[1] - a[1]) * f) // 255,
a[2] + ((b[2] - a[2]) * f) // 255,
)
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
t = (i * twopi / period) + (phase * twopi / 256.0)
w = (math.sin(t) + 1.0) * 0.5
u = w * (ncols - 1) * 256.0
fi = int(u) >> 8
frac = int(u) & 255
if fi >= ncols - 1:
fi = ncols - 2
frac = 255
peak = lerp3(colors[fi], colors[fi + 1], frac)
peak = self.driver.apply_brightness(peak, preset.b)
mixf = min(255, int(w * contrast * 2) >> 1)
self.driver.n[self.driver.led_i(preset, i)] = lerp3(bg, peak, mixf)
self.driver.n.write()
phase = (phase + self.driver.signed(preset, drift)) % 256
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield
def run(self, preset):
"""Aurora bands (n6=0) or sine northern wave (n6=1, legacy northern_wave)."""
colors = preset.c if preset.c else [(40, 200, 140), (80, 120, 255), (160, 80, 220)]
if style_mode(preset, 0, _LEGACY) == 1:
colors = preset.c if preset.c else [(20, 55, 120), (60, 140, 220), (180, 220, 255)]
yield from self._run_northern(preset, colors)
return
yield from self._run_bands(preset, colors)

29
src/patterns/bar_graph.py Normal file
View File

@@ -0,0 +1,29 @@
import utime
class BarGraph:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(0, 255, 0), (255, 80, 0)]
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
level = max(0, min(100, int(preset.n1) if int(preset.n1) >= 0 else 50))
target = (self.driver.num_leds * level) // 100
lit = self.driver.apply_brightness(colors[0], preset.b)
unlit = self.driver.apply_brightness(
preset.background_or(colors),
preset.b,
)
for i in range(self.driver.num_leds):
self.driver.n[i] = lit if i < target else unlit
self.driver.n.write()
last_update = utime.ticks_add(last_update, delay_ms)
if not preset.a:
yield
return
yield

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

@@ -0,0 +1,34 @@
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)]
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
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:
# Inactive phase uses the preset background color.
self.driver.fill(bg_color)
state = not state
last_update = utime.ticks_add(last_update, delay_ms)
# Yield once per tick so other logic can run
yield

67
src/patterns/blizzard.py Normal file
View File

@@ -0,0 +1,67 @@
import random
import utime
class Blizzard:
"""Dense falling flakes with sideways drift (compare `snowfall` for gentler flakes)."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (200, 230, 255), (180, 210, 255)]
# Higher n1 → more spawns (0255 threshold vs random)
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 90)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
# n3: 128 = no bias; <128 drift one way, >128 the other (scaled to small steps)
wraw = int(preset.n3)
if wraw <= 0:
wind = 0
else:
wind = max(-4, min(4, (wraw - 128) // 20))
flakes = []
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
nled = self.driver.num_leds
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(nled):
self.driver.n[i] = bg
if random.randint(0, 255) < density:
flakes.append(
[
nled - 1,
random.randint(0, len(colors) - 1),
0 if wind == 0 else random.randint(-1, 1),
]
)
nf = []
for pos, ci, wj in flakes:
p = pos
lateral = wind + (wj if wj else 0)
p -= self.driver.signed(preset, speed)
p += self.driver.signed(preset, lateral)
if p < -2 or p >= nled + 2:
continue
pi = max(0, min(nled - 1, int(p)))
self.driver.n[self.driver.led_i(preset, pi)] = self.driver.apply_brightness(
colors[ci], preset.b
)
nf.append([p, ci, wj])
flakes = nf
self.driver.n.write()
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,56 @@
import random
import utime
class CandleGlow:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 140, 40), (255, 200, 120), (255, 90, 20)]
n_candles = max(1, min(self.driver.num_leds, int(preset.n1) if int(preset.n1) > 0 else 4))
width = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
flicker = max(1, min(255, int(preset.n3) if int(preset.n3) > 0 else 90))
n_led = self.driver.num_leds
centers = tuple(random.randint(0, max(0, n_led - 1)) for _ in range(n_candles))
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(n_led):
self.driver.n[i] = bg
base_lo = 180 - flicker // 2
if base_lo < 40:
base_lo = 40
for ci, c in enumerate(centers):
warmth = colors[ci % len(colors)]
pulse = base_lo + random.randint(0, flicker)
if pulse > 255:
pulse = 255
for off in range(-width, width + 1):
idx = c + off
if 0 <= idx < n_led:
dist = abs(off)
fall = ((width - dist + 1) * 256) // (width + 1)
fac = (fall * pulse) // 256
px = (
(warmth[0] * fac) // 255,
(warmth[1] * fac) // 255,
(warmth[2] * fac) // 255,
)
lit = self.driver.apply_brightness(px, preset.b)
o = self.driver.n[idx]
self.driver.n[idx] = (
max(o[0], lit[0]),
max(o[1], lit[1]),
max(o[2], lit[2]),
)
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

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

@@ -0,0 +1,164 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"marquee": 1}
class Chase:
def __init__(self, driver):
self.driver = driver
def _run_marquee(self, preset, colors):
on_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
off_len = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
step = max(1, abs(self.driver.signed(preset, int(preset.n3) if int(preset.n3) > 0 else 1)))
phase = self.driver.step % (on_len + off_len)
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
m = (i + phase) % (on_len + off_len)
self.driver.n[self.driver.led_i(preset, i)] = c if m < on_len else bg_color
self.driver.n.write()
phase = (phase + self.driver.signed(preset, step)) % (on_len + off_len)
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield
def run(self, preset):
"""Chase (n6=0) or marquee dashes (n6=1, legacy marquee).
Chase: n1/n2 segment lengths, n3/n4 step on even/odd beats.
Marquee: n1 on length, n2 off length, n3 scroll step.
"""
if style_mode(preset, 0, _LEGACY) == 1:
colors = preset.c if preset.c else [(255, 255, 255)]
yield from self._run_marquee(preset, colors)
return
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)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
n1 = max(1, int(preset.n1)) # LEDs of color 0
n2 = max(1, int(preset.n2)) # LEDs of color 1
n3 = self.driver.signed(preset, int(preset.n3)) # Step movement on even steps
n4 = self.driver.signed(preset, int(preset.n4)) # Step movement on odd steps
segment_length = n1 + n2
# Calculate position from step_count
step_count = int(self.driver.step) % 2
# 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(bg_color)
# 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[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)
# Increment step for next beat
self.driver.step = (step_count + 1) % 2
# 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(bg_color)
# 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[self.driver.led_i(preset, i)] = color0
else:
self.driver.n[self.driver.led_i(preset, i)] = color1
self.driver.n.write()
print("[chase] step", step_count)
# Increment step
step_count = (step_count + 1) % 2
self.driver.step = step_count
last_update = utime.ticks_add(last_update, transition_duration)
transition_duration = max(10, int(preset.d))
# Yield once per tick so other logic can run
yield

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

@@ -0,0 +1,100 @@
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 = preset.background_or(colors)
else:
base0 = colors[0]
base1 = preset.background_or(colors)
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(color1)
# 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 = utime.ticks_add(last_head_move, head_delay)
head_rate = max(1, int(preset.n1))
head_delay = 1000 // head_rate
# 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 = utime.ticks_add(last_tail_move, tail_delay)
tail_rate = max(1, int(preset.n3))
tail_delay = 1000 // tail_rate
# 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

View File

@@ -0,0 +1,33 @@
import utime
class ClockSweep:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (60, 60, 60)]
width = max(1, int(preset.n1) if int(preset.n1) > 0 else 1)
marker = max(0, int(preset.n2) if int(preset.n2) > 0 else 0)
pos = self.driver.step % max(1, self.driver.num_leds)
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
fg = self.driver.apply_brightness(colors[0], preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg
if marker > 0 and i % marker == 0:
self.driver.n[i] = ((bg[0]*2)//3, (bg[1]*2)//3, (bg[2]*2)//3)
for w in range(width):
self.driver.n[(pos + w) % self.driver.num_leds] = fg
self.driver.n.write()
pos = (pos + 1) % max(1, self.driver.num_leds)
self.driver.step = pos
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,100 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"rainbow": 1, "gradient_scroll": 0}
class ColourCycle:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
if pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def _render_gradient(self, preset, colors, phase, brightness):
num_leds = self.driver.num_leds
color_count = len(colors)
if num_leds <= 0 or color_count <= 0:
return
if color_count == 1:
self.driver.fill(self.driver.apply_brightness(colors[0], brightness))
return
full_span = color_count * 256
phase_shift = (phase * full_span) // 256
for i in range(num_leds):
pos = ((i * full_span) // num_leds + phase_shift) % full_span
idx = pos // 256
frac = pos & 255
c1 = colors[idx]
c2 = colors[(idx + 1) % color_count]
blended = (
c1[0] + ((c2[0] - c1[0]) * frac) // 256,
c1[1] + ((c2[1] - c1[1]) * frac) // 256,
c1[2] + ((c2[2] - c1[2]) * frac) // 256,
)
self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness(
blended, brightness
)
self.driver.n.write()
def _render_rainbow(self, preset, phase, brightness):
num_leds = self.driver.num_leds
for i in range(num_leds):
rc_index = (i * 256 // max(1, num_leds)) + phase
self.driver.n[self.driver.led_i(preset, i)] = self.driver.apply_brightness(
self._wheel(rc_index & 255), brightness
)
self.driver.n.write()
def run(self, preset):
"""Scroll gradient (n6=0) or fixed spectrum wheel (n6=1, legacy rainbow).
n1: step rate
n6: 0 gradient scroll, 1 rainbow wheel
"""
mode = style_mode(preset, 0, _LEGACY)
step_amount = max(1, int(preset.n1) if int(preset.n1) > 0 else 1)
phase = self.driver.step % 256
if mode == 1:
if not preset.a:
self._render_rainbow(preset, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256
yield
return
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_rainbow(preset, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield
colors = preset.c if preset.c else [(255, 0, 0), (0, 0, 255)]
if not preset.a:
self._render_gradient(preset, colors, phase, preset.b)
self.driver.step = (phase + self.driver.signed(preset, step_amount)) % 256
yield
return
last_update = utime.ticks_ms()
while True:
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
self._render_gradient(preset, colors, phase, preset.b)
phase = (phase + self.driver.signed(preset, step_amount)) % 256
self.driver.step = phase
last_update = utime.ticks_add(last_update, delay_ms)
yield

210
src/patterns/flame.py Normal file
View File

@@ -0,0 +1,210 @@
import random
import utime
# Default warm palette: ember → orange → yellow → pale hot (RGB)
_DEFAULT_PALETTE = (
(90, 8, 8),
(200, 40, 12),
(255, 120, 30),
(255, 220, 140),
)
def _clamp(x, lo, hi):
if x < lo:
return lo
if x > hi:
return hi
return x
def _lerp_chan(a, b, t):
return a + ((b - a) * t >> 8)
def _lerp_rgb(c0, c1, t):
return (
_lerp_chan(c0[0], c1[0], t),
_lerp_chan(c0[1], c1[1], t),
_lerp_chan(c0[2], c1[2], t),
)
def _palette_sample(palette, pos256):
n = len(palette)
if n == 0:
return (255, 160, 60)
if n == 1:
return palette[0]
span = (n - 1) * pos256
seg = span >> 8
if seg >= n - 1:
return palette[n - 1]
frac = span & 0xFF
return _lerp_rgb(palette[seg], palette[seg + 1], frac)
def _triangle_255(elapsed_ms, period_ms):
period_ms = max(period_ms, 400)
p = elapsed_ms % period_ms
half = period_ms >> 1
if half <= 0:
return 128
if p < half:
return (p * 255) // half
return ((period_ms - p) * 255) // (period_ms - half)
class Flame:
def __init__(self, driver):
self.driver = driver
def _build_palette(self, preset):
colors = preset.c
if not colors:
return list(_DEFAULT_PALETTE)
out = []
for c in colors:
if isinstance(c, (list, tuple)) and len(c) == 3:
out.append(
(
_clamp(int(c[0]), 0, 255),
_clamp(int(c[1]), 0, 255),
_clamp(int(c[2]), 0, 255),
)
)
return out if out else list(_DEFAULT_PALETTE)
def _draw_frame(self, preset, palette, ticks_now, breath_el_ms, rise, cluster_jit, breath_ms, lo, hi, spark_state):
"""spark_state: (active: bool, start_ticks, duration_ms). ticks_now for sparks; breath_el_ms for slow wave."""
num = self.driver.num_leds
denom = num - 1 if num > 1 else 1
breathe = _triangle_255(breath_el_ms, breath_ms)
base_level = lo + (((hi - lo) * breathe) >> 8)
micro = 232 + random.randint(0, 35)
level = (base_level * micro) >> 8
level = _clamp(level, lo, hi)
spark_boost = 0
spark_white = (0, 0, 0)
active, s0, dur = spark_state
if active and dur > 0:
el = utime.ticks_diff(ticks_now, s0)
if el < 0:
el = 0
if el >= dur:
spark_boost = 0
else:
env = 255 - ((el * 255) // dur)
spark_boost = (env * 90) >> 8
spark_white = ((env * 55) >> 8, (env * 50) >> 8, (env * 40) >> 8)
for i in range(num):
h = (i * 256) // denom
flow = (h + rise + ((i // max(1, num >> 3)) * 17)) & 255
pos = (flow + cluster_jit[(i >> 2) & 7]) & 255
rgb = _palette_sample(palette, pos)
if spark_boost:
rgb = (
_clamp(rgb[0] + spark_white[0] + (spark_boost * 3 >> 2), 0, 255),
_clamp(rgb[1] + spark_white[1] + (spark_boost >> 1), 0, 255),
_clamp(rgb[2] + spark_white[2] + (spark_boost >> 2), 0, 255),
)
self.driver.n[i] = self.driver.apply_brightness(rgb, level)
self.driver.n.write()
def run(self, preset):
"""Salt-lamp / hearth-style flame: warm gradient, breathing, jitter, drift, rare sparks."""
palette = self._build_palette(preset)
lo = max(0, min(255, int(preset.n1)))
hi = max(0, min(255, int(preset.b)))
if lo > hi:
lo, hi = hi, lo
bp = int(preset.n2)
breath_ms = max(800, bp if bp > 0 else 2500)
gap_lo = int(preset.n3)
gap_hi = int(preset.n4)
# n3 < 0 disables sparks; n3=n4=0 uses ~1030 s gaps (hearth pops).
if gap_lo < 0:
sparks_on = False
else:
sparks_on = True
if gap_lo == 0 and gap_hi == 0:
gap_lo, gap_hi = 10000, 30000
else:
gap_lo = max(gap_lo, 500)
if gap_hi < gap_lo:
gap_hi = gap_lo
delay_ms = max(16, int(preset.d))
rise = random.randint(0, 255)
cluster_jit = [random.randint(-18, 18) for _ in range(8)]
last_draw = utime.ticks_ms()
breath_origin = last_draw
last_cluster = last_draw
spark_active = False
spark_start = 0
spark_dur = 0
next_spark = utime.ticks_add(last_draw, random.randint(gap_lo, gap_hi)) if sparks_on else 0
if not preset.a:
now = utime.ticks_ms()
self._draw_frame(
preset,
palette,
now,
utime.ticks_diff(now, breath_origin),
rise,
cluster_jit,
breath_ms,
lo,
hi,
(False, 0, 0),
)
yield
return
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_draw) < delay_ms:
yield
continue
last_draw = utime.ticks_add(last_draw, delay_ms)
rise = (rise + random.randint(-10, 12)) & 255
if utime.ticks_diff(now, last_cluster) >= (delay_ms * 4):
last_cluster = now
cluster_jit = [random.randint(-18, 18) for _ in range(8)]
spark_state = (spark_active, spark_start, spark_dur)
if sparks_on:
if spark_active:
if utime.ticks_diff(now, spark_start) >= spark_dur:
spark_active = False
next_spark = utime.ticks_add(
now,
random.randint(gap_lo, gap_hi),
)
elif utime.ticks_diff(now, next_spark) >= 0:
spark_active = True
spark_start = now
spark_dur = random.randint(180, 360)
self._draw_frame(
preset,
palette,
now,
utime.ticks_diff(now, breath_origin),
rise,
cluster_jit,
breath_ms,
lo,
hi,
(spark_active, spark_start, spark_dur),
)
yield

40
src/patterns/flicker.py Normal file
View File

@@ -0,0 +1,40 @@
import random
import utime
class Flicker:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
"""Random brightness between n1 (min) and b (max); delay d ms between updates."""
colors = preset.c if preset.c else [(255, 255, 255)]
color_index = 0
last_update = utime.ticks_ms()
def brightness_bounds():
lo = max(0, min(255, int(preset.n1)))
hi = max(0, min(255, int(preset.b)))
if lo > hi:
lo, hi = hi, lo
return lo, hi
if not preset.a:
lo, hi = brightness_bounds()
level = random.randint(lo, hi)
base = colors[color_index % len(colors)]
self.driver.fill(self.driver.apply_brightness(base, level))
yield
return
while True:
current_time = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
lo, hi = brightness_bounds()
if utime.ticks_diff(current_time, last_update) >= delay_ms:
level = random.randint(lo, hi)
base = colors[color_index % len(colors)]
self.driver.fill(self.driver.apply_brightness(base, level))
color_index += 1
last_update = utime.ticks_add(last_update, delay_ms)
yield

62
src/patterns/icicles.py Normal file
View File

@@ -0,0 +1,62 @@
import utime
class Icicles:
"""Icicles hanging from anchor points; tips brighten toward max length then shrink."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(240, 248, 255), (160, 210, 255), (255, 255, 255)]
spacing = max(1, int(preset.n1) if int(preset.n1) > 0 else 12)
nled = self.driver.num_leds
max_len = max(
2,
min(
int(preset.n2) if int(preset.n2) > 0 else min(14, max(3, nled // 4)),
max(2, nled),
),
)
span = max_len * 2
phase_step = max(1, int(preset.n3) if int(preset.n3) > 0 else 1)
phase = 0
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
bg_rgb = preset.background_or(colors)
bg = self.driver.apply_brightness(bg_rgb, preset.b)
for i in range(nled):
self.driver.n[i] = bg
aidx = 0
for anchor in range(0, nled, spacing):
tri_i = (phase + aidx * 5) % span
ic_len = tri_i if tri_i <= max_len else span - tri_i
tip_c = colors[aidx % len(colors)]
tip = self.driver.apply_brightness(tip_c, preset.b)
for k in range(ic_len):
idx = anchor + k
if idx >= nled:
break
br = ((k + 1) * 255) // max(1, ic_len)
self.driver.n[self.driver.led_i(preset, idx)] = (
(tip[0] * br + bg[0] * (255 - br)) // 255,
(tip[1] * br + bg[1] * (255 - br)) // 255,
(tip[2] * br + bg[2] * (255 - br)) // 255,
)
aidx += 1
self.driver.n.write()
phase = (phase + self.driver.signed(preset, phase_step)) % span
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

176
src/patterns/meteor.py Normal file
View File

@@ -0,0 +1,176 @@
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"comet_dual": 1, "scanner": 2}
class Meteor:
def __init__(self, driver):
self.driver = driver
def _fade(self, color, fade_amount):
return (
(color[0] * fade_amount) // 255,
(color[1] * fade_amount) // 255,
(color[2] * fade_amount) // 255,
)
def _run_meteor(self, preset, colors, color_index, head, direction, last_update):
tail_len = max(1, int(preset.n1) if int(preset.n1) > 0 else 8)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
fade_amount = int(preset.n3) if int(preset.n3) > 0 else 192
fade_amount = max(1, min(255, fade_amount))
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
return color_index, head, direction, last_update, False
for i in range(self.driver.num_leds):
self.driver.n[i] = self._fade(self.driver.n[i], fade_amount)
base = colors[color_index % len(colors)]
lit = self.driver.apply_brightness(base, preset.b)
if 0 <= head < self.driver.num_leds:
self.driver.n[self.driver.led_i(preset, head)] = lit
self.driver.n.write()
head += self.driver.signed(preset, direction * speed)
if head >= self.driver.num_leds + tail_len:
head = self.driver.num_leds - 1
direction = -1
color_index += 1
elif head < -tail_len:
head = 0
direction = 1
color_index += 1
return color_index, head, direction, utime.ticks_add(last_update, delay_ms), True
def _run_comet_dual(self, preset, colors, p1, p2, last):
tail = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
gap = max(0, int(preset.n3))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return p1, p2, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
c1 = self.driver.apply_brightness(colors[0 % len(colors)], preset.b)
c2 = self.driver.apply_brightness(
colors[1 % len(colors)] if len(colors) > 1 else colors[0], preset.b
)
for t in range(tail):
i1 = p1 - t
if 0 <= i1 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[self.driver.led_i(preset, i1)] = (
(c1[0] * s) // 255,
(c1[1] * s) // 255,
(c1[2] * s) // 255,
)
i2 = p2 + t
if 0 <= i2 < self.driver.num_leds:
s = (255 * (tail - t)) // max(1, tail)
self.driver.n[self.driver.led_i(preset, i2)] = (
(c2[0] * s) // 255,
(c2[1] * s) // 255,
(c2[2] * s) // 255,
)
self.driver.n.write()
p1 += self.driver.signed(preset, speed)
p2 -= self.driver.signed(preset, speed)
if p1 - tail > self.driver.num_leds and p2 + tail < 0:
p1 = 0
p2 = self.driver.num_leds - 1 - gap
return p1, p2, utime.ticks_add(last, d), True
def _run_scanner(self, preset, colors, color_index, center, direction, pause_frames, last_update):
width = max(1, int(preset.n1) if int(preset.n1) > 0 else 4)
end_pause = max(0, int(preset.n2))
delay_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) < delay_ms:
return color_index, center, direction, pause_frames, last_update, False
base = self.driver.apply_brightness(colors[color_index % len(colors)], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
dist = i - center
if dist < 0:
dist = -dist
if dist > width:
self.driver.n[self.driver.led_i(preset, i)] = bg_color
else:
scale = ((width - dist) * 255) // max(1, width)
self.driver.n[self.driver.led_i(preset, i)] = (
(base[0] * scale) // 255,
(base[1] * scale) // 255,
(base[2] * scale) // 255,
)
self.driver.n.write()
if pause_frames > 0:
pause_frames -= 1
else:
center += self.driver.signed(preset, direction)
if center >= self.driver.num_leds - 1:
center = self.driver.num_leds - 1
direction = -1
pause_frames = end_pause
color_index += 1
elif center <= 0:
center = 0
direction = 1
pause_frames = end_pause
color_index += 1
return color_index, center, direction, pause_frames, utime.ticks_add(last_update, delay_ms), True
def run(self, preset):
"""Moving lights: n6 style 0 meteor, 1 dual comet, 2 scanner (legacy ids still work)."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(255, 255, 255)]
if mode == 1:
gap = max(0, int(preset.n3))
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
p1, p2 = nled - 1, gap
else:
p1, p2 = 0, nled - 1 - gap
last = utime.ticks_ms()
while True:
p1, p2, last, stepped = self._run_comet_dual(preset, colors, p1, p2, last)
if stepped and not preset.a:
yield
return
yield
if mode == 2:
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, center, direction, pause_frames = 0, max(0, nled - 1), -1, 0
else:
color_index, center, direction, pause_frames = 0, 0, 1, 0
last_update = utime.ticks_ms()
while True:
color_index, center, direction, pause_frames, last_update, stepped = (
self._run_scanner(
preset, colors, color_index, center, direction, pause_frames, last_update
)
)
if stepped and not preset.a:
yield
return
yield
nled = self.driver.num_leds
if self.driver.is_reversed(preset):
color_index, head, direction = 0, max(0, nled - 1), -1
else:
color_index, head, direction = 0, 0, 1
last_update = utime.ticks_ms()
while True:
color_index, head, direction, last_update, stepped = self._run_meteor(
preset, colors, color_index, head, direction, last_update
)
if stepped and not preset.a:
yield
return
yield

31
src/patterns/orbit.py Normal file
View File

@@ -0,0 +1,31 @@
import utime
class Orbit:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255), (0, 180, 255), (255, 0, 120)]
orbits = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 1)
phase = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
for k in range(orbits):
idx = ((phase * (k + 1)) // 8 + (k * self.driver.num_leds // max(1, orbits))) % max(1, self.driver.num_leds)
self.driver.n[idx] = self.driver.apply_brightness(colors[k % len(colors)], preset.b)
self.driver.n.write()
phase = (phase + speed) & 255
self.driver.step = phase
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

View File

@@ -0,0 +1,81 @@
import utime
class PaletteMorph:
def __init__(self, driver):
self.driver = driver
def _blend(self, c1, c2, t):
return (
c1[0] + ((c2[0] - c1[0]) * t) // 255,
c1[1] + ((c2[1] - c1[1]) * t) // 255,
c1[2] + ((c2[2] - c1[2]) * t) // 255,
)
def run(self, preset):
"""Living color field (non-scrolling palette warp).
Different from `colour_cycle`: this does not scroll a fixed gradient.
Instead, each LED breathes/warps through the palette with local phase
offsets so the strip looks alive.
n1: morph duration (ms)
n2: warp rate
n3: spatial turbulence amount
"""
colors = preset.c if preset.c else [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
if len(colors) < 2:
while True:
self.driver.fill(self.driver.apply_brightness(colors[0], preset.b))
yield
morph = max(50, int(preset.n1) if int(preset.n1) > 0 else 1200)
warp_rate = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
turbulence = max(1, int(preset.n3) if int(preset.n3) > 0 else 24)
base_idx = 0
start = utime.ticks_ms()
phase = self.driver.step % 256
last_update = start
while True:
now = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(now, last_update) < delay_ms:
yield
continue
last_update = utime.ticks_add(last_update, delay_ms)
age = utime.ticks_diff(now, start)
if age < morph:
t = (age * 255) // morph
else:
t = 255
# Global morph anchor between neighboring palette colors.
a = colors[base_idx % len(colors)]
b = colors[(base_idx + 1) % len(colors)]
anchor = self._blend(a, b, t)
for i in range(self.driver.num_leds):
# Non-linear local warp per LED to create "living" motion.
pos = (i * 256) // max(1, self.driver.num_leds)
wobble = ((pos * turbulence) // 32 + phase + (t // 2)) & 255
breath = 255 - abs(128 - wobble) * 2
local = (pos + (breath // 3) + (t // 4)) % 256
idx = (base_idx + ((local * len(colors)) // 256)) % len(colors)
frac = (local * len(colors)) & 255
c1 = colors[idx]
c2 = colors[(idx + 1) % len(colors)]
grad = self._blend(c1, c2, frac)
# Blend with anchor to keep coherent palette morphing.
out = self._blend(grad, anchor, 80)
self.driver.n[i] = self.driver.apply_brightness(out, preset.b)
self.driver.n.write()
if age >= morph:
base_idx = (base_idx + 1) % len(colors)
start = now
if not preset.a:
yield
return
phase = (phase + warp_rate) & 255
self.driver.step = phase
yield

111
src/patterns/particles.py Normal file
View File

@@ -0,0 +1,111 @@
import random
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"starfall": 1}
class Particles:
def __init__(self, driver):
self.driver = driver
def _run_snowfall(self, preset, colors, flakes, last):
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 20)
speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 1)))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return flakes, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
if random.randint(0, 255) < density:
flakes.append([self.driver.num_leds - 1, random.randint(0, len(colors) - 1)])
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
nf = []
for pos, ci in flakes:
if 0 <= pos < self.driver.num_leds:
self.driver.n[self.driver.led_i(preset, pos)] = self.driver.apply_brightness(
colors[ci], preset.b
)
pos -= self.driver.signed(preset, speed)
if pos >= -1:
nf.append([pos, ci])
self.driver.n.write()
return nf, utime.ticks_add(last, d), True
def _run_starfall(self, preset, colors, stars, last):
rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 14))
speed = max(1, abs(self.driver.signed(preset, int(preset.n2) if int(preset.n2) > 0 else 2)))
tail = max(2, int(preset.n3) if int(preset.n3) > 0 else 10)
max_stars = 4
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return stars, last, False
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg
if len(stars) < max_stars and random.randint(0, 255) < rate:
top = self.driver.num_leds - 1 + random.randint(
0, min(8, self.driver.num_leds // 2)
)
stars.append({"h": float(top), "ci": random.randint(0, len(colors) - 1)})
ns = []
for s in stars:
h = s["h"]
ci = s["ci"]
ih = int(h)
for t in range(tail):
idx = ih + t
if 0 <= idx < self.driver.num_leds:
fade = 255 - (t * 255 // max(1, tail - 1))
base = colors[ci]
lit = (
(base[0] * fade) // 255,
(base[1] * fade) // 255,
(base[2] * fade) // 255,
)
lit = self.driver.apply_brightness(lit, preset.b)
pix = self.driver.led_i(preset, idx)
o = self.driver.n[pix]
self.driver.n[pix] = (
max(o[0], lit[0]),
max(o[1], lit[1]),
max(o[2], lit[2]),
)
h -= self.driver.signed(preset, speed)
if h >= -tail:
s["h"] = h
ns.append(s)
stars = ns
self.driver.n.write()
return stars, utime.ticks_add(last, d), True
def run(self, preset):
"""Falling particles: n6 0 snowfall flakes, 1 starfall streaks."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(255, 255, 255), (180, 220, 255)]
last = utime.ticks_ms()
if mode == 1:
colors = preset.c if preset.c else [
(255, 255, 255),
(200, 230, 255),
(255, 248, 220),
]
stars = []
while True:
stars, last, stepped = self._run_starfall(preset, colors, stars, last)
if stepped and not preset.a:
yield
return
yield
flakes = []
while True:
flakes, last, stepped = self._run_snowfall(preset, colors, flakes, last)
if stepped and not preset.a:
yield
return
yield

View File

@@ -0,0 +1,19 @@
"""Strip install direction: n5 bit 0 reverses along-strip motion (upside-down wiring)."""
def is_reversed(preset):
return bool(int(getattr(preset, "n5", 0) or 0) & 1)
def led_i(driver, preset, logical_index):
"""Map a logical strip index (0 = pattern start) to a physical pixel index."""
n = int(driver.num_leds)
i = int(logical_index)
if 0 <= i < n and is_reversed(preset):
return n - 1 - i
return i
def signed(preset, value):
v = int(value)
return -v if is_reversed(preset) else v

View File

@@ -0,0 +1,18 @@
"""Resolve pattern style from n6 or legacy preset pattern id (p)."""
def style_mode(preset, default=0, legacy=None):
legacy = legacy or {}
p = getattr(preset, "p", "") or ""
if p in legacy:
return legacy[p]
mode = getattr(preset, "mode", None)
if mode is None and isinstance(preset, dict):
mode = preset.get("mode")
if mode is not None:
try:
return int(mode)
except (TypeError, ValueError):
pass
n6 = int(getattr(preset, "n6", 0) or 0)
return n6 if n6 > 0 else default

39
src/patterns/plasma.py Normal file
View File

@@ -0,0 +1,39 @@
import utime
class Plasma:
def __init__(self, driver):
self.driver = driver
def _wheel(self, pos):
if pos < 85:
return (pos * 3, 255 - pos * 3, 0)
if pos < 170:
pos -= 85
return (255 - pos * 3, 0, pos * 3)
pos -= 170
return (0, pos * 3, 255 - pos * 3)
def run(self, preset):
scale = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 2)
contrast = max(1, int(preset.n3) if int(preset.n3) > 0 else 2)
t = self.driver.step % 256
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
for i in range(self.driver.num_leds):
v = ((i * scale + t) & 255)
v2 = (((i * scale // max(1, contrast)) - (t * 2)) & 255)
c = self._wheel((v + v2) & 255)
self.driver.n[i] = self.driver.apply_brightness(c, preset.b)
self.driver.n.write()
t = (t + speed) % 256
self.driver.step = t
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

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

@@ -0,0 +1,68 @@
import utime
class Pulse:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
# Get colors from preset
colors = preset.c
if not colors:
colors = [(255, 255, 255)]
bg_base = preset.background_or(colors)
self.driver.fill(self.driver.apply_brightness(bg_base, preset.b))
manual = not preset.a
color_index = self.driver.step % max(1, len(colors))
cycle_start = utime.ticks_ms()
# State machine based pulse using a single generator loop
while True:
bg_color = self.driver.apply_brightness(bg_base, preset.b)
# 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 = 0 if manual else 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(bg_color)
else:
# End of cycle: advance colour for the next run, then loop or stop.
nclr = max(1, len(colors))
color_index = (color_index + 1) % nclr
self.driver.step = color_index
if manual:
self.driver.fill(bg_color)
break
cycle_start = now
yield
continue
# Yield once per tick
yield

172
src/patterns/radiate.py Normal file
View File

@@ -0,0 +1,172 @@
import utime
# When ``driver.debug`` is True (``settings["debug"]``), log at most this often (ms).
_RADIATE_DBG_INTERVAL_MS = 2500
class Radiate:
def __init__(self, driver):
self.driver = driver
self._color_step = 0
def run(self, preset):
"""Radiate from nodes every n1 LEDs, retriggering every delay (d).
- n1: node spacing in LEDs
- n2: outbound travel time in ms
- n3: return travel time in ms
- d: retrigger interval in ms
"""
colors = preset.c if preset.c else [(255, 255, 255)]
base_off = preset.background_or(colors)
spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
max_dist = spacing // 2
lit_color = self.driver.apply_brightness(colors[self._color_step % max(1, len(colors))], preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
now = utime.ticks_ms()
last_trigger = now
active_pulses = [now]
last_dbg = now
dbg_banner = False
if not preset.a:
# Manual mode: one-shot pulse using the same ms-based timing as auto.
cycle_start = utime.ticks_ms()
last_dbg = cycle_start
while True:
dbg = bool(getattr(self.driver, "debug", False))
spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
max_dist = spacing // 2
on_color = colors[self._color_step % max(1, len(colors))]
lit_color = self.driver.apply_brightness(on_color, preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
pulse_lifetime = outward_ms + return_ms
now = utime.ticks_ms()
age = utime.ticks_diff(now, cycle_start)
if age < 1:
age = 1
if age <= outward_ms:
front = (age * max_dist + outward_ms - 1) // outward_ms
elif age <= outward_ms + return_ms:
back_age = age - outward_ms
remaining = return_ms - back_age
front = (remaining * max_dist + return_ms - 1) // return_ms
else:
front = 0
lit_count = 0
for i in range(self.driver.num_leds):
offset = (i + (spacing // 2)) % spacing
dist = min(offset, spacing - offset)
lit = dist <= front
self.driver.n[i] = lit_color if lit else off_color
if lit:
lit_count += 1
self.driver.n.write()
if dbg:
if not dbg_banner:
dbg_banner = True
print(
"[radiate] debug on n1=%s n2=%s n3=%s d=%s auto=%s num_leds=%d"
% (preset.n1, preset.n2, preset.n3, preset.d, preset.a, self.driver.num_leds)
)
if utime.ticks_diff(now, last_dbg) >= _RADIATE_DBG_INTERVAL_MS:
print(
"[radiate] manual frame age=%d/%d front=%d lit=%d"
% (age, pulse_lifetime, front, lit_count)
)
last_dbg = now
yield
if age >= pulse_lifetime:
self._color_step += 1
return
while True:
now = utime.ticks_ms()
dbg = bool(getattr(self.driver, "debug", False))
delay_ms = max(1, int(preset.d))
spacing = max(1, int(preset.n1))
outward_ms = max(1, int(preset.n2))
return_ms = max(1, int(preset.n3))
pulse_lifetime = outward_ms + return_ms
max_dist = spacing // 2
on_color = colors[self._color_step % max(1, len(colors))]
lit_color = self.driver.apply_brightness(on_color, preset.b)
off_color = self.driver.apply_brightness(base_off, preset.b)
if preset.a and utime.ticks_diff(now, last_trigger) >= delay_ms:
# Keep one pulse train at a time; replacing instead of appending
# prevents overlap from keeping color[0] continuously visible.
active_pulses = [now]
last_trigger = utime.ticks_add(last_trigger, delay_ms)
self._color_step += 1
# Drop pulses once their out-and-back lifetime ends.
kept = []
for start in active_pulses:
age = utime.ticks_diff(now, start)
if age < pulse_lifetime:
kept.append(start)
active_pulses = kept
lit_count = 0
for i in range(self.driver.num_leds):
# Nearest node distance for a repeating node grid every `spacing` LEDs.
offset = (i + (spacing // 2)) % spacing
dist = min(offset, spacing - offset)
lit = False
for start in active_pulses:
age = utime.ticks_diff(now, start)
# Auto: skip the exact trigger tick (age==0) so nodes are not stuck on.
if age <= 0:
continue
if age <= outward_ms:
# Integer-ceiling progression so peak can be reached even
# when tick timing skips the exact outward_ms boundary.
front = (age * max_dist + outward_ms - 1) // outward_ms
elif age <= outward_ms + return_ms:
back_age = age - outward_ms
remaining = return_ms - back_age
front = (remaining * max_dist + return_ms - 1) // return_ms
else:
continue
if dist <= front:
lit = True
break
self.driver.n[i] = lit_color if lit else off_color
if lit:
lit_count += 1
self.driver.n.write()
if dbg:
if not dbg_banner:
dbg_banner = True
print(
"[radiate] debug on n1=%s n2=%s n3=%s d=%s auto=%s num_leds=%d"
% (preset.n1, preset.n2, preset.n3, preset.d, preset.a, self.driver.num_leds)
)
pulse_age = -1
if active_pulses:
pulse_age = utime.ticks_diff(now, active_pulses[0])
if utime.ticks_diff(now, last_dbg) >= _RADIATE_DBG_INTERVAL_MS:
print(
"[radiate] pulses=%d first_age=%d lit=%d lifetime=%d"
% (len(active_pulses), pulse_age, lit_count, pulse_lifetime)
)
last_dbg = now
yield

View File

@@ -0,0 +1,41 @@
import random
import utime
class RainDrops:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(120, 180, 255)]
rate = max(1, int(preset.n1) if int(preset.n1) > 0 else 32)
width = max(1, int(preset.n2) if int(preset.n2) > 0 else 3)
drops = []
last = utime.ticks_ms()
while True:
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d:
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
if random.randint(0, 255) < rate:
drops.append([random.randint(0, max(0, self.driver.num_leds - 1)), 0])
nd = []
for pos, age in drops:
for off in range(-width, width + 1):
idx = pos + off
if 0 <= idx < self.driver.num_leds:
s = 255 - min(255, abs(off) * 255 // max(1, width + 1) + age * 40)
base = self.driver.apply_brightness(colors[age % len(colors)], preset.b)
self.driver.n[idx] = ((base[0]*s)//255, (base[1]*s)//255, (base[2]*s)//255)
age += 1
if age < 8:
nd.append([pos, age])
drops = nd
self.driver.n.write()
last = utime.ticks_add(last, d)
if not preset.a:
yield
return
yield

72
src/patterns/rime.py Normal file
View File

@@ -0,0 +1,72 @@
import random
import utime
class Rime:
"""Slow frost build-up on a chilly background — gentle random brightening then decay."""
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(220, 235, 255), (255, 255, 255), (185, 220, 255)]
num = self.driver.num_leds
if num <= 0:
while True:
yield
return
# n1: spawn tendency (like twinkle upper range)
chill = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 36))
# n2: decay per refresh (subtract from glow buffer)
melt = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 12))
# n3: how many LEDs can flash brighter per refresh (cap)
spark_cap = max(1, min(num, int(preset.n3) if int(preset.n3) > 0 else 3))
glow = [0] * num
last = utime.ticks_ms()
while True:
d_ms = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) >= d_ms:
base_bg = preset.background_or(colors)
bg = self.driver.apply_brightness(base_bg, preset.b)
for i in range(num):
if glow[i] > melt:
glow[i] -= melt
else:
glow[i] = 0
spawned = 0
tries = spark_cap + num // 8
for _ in range(tries):
if spawned >= spark_cap:
break
if random.randint(0, 255) >= chill:
continue
j = random.randint(0, num - 1)
glow[j] = min(255, glow[j] + random.randint(80, 200))
spawned += 1
palette = colors
for i in range(num):
g = glow[i]
fg = palette[i % len(palette)]
hi = self.driver.apply_brightness(fg, preset.b)
mix = max(0, min(255, g))
self.driver.n[i] = (
(hi[0] * mix + bg[0] * (255 - mix)) // 255,
(hi[1] * mix + bg[1] * (255 - mix)) // 255,
(hi[2] * mix + bg[2] * (255 - mix)) // 255,
)
self.driver.n.write()
last = utime.ticks_add(last, d_ms)
if not preset.a:
yield
return
yield

147
src/patterns/sparkle.py Normal file
View File

@@ -0,0 +1,147 @@
import random
import utime
from patterns.pattern_modes import style_mode
_LEGACY = {"sparkle_trail": 0, "ice_sparkle": 1, "fireflies": 2}
class Sparkle:
def __init__(self, driver):
self.driver = driver
def _run_trail(self, preset, colors, last):
density = max(1, int(preset.n1) if int(preset.n1) > 0 else 24)
decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 210))
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return last, False
for i in range(self.driver.num_leds):
r, g, b = self.driver.n[i]
self.driver.n[i] = ((r * decay) // 255, (g * decay) // 255, (b * decay) // 255)
sparks = max(1, self.driver.num_leds * density // 255)
for _ in range(sparks):
idx = random.randint(0, max(0, self.driver.num_leds - 1))
c = self.driver.apply_brightness(
colors[random.randint(0, len(colors) - 1)], preset.b
)
self.driver.n[idx] = c
self.driver.n.write()
return utime.ticks_add(last, d), True
def _run_ice(self, preset, colors, sparks, last):
rate = max(1, min(255, int(preset.n1) if int(preset.n1) > 0 else 55))
decay = max(1, min(255, int(preset.n2) if int(preset.n2) > 0 else 140))
halo = max(0, min(3, int(preset.n3)))
cap = 28
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return sparks, last, False
bg = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg
ns = []
for s in sparks:
lv = s["lv"] - decay
if lv > 0:
s["lv"] = lv
ns.append(s)
sparks = ns
if len(sparks) < cap and random.randint(0, 255) < rate:
sparks.append(
{
"p": random.randint(0, max(0, self.driver.num_leds - 1)),
"lv": 255,
"ci": random.randint(0, len(colors) - 1),
}
)
for s in sparks:
p = s["p"]
lv = s["lv"]
ci = s["ci"]
base = colors[ci]
for off in range(-halo, halo + 1):
idx = p + off
if 0 <= idx < self.driver.num_leds:
dist = abs(off)
fac = lv if dist == 0 else (lv * (halo - dist + 1)) // (halo + 1)
lit = self.driver.apply_brightness(
(
(base[0] * fac) // 255,
(base[1] * fac) // 255,
(base[2] * fac) // 255,
),
preset.b,
)
o = self.driver.n[idx]
self.driver.n[idx] = (
min(255, o[0] + lit[0]),
min(255, o[1] + lit[1]),
min(255, o[2] + lit[2]),
)
self.driver.n.write()
return sparks, utime.ticks_add(last, d), True
def _run_fireflies(self, preset, colors, bugs, last):
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
speed = max(1, int(preset.n2) if int(preset.n2) > 0 else 8)
d = max(1, int(preset.d))
now = utime.ticks_ms()
if utime.ticks_diff(now, last) < d:
return bugs, last, False
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
for i in range(self.driver.num_leds):
self.driver.n[i] = bg_color
for b in bugs:
idx, ph = b
tri = 255 - abs(128 - ph) * 2
c = self.driver.apply_brightness(colors[idx % len(colors)], preset.b)
self.driver.n[idx] = ((c[0] * tri) // 255, (c[1] * tri) // 255, (c[2] * tri) // 255)
b[1] = (ph + speed) & 255
if random.randint(0, 31) == 0:
b[0] = random.randint(0, max(0, self.driver.num_leds - 1))
self.driver.n.write()
return bugs, utime.ticks_add(last, d), True
def run(self, preset):
"""Sparkles: n6 0 trail decay, 1 ice burst+halo, 2 fireflies."""
mode = style_mode(preset, 0, _LEGACY)
colors = preset.c if preset.c else [(120, 120, 255)]
last = utime.ticks_ms()
if mode == 2:
colors = preset.c if preset.c else [(255, 210, 80), (120, 255, 120)]
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 6)
bugs = [
[random.randint(0, max(0, self.driver.num_leds - 1)), random.randint(0, 255)]
for _ in range(count)
]
while True:
bugs, last, stepped = self._run_fireflies(preset, colors, bugs, last)
if stepped and not preset.a:
yield
return
yield
if mode == 1:
colors = preset.c if preset.c else [
(240, 248, 255),
(200, 235, 255),
(255, 255, 255),
]
sparks = []
while True:
sparks, last, stepped = self._run_ice(preset, colors, sparks, last)
if stepped and not preset.a:
yield
return
yield
while True:
last, stepped = self._run_trail(preset, colors, last)
if stepped and not preset.a:
yield
return
yield

View File

@@ -0,0 +1,45 @@
import utime
class StrobeBurst:
def __init__(self, driver):
self.driver = driver
def run(self, preset):
colors = preset.c if preset.c else [(255, 255, 255)]
state = "flash_on"
flash_idx = 0
state_start = utime.ticks_ms()
while True:
count = max(1, int(preset.n1) if int(preset.n1) > 0 else 3)
gap = max(1, int(preset.n2) if int(preset.n2) > 0 else 60)
cooldown = max(1, int(preset.n3) if int(preset.n3) > 0 else 400)
on_ms = max(1, int(preset.d) // 2)
c = self.driver.apply_brightness(colors[0], preset.b)
bg_color = self.driver.apply_brightness(preset.background_or(colors), preset.b)
now = utime.ticks_ms()
if state == "flash_on":
self.driver.fill(c)
if utime.ticks_diff(now, state_start) >= on_ms:
state = "flash_off"
state_start = utime.ticks_add(state_start, on_ms)
elif state == "flash_off":
self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= gap:
flash_idx += 1
if flash_idx >= count:
if not preset.a:
return
state = "cooldown"
flash_idx = 0
state_start = utime.ticks_add(state_start, gap)
else:
state = "flash_on"
state_start = utime.ticks_add(state_start, gap)
else:
self.driver.fill(bg_color)
if utime.ticks_diff(now, state_start) >= cooldown:
state = "flash_on"
state_start = utime.ticks_add(state_start, cooldown)
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

160
src/patterns/twinkle.py Normal file
View File

@@ -0,0 +1,160 @@
import random
import utime
# Default cool palette (icy blues, violet, mint) when preset has no colours.
_DEFAULT_COOL = (
(120, 200, 255),
(80, 140, 255),
(180, 120, 255),
(100, 220, 240),
(160, 200, 255),
(90, 180, 220),
)
class Twinkle:
def __init__(self, driver):
self.driver = driver
def _palette(self, preset):
colors = preset.c
if not colors:
return list(_DEFAULT_COOL)
out = []
for c in colors:
if isinstance(c, (list, tuple)) and len(c) == 3:
out.append(
(
max(0, min(255, int(c[0]))),
max(0, min(255, int(c[1]))),
max(0, min(255, int(c[2]))),
)
)
return out if out else list(_DEFAULT_COOL)
def run(self, preset):
"""Twinkle: n1 activity, n2 density; n3/n4 min/max length of adjacent on/off runs."""
palette = self._palette(preset)
num = self.driver.num_leds
bg_color = self.driver.apply_brightness(preset.background_or(palette), preset.b)
if num <= 0:
while True:
yield
return
def activity_rate():
r = int(preset.n1)
if r <= 0:
r = 48
return max(1, min(255, r))
def density255():
"""Higher → more LEDs lit on average when a twinkle step fires (0 = default mid)."""
d = int(preset.n2)
if d <= 0:
d = 128
return max(0, min(255, d))
def cluster_len_bounds():
"""n3 = min adjacent LEDs per twinkle, n4 = max (both 0 → 1..4)."""
lo = int(preset.n3)
hi = int(preset.n4)
if lo <= 0 and hi <= 0:
lo, hi = 1, min(4, num)
else:
if lo <= 0:
lo = 1
if hi <= 0:
hi = lo
if hi < lo:
lo, hi = hi, lo
lo = max(1, min(lo, num))
hi = max(lo, min(hi, num))
return lo, hi
def random_cluster_len():
lo, hi = cluster_len_bounds()
# When min and max match, every lit/dim run is exactly that many LEDs (still capped by strip length).
if lo == hi:
return lo
return random.randint(lo, hi)
def cluster_base_index(start, k):
"""Shift run left so a length-k segment fits; keeps full k when num >= k."""
k = min(max(0, int(k)), num)
if k <= 0:
return 0
return max(0, min(int(start), num - k))
dens = density255()
on = [random.randint(0, 255) < dens for _ in range(num)]
colour_i = [random.randint(0, len(palette) - 1) for _ in range(num)]
last_update = utime.ticks_ms()
if not preset.a:
for i in range(num):
if on[i]:
base = palette[colour_i[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else:
self.driver.n[i] = bg_color
self.driver.n.write()
yield
return
while True:
now = utime.ticks_ms()
delay_ms = max(1, int(preset.d))
if utime.ticks_diff(now, last_update) >= delay_ms:
rate = activity_rate()
dens = density255()
# Snapshot for decisions; apply all darks then all lights so
# overlaps in the same tick favour lit runs (lights win).
prev_on = on[:]
prev_ci = colour_i[:]
next_on = list(prev_on)
next_ci = list(prev_ci)
light_i = []
dark_i = []
for i in range(num):
if random.randint(0, 255) < rate:
r = random.randint(0, 255)
if not prev_on[i]:
if r < dens:
light_i.append(i)
else:
if r < (255 - dens):
dark_i.append(i)
def light_adjacent(start):
k = random_cluster_len()
b = cluster_base_index(start, k)
for dj in range(k):
idx = b + dj
next_on[idx] = True
next_ci[idx] = random.randint(0, len(palette) - 1)
def dark_adjacent(start):
k = random_cluster_len()
b = cluster_base_index(start, k)
for dj in range(k):
idx = b + dj
next_on[idx] = False
for i in dark_i:
dark_adjacent(i)
for i in light_i:
light_adjacent(i)
for i in range(num):
if next_on[i]:
base = palette[next_ci[i] % len(palette)]
self.driver.n[i] = self.driver.apply_brightness(base, preset.b)
else:
self.driver.n[i] = bg_color
self.driver.n.write()
on = next_on
colour_i = next_ci
last_update = utime.ticks_add(last_update, delay_ms)
yield

156
src/preset.py Normal file
View File

@@ -0,0 +1,156 @@
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
self.bg = (0, 0, 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",
"background": "bg",
"mode": "n6",
}
int_fields = {"d", "b", "n1", "n2", "n3", "n4", "n5", "n6"}
allowed_fields = {"p", "c", "d", "b", "a", "bg", "n1", "n2", "n3", "n4", "n5", "n6"}
for key, value in data.items():
if key == "reverse":
try:
if isinstance(value, bool):
self.n5 = 1 if value else 0
elif isinstance(value, (int, float)):
self.n5 = 1 if int(value) else 0
elif isinstance(value, str):
lowered = value.lower()
self.n5 = 1 if lowered in ("true", "1", "yes", "on") else 0
except (TypeError, ValueError):
pass
continue
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
elif key == "bg":
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
try:
self.bg = (
int(value[1:3], 16),
int(value[3:5], 16),
int(value[5:7], 16),
)
except (TypeError, ValueError):
continue
elif isinstance(value, (list, tuple)) and len(value) == 3:
try:
self.bg = tuple(max(0, min(255, int(x))) for x in value)
except (TypeError, ValueError):
continue
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 background_or(self, colors=None, default=(0, 0, 0)):
bg = getattr(self, "bg", None)
if isinstance(bg, (list, tuple)) and len(bg) == 3:
try:
return tuple(max(0, min(255, int(x))) for x in bg)
except (TypeError, ValueError):
return default
return default
def to_dict(self):
return {
"p": self.p,
"d": self.d,
"b": self.b,
"c": self.c,
"a": self.a,
"bg": self.bg,
"n1": self.n1,
"n2": self.n2,
"n3": self.n3,
"n4": self.n4,
"n5": self.n5,
"n6": self.n6,
}

304
src/presets.py Normal file
View File

@@ -0,0 +1,304 @@
from machine import Pin
from neopixel import NeoPixel
from preset import Preset
from utils import convert_and_reorder_colors
import json
import sys
import utime
try:
import uos as os
except ImportError:
import os
MAX_PRESETS = 32
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
self.reload_patterns()
def reload_patterns(self):
# Register built-in methods first, then discovered pattern classes
self.patterns = {
"off": self.off,
"on": self.on,
"blink": self.blink,
}
self.patterns.update(self._load_dynamic_patterns())
def _load_dynamic_patterns(self):
loaded = {}
try:
files = os.listdir("patterns")
except OSError:
return loaded
for filename in files:
if not filename.endswith(".py") or filename in ("__init__.py", "main.py"):
continue
module_basename = filename[:-3]
module_name = "patterns." + module_basename
try:
if module_name in sys.modules:
del sys.modules[module_name]
module = __import__(module_name, None, None, ["*"])
except Exception as e:
print("Pattern import failed:", module_name, e)
continue
pattern_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
# Pick the first class in the module that exposes run()
if isinstance(attr, type) and hasattr(attr, "run"):
pattern_class = attr
break
if pattern_class is None:
continue
try:
loaded[module_basename] = pattern_class(self).run
except Exception as e:
print("Pattern init failed:", module_name, e)
self._apply_pattern_aliases(loaded)
return loaded
def _apply_pattern_aliases(self, loaded):
"""Legacy pattern ids -> merged implementations (same generator)."""
aliases = (
("rainbow", "colour_cycle"),
("gradient_scroll", "colour_cycle"),
("meteor_rain", "meteor"),
("comet_dual", "meteor"),
("scanner", "meteor"),
("snowfall", "particles"),
("starfall", "particles"),
("sparkle_trail", "sparkle"),
("ice_sparkle", "sparkle"),
("fireflies", "sparkle"),
("marquee", "chase"),
("northern_wave", "aurora"),
)
for old, new in aliases:
if new in loaded and old not in loaded:
loaded[old] = loaded[new]
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():
if len(self.presets) >= MAX_PRESETS:
print("Preset limit reached on load:", MAX_PRESETS)
break
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)
return True
def edit(self, name, data):
"""Create or update a preset with the given name."""
if name in self.presets:
# Update existing preset
was_auto = self.presets[name].a
self.presets[name].edit(data)
# Editing the live preset: auto still re-selects (one tick) so the strip
# restarts without a separate select message (controller often sends both).
# Manual must NOT call select() here — presets-only pushes (e.g. zone sequence
# arming the first step) would otherwise run select's first tick and consume a
# beat/step. Manual advances only on explicit select from the controller.
if self.selected == name:
preset = self.presets[name]
if preset.a:
self.step = 0
self.generator = None
self.fill((0, 0, 0))
self.select(name)
elif was_auto:
self.step = 0
self.generator = None
self.fill((0, 0, 0))
else:
if len(self.presets) >= MAX_PRESETS and name not in ("on", "off"):
print("Preset limit reached:", MAX_PRESETS)
return False
# 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 delete_all(self):
self.presets = {}
self.generator = None
self.selected = None
return True
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:
if preset.p == "off":
self.generator = None
self.step = 0
self.fill((0, 0, 0))
self.selected = preset_name
return True
# If re-selecting the same preset before the main loop has tick()'d the
# previous frame, run one pending tick so step stays in sync.
if (
preset_name == self.selected
and self.generator is not None
):
self.tick()
# Set step value if explicitly provided
if step is not None:
self.step = step
elif 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
self.tick()
return True
print("select failed: pattern not found for preset", preset_name, "pattern=", preset.p)
return False
print("select failed: preset not found", preset_name)
# 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 is_reversed(self, preset):
from patterns.pattern_direction import is_reversed as _is_reversed
return _is_reversed(preset)
def led_i(self, preset, logical_index):
from patterns.pattern_direction import led_i as _led_i
return _led_i(self, preset, logical_index)
def signed(self, preset, value):
from patterns.pattern_direction import signed as _signed
return _signed(preset, value)
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)
lit = self.apply_brightness(color, preset.b)
while True:
self.fill(lit)
yield
def blink(self, preset):
"""Built-in blink (used by controller identify); no patterns/ deploy required."""
colors = preset.c if preset.c else [(255, 255, 255)]
bg_color = self.apply_brightness(preset.background_or(colors), preset.b)
color_index = 0
delay_ms = max(1, int(preset.d))
period = delay_ms * 2
now = utime.ticks_ms()
# Phase-lock to wall time so group identify (broadcast select) stays in sync even
# when devices process the packet on different main-loop iterations.
phase = now % period if period else 0
state = phase < delay_ms
last_update = utime.ticks_add(now, -phase)
if state:
base = colors[color_index % len(colors)]
self.fill(self.apply_brightness(base, preset.b))
color_index += 1
else:
self.fill(bg_color)
while True:
now = utime.ticks_ms()
if utime.ticks_diff(now, last_update) >= delay_ms:
if state:
base = colors[color_index % len(colors)]
self.fill(self.apply_brightness(base, preset.b))
color_index += 1
else:
self.fill(bg_color)
state = not state
last_update = utime.ticks_add(last_update, delay_ms)
yield
def run_tick(presets):
"""Advance one animation frame (standalone tests / mpremote demos)."""
presets.tick()

17
src/print_timestamp.py Normal file
View File

@@ -0,0 +1,17 @@
"""Install a builtins.print wrapper that prefixes each line with uptime (ms).
Import this module before other led-driver imports that print (e.g. first in main).
"""
import builtins
import utime
_original_print = builtins.print
def _timestamped_print(*args, **kwargs):
ts = utime.ticks_ms()
return _original_print("[%d]" % ts, *args, **kwargs)
builtins.print = _timestamped_print

12
src/runtime_state.py Normal file
View File

@@ -0,0 +1,12 @@
class RuntimeState:
def __init__(self):
self.hello = True
self.ws_client_count = 0
def ws_connected(self):
self.ws_client_count += 1
self.hello = False
def ws_disconnected(self):
self.ws_client_count = max(0, self.ws_client_count - 1)
self.hello = self.ws_client_count == 0

View File

@@ -3,6 +3,8 @@ import ubinascii
import machine
import network
WIFI_CHANNEL_DEFAULT = 5
class Settings(dict):
SETTINGS_FILE = "/settings.json"
@@ -12,23 +14,35 @@ class Settings(dict):
self.color_order = self.get_color_order(self["color_order"])
def set_defaults(self):
self["led_pin"] = 10
self["num_leds"] = 50
self["pattern"] = "on"
self["delay"] = 100
self["brightness"] = 10
self["color_order"] = "rgb"
self["name"] = f"led-{ubinascii.hexlify(network.WLAN(network.AP_IF).config('mac')).decode()}"
self["ap_password"] = ""
self["id"] = 0
self["num_leds"] = 200
self["color_order"] = "grb"
sta = network.WLAN(network.STA_IF)
sta.active(True)
#use led-mac for name
mac = sta.config("mac")
mac = ubinascii.hexlify(mac).decode().lower()
self["name"] = "led-" + mac
self["debug"] = False
self["default"] = "on"
self["last_preset"] = ""
# Power-on: "default" | "last" | "off"
self["startup_mode"] = "default"
self["brightness"] = 32
self["wifi_channel"] = WIFI_CHANNEL_DEFAULT
self["groups"] = []
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -37,13 +51,22 @@ class Settings(dict):
with open(self.SETTINGS_FILE, 'r') as file:
loaded_settings = json.load(file)
self.update(loaded_settings)
print("Settings loaded successfully.")
old_recent = self.pop("recent_presets", None)
if isinstance(old_recent, list) and old_recent and not self.get("last_preset"):
for x in reversed(old_recent):
if isinstance(x, str) and x.strip():
self["last_preset"] = x.strip()
break
if x is not None:
s = str(x).strip()
if s:
self["last_preset"] = s
break
except Exception as e:
print(f"Error loading settings")
self.set_defaults()
self.save()
def get_color_order(self, color_order):
"""Convert color order string to tuple of hex string indices."""
color_orders = {

51
src/startup.py Normal file
View File

@@ -0,0 +1,51 @@
import gc
import machine
import network
import utime
from presets import Presets
from settings import Settings
from controller_messages import apply_startup_pattern
def initialize_runtime():
machine.freq(160000000)
settings = Settings()
wdt = machine.WDT(timeout=10000)
wdt.feed()
gc.collect()
presets = Presets(settings["led_pin"], settings["num_leds"])
presets.load(settings)
presets.b = settings.get("brightness", 255)
presets.debug = bool(settings.get("debug", False))
gc.collect()
apply_startup_pattern(settings, presets)
# On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated.
# Reset both interfaces and collect before bringing STA up.
ap_if = network.WLAN(network.AP_IF)
ap_if.active(False)
sta_if = network.WLAN(network.STA_IF)
if sta_if.active():
sta_if.active(False)
utime.sleep_ms(100)
gc.collect()
sta_if.active(True)
sta_if.config(pm=network.WLAN.PM_NONE)
sta_if.connect(settings["ssid"], settings["password"])
while not sta_if.isconnected():
utime.sleep(1)
wdt.feed()
try:
led_ip = sta_if.ifconfig()[0]
except Exception:
led_ip = "?"
print("led-driver IP:", led_ip, " led-controller IP:", "(not connected)")
return settings, presets, wdt, sta_if

View File

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

36
src/v1_wire.py Normal file
View File

@@ -0,0 +1,36 @@
"""Expand short v1 wire keys to long names (MicroPython)."""
K_PRESETS = "p"
K_SELECT = "s"
K_GROUPS = "g"
K_SET_GROUPS = "sg"
K_SAVE = "sv"
K_DEFAULT = "df"
K_DEVICE_CONFIG = "dc"
K_CLEAR_PRESETS = "cp"
K_MANIFEST = "mf"
_SHORT_TO_LONG = {
K_PRESETS: "presets",
K_SELECT: "select",
K_GROUPS: "groups",
K_SET_GROUPS: "set_groups",
K_SAVE: "save",
K_DEFAULT: "default",
K_DEVICE_CONFIG: "device_config",
K_CLEAR_PRESETS: "clear_presets",
K_MANIFEST: "manifest",
}
def expand_v1(data):
if not isinstance(data, dict):
return data
out = dict(data)
for short_key, long_key in _SHORT_TO_LONG.items():
if short_key in data and long_key not in out:
out[long_key] = data[short_key]
if short_key in out:
del out[short_key]
return out

129
src/wifi_sta.py Normal file
View File

@@ -0,0 +1,129 @@
"""STA connect helpers aligned with tests/test_wifi.py (status polling, fatal codes)."""
import gc
import machine
import utime
import network
_CONNECT_TIMEOUT_S = 45
_RETRY_DELAY_S = 2
def _wifi_status_label(code):
names = {
getattr(network, "STAT_IDLE", 0): "idle",
getattr(network, "STAT_CONNECTING", 1): "connecting",
getattr(network, "STAT_WRONG_PASSWORD", -3): "wrong_password",
getattr(network, "STAT_NO_AP_FOUND", -2): "no_ap_found",
getattr(network, "STAT_CONNECT_FAIL", -1): "connect_fail",
getattr(network, "STAT_GOT_IP", 3): "got_ip",
}
return names.get(code, str(code))
# Only abort the wait loop immediately on wrong password. NO_AP_FOUND / CONNECT_FAIL are often
# transient while the radio is still scanning (ESP32-C3 may report them before the AP appears).
_ABORT_WAIT_IMMEDIATE = (
getattr(network, "STAT_WRONG_PASSWORD", -3),
)
def _one_association_campaign(sta_if, ssid, password, wdt):
"""disconnect → connect → wait until connected, wrong password, or timeout. Returns True if connected."""
try:
sta_if.disconnect()
except Exception:
pass
utime.sleep_ms(200)
try:
sta_if.connect(ssid, password)
except Exception as ex:
print("wifi_sta: connect raised:", ex)
return False
start = utime.time()
last_status = None
while not sta_if.isconnected():
status = sta_if.status()
if status != last_status:
print("wifi_sta: status", status, _wifi_status_label(status))
last_status = status
if status in _ABORT_WAIT_IMMEDIATE:
return False
if utime.time() - start >= _CONNECT_TIMEOUT_S:
print("wifi_sta: association timeout")
return False
utime.sleep(1)
if wdt is not None:
wdt.feed()
return True
def boot_sta(settings, wdt):
"""Tear down and bring up STA. Call before large heap users (NeoPixel, patterns).
On ESP32-C3, soft reboots can leave the Wi-Fi driver allocated; init while the
heap is still free. If re-init fails after a soft reboot, hard-reset once.
"""
sta_if = network.WLAN(network.STA_IF)
try:
if sta_if.active():
try:
sta_if.disconnect()
except Exception:
pass
sta_if.active(False)
except Exception:
pass
utime.sleep_ms(100)
gc.collect()
try:
sta_if.active(True)
except OSError as e:
err = str(e)
if "Out of Memory" in err or "WiFi" in err:
if machine.reset_cause() == machine.SOFT_RESET:
print("wifi_sta: init failed after soft reboot, hard reset:", err)
machine.reset()
raise
sta_if.config(pm=network.WLAN.PM_NONE)
ssid = settings.get("ssid") or ""
if ssid:
connect_until_up(sta_if, ssid, settings.get("password") or "", wdt)
return sta_if
def connect_until_up(sta_if, ssid, password, wdt):
"""Boot: repeat campaigns until STA has a route (same strategy as tests/test_wifi.py)."""
if not ssid:
print("wifi_sta: no ssid in settings")
return False
attempt = 0
while True:
attempt += 1
print("wifi_sta: boot attempt", attempt, "ssid=", repr(ssid))
if _one_association_campaign(sta_if, ssid, password, wdt):
try:
print("wifi_sta: connected", sta_if.ifconfig()[0])
except Exception:
print("wifi_sta: connected")
return True
print("wifi_sta: retry in", _RETRY_DELAY_S, "s")
for _ in range(_RETRY_DELAY_S):
utime.sleep(1)
if wdt is not None:
wdt.feed()
def try_reconnect(sta_if, ssid, password, wdt):
"""Runtime: single association campaign after link loss; non-looping."""
if not ssid:
return False
print("wifi_sta: reconnect")
ok = _one_association_campaign(sta_if, ssid, password, wdt)
if ok:
try:
print("wifi_sta: connected", sta_if.ifconfig()[0])
except Exception:
print("wifi_sta: connected")
return ok

View File

@@ -1,190 +0,0 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, duration_ms):
"""Run pattern for specified duration."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
print("=" * 50)
print("Testing Auto and Manual Modes")
print("=" * 50)
# Test 1: Rainbow in AUTO mode (continuous)
print("\nTest 1: Rainbow pattern in AUTO mode (should run continuously)")
p.edit("rainbow_auto", {
"pattern": "rainbow",
"brightness": 128,
"delay": 50,
"n1": 2,
"auto": True
})
p.select("rainbow_auto")
print("Running rainbow_auto for 3 seconds...")
run_for(p, wdt, 3000)
print("✓ Auto mode: Pattern ran continuously")
# Test 2: Rainbow in MANUAL mode (one step per tick)
print("\nTest 2: Rainbow pattern in MANUAL mode (one step per tick)")
p.edit("rainbow_manual", {
"pattern": "rainbow",
"brightness": 128,
"delay": 50,
"n1": 2,
"auto": False
})
p.select("rainbow_manual")
print("Calling tick() 5 times (should advance 5 steps)...")
for i in range(5):
p.tick()
utime.sleep_ms(100) # Small delay to see changes
print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}")
# Check if generator stopped after one cycle
if p.generator is None:
print("✓ Manual mode: Generator stopped after one step (as expected)")
else:
print("⚠ Manual mode: Generator still active (may need multiple ticks)")
# Test 3: Pulse in AUTO mode (continuous cycles)
print("\nTest 3: Pulse pattern in AUTO mode (should pulse continuously)")
p.edit("pulse_auto", {
"pattern": "pulse",
"brightness": 128,
"delay": 100,
"n1": 500, # Attack
"n2": 200, # Hold
"n3": 500, # Decay
"colors": [(255, 0, 0)],
"auto": True
})
p.select("pulse_auto")
print("Running pulse_auto for 3 seconds...")
run_for(p, wdt, 3000)
print("✓ Auto mode: Pulse ran continuously")
# Test 4: Pulse in MANUAL mode (one cycle then stop)
print("\nTest 4: Pulse pattern in MANUAL mode (one cycle then stop)")
p.edit("pulse_manual", {
"pattern": "pulse",
"brightness": 128,
"delay": 100,
"n1": 300, # Attack
"n2": 200, # Hold
"n3": 300, # Decay
"colors": [(0, 255, 0)],
"auto": False
})
p.select("pulse_manual")
print("Running pulse_manual until generator stops...")
tick_count = 0
max_ticks = 200 # Safety limit
while p.generator is not None and tick_count < max_ticks:
p.tick()
tick_count += 1
utime.sleep_ms(10)
if p.generator is None:
print(f"✓ Manual mode: Pulse completed one cycle after {tick_count} ticks")
else:
print(f"⚠ Manual mode: Pulse still running after {tick_count} ticks")
# Test 5: Transition in AUTO mode (continuous transitions)
print("\nTest 5: Transition pattern in AUTO mode (continuous transitions)")
p.edit("transition_auto", {
"pattern": "transition",
"brightness": 128,
"delay": 500,
"colors": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
"auto": True
})
p.select("transition_auto")
print("Running transition_auto for 3 seconds...")
run_for(p, wdt, 3000)
print("✓ Auto mode: Transition ran continuously")
# Test 6: Transition in MANUAL mode (one transition then stop)
print("\nTest 6: Transition pattern in MANUAL mode (one transition then stop)")
p.edit("transition_manual", {
"pattern": "transition",
"brightness": 128,
"delay": 500,
"colors": [(255, 0, 0), (0, 255, 0)],
"auto": False
})
p.select("transition_manual")
print("Running transition_manual until generator stops...")
tick_count = 0
max_ticks = 200
while p.generator is not None and tick_count < max_ticks:
p.tick()
tick_count += 1
utime.sleep_ms(10)
if p.generator is None:
print(f"✓ Manual mode: Transition completed after {tick_count} ticks")
else:
print(f"⚠ Manual mode: Transition still running after {tick_count} ticks")
# Test 7: Switching between auto and manual modes
print("\nTest 7: Switching between auto and manual modes")
p.edit("switch_test", {
"pattern": "rainbow",
"brightness": 128,
"delay": 50,
"n1": 2,
"auto": True
})
p.select("switch_test")
print("Running in auto mode for 1 second...")
run_for(p, wdt, 1000)
# Switch to manual mode by editing the preset
print("Switching to manual mode...")
p.edit("switch_test", {"auto": False})
p.select("switch_test") # Re-select to apply changes
print("Calling tick() 3 times in manual mode...")
for i in range(3):
p.tick()
utime.sleep_ms(100)
print(f" Tick {i+1}: generator={'active' if p.generator is not None else 'stopped'}")
# Switch back to auto mode
print("Switching back to auto mode...")
p.edit("switch_test", {"auto": True})
p.select("switch_test")
print("Running in auto mode for 1 second...")
run_for(p, wdt, 1000)
print("✓ Successfully switched between auto and manual modes")
# Cleanup
print("\nCleaning up...")
p.edit("cleanup_off", {"pattern": "off"})
p.select("cleanup_off")
p.tick()
utime.sleep_ms(100)
print("\n" + "=" * 50)
print("All tests completed!")
print("=" * 50)
if __name__ == "__main__":
main()

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Create blink preset
p.edit("test_blink", {
"pattern": "blink",
"brightness": 64,
"delay": 200,
"colors": [(255, 0, 0), (0, 0, 255)]
})
p.select("test_blink")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 1500:
wdt.feed()
p.tick()
utime.sleep_ms(10)
if __name__ == "__main__":
main()

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
def run_for(p, wdt, ms):
"""Helper: run current pattern for given ms using tick()."""
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
utime.sleep_ms(10)
def main():
s = Settings()
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# Test 1: Basic rainbow with auto=True (continuous)
print("Test 1: Basic rainbow (auto=True, n1=1)")
p.edit("rainbow1", {
"pattern": "rainbow",
"brightness": 255,
"delay": 100,
"n1": 1,
"auto": True
})
p.select("rainbow1")
run_for(p, wdt, 3000)
# Test 2: Fast rainbow
print("Test 2: Fast rainbow (low delay, n1=1)")
p.edit("rainbow2", {
"pattern": "rainbow",
"delay": 50,
"n1": 1,
"auto": True
})
p.select("rainbow2")
run_for(p, wdt, 2000)
# Test 3: Slow rainbow
print("Test 3: Slow rainbow (high delay, n1=1)")
p.edit("rainbow3", {
"pattern": "rainbow",
"delay": 500,
"n1": 1,
"auto": True
})
p.select("rainbow3")
run_for(p, wdt, 3000)
# Test 4: Low brightness rainbow
print("Test 4: Low brightness rainbow (n1=1)")
p.edit("rainbow4", {
"pattern": "rainbow",
"brightness": 64,
"delay": 100,
"n1": 1,
"auto": True
})
p.select("rainbow4")
run_for(p, wdt, 2000)
# Test 5: Single-step rainbow (auto=False)
print("Test 5: Single-step rainbow (auto=False, n1=1)")
p.edit("rainbow5", {
"pattern": "rainbow",
"brightness": 255,
"delay": 100,
"n1": 1,
"auto": False
})
p.step = 0
for i in range(10):
p.select("rainbow5")
# One tick advances the generator one frame when auto=False
p.tick()
utime.sleep_ms(100)
wdt.feed()
# Test 6: Verify step updates correctly
print("Test 6: Verify step updates (auto=False, n1=1)")
p.edit("rainbow6", {
"pattern": "rainbow",
"n1": 1,
"auto": False
})
initial_step = p.step
p.select("rainbow6")
p.tick()
final_step = p.step
print(f"Step updated from {initial_step} to {final_step} (expected increment: 1)")
# Test 7: Fast step increment (n1=5)
print("Test 7: Fast rainbow (n1=5, auto=True)")
p.edit("rainbow7", {
"pattern": "rainbow",
"brightness": 255,
"delay": 100,
"n1": 5,
"auto": True
})
p.select("rainbow7")
run_for(p, wdt, 2000)
# Test 8: Very fast step increment (n1=10)
print("Test 8: Very fast rainbow (n1=10, auto=True)")
p.edit("rainbow8", {
"pattern": "rainbow",
"n1": 10,
"auto": True
})
p.select("rainbow8")
run_for(p, wdt, 2000)
# Test 9: Verify n1 controls step increment (auto=False)
print("Test 9: Verify n1 step increment (auto=False, n1=5)")
p.edit("rainbow9", {
"pattern": "rainbow",
"n1": 5,
"auto": False
})
p.step = 0
initial_step = p.step
p.select("rainbow9")
p.tick()
final_step = p.step
expected_step = (initial_step + 5) % 256
print(f"Step updated from {initial_step} to {final_step} (expected: {expected_step})")
if final_step == expected_step:
print("✓ n1 step increment working correctly")
else:
print(f"✗ Step increment mismatch! Expected {expected_step}, got {final_step}")
# Cleanup
print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

419
tests/all.py Normal file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Self-contained led-driver test runner for MicroPython/mpremote.
Run on device (from led-driver repo root)::
mpremote connect <port> run tests/all.py
Or via dev helper::
python dev.py <port> test
"""
import json
import sys
import utime
from machine import WDT
def _bootstrap_import_path():
"""Find ``settings`` / ``presets`` whether this file lives in ``tests/`` or ``:/``."""
try:
import uos as os
except ImportError:
import os
candidates = []
try:
here = __file__.rsplit("/", 1)[0]
if here:
candidates.append(here)
parent = here.rsplit("/", 1)[0]
if parent:
candidates.append(parent)
except NameError:
pass
candidates.extend([".", "..", "/"])
for p in candidates:
if p and p not in sys.path:
sys.path.insert(0, p)
_bootstrap_import_path()
from settings import Settings # noqa: E402
from presets import Presets, run_tick # noqa: E402
from preset import Preset # noqa: E402
from utils import convert_and_reorder_colors # noqa: E402
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()
run_tick(self.presets)
utime.sleep_ms(sleep_ms)
def _pattern_loaded(ctx, pattern_id):
return pattern_id in ctx.presets.patterns
def _smoke_preset(ctx, name, data, ms=80):
pattern_id = data.get("p") or data.get("pattern")
if not _pattern_loaded(ctx, pattern_id):
raise AssertionError("pattern not loaded: %s" % pattern_id)
ctx.presets.edit(name, data)
if not ctx.presets.select(name):
raise AssertionError("select failed: %s" % name)
ctx.tick_for_ms(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_preset_mode_alias_maps_to_n6():
ctx = _TestContext()
ctx.presets.edit(
"rainbow_mode",
{"pattern": "colour_cycle", "mode": 1, "d": 50, "n1": 2, "a": True},
)
p = ctx.presets.presets["rainbow_mode"]
assert p.p == "colour_cycle"
assert p.n6 == 1
def test_style_mode_and_legacy_aliases():
from patterns.pattern_modes import style_mode
p = Preset({"p": "colour_cycle", "mode": 0, "d": 50, "c": [(255, 0, 0)]})
assert style_mode(p, 0, {"rainbow": 1}) == 0
legacy = Preset({"p": "rainbow", "d": 50, "c": [(255, 0, 0)]})
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
ctx = _TestContext()
legacy_ids = (
"rainbow",
"meteor_rain",
"snowfall",
"sparkle_trail",
"marquee",
"northern_wave",
)
for lid in legacy_ids:
if not _pattern_loaded(ctx, lid):
raise AssertionError("legacy alias not registered: %s" % lid)
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"
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_colour_cycle": {"p": "colour_cycle", "n6": 0, "d": 5, "n1": 2, "c": [(255, 0, 0), (0, 255, 0)]},
"t_chase": {"p": "chase", "c": [(255, 0, 0), (0, 0, 255)], "n1": 3, "n2": 2, "n3": 1, "n4": 1, "d": 20},
}
for name, data in cases.items():
_smoke_preset(ctx, name, data, ms=100)
def test_merged_pattern_modes():
"""Smoke each style (``n6`` / ``mode``) for merged multi-mode patterns."""
ctx = _TestContext()
colors = [(200, 220, 255), (255, 180, 80)]
cases = (
("mc_grad", "colour_cycle", {"p": "colour_cycle", "n6": 0, "n1": 2, "d": 8, "c": colors}),
("mc_wheel", "colour_cycle", {"p": "colour_cycle", "mode": 1, "n1": 2, "d": 8}),
("chase_std", "chase", {"p": "chase", "n6": 0, "n1": 2, "n2": 2, "n3": 1, "n4": 1, "d": 15, "c": colors}),
("chase_marq", "chase", {"p": "chase", "n6": 1, "n1": 3, "n2": 2, "n3": 1, "d": 15, "c": colors}),
("meteor_0", "meteor", {"p": "meteor", "n6": 0, "n1": 4, "n2": 2, "n3": 8, "d": 10, "c": colors}),
("meteor_1", "meteor", {"p": "meteor", "n6": 1, "n1": 3, "n2": 2, "n3": 4, "d": 10, "c": colors}),
("part_0", "particles", {"p": "particles", "n6": 0, "n1": 4, "n2": 1, "d": 10, "c": colors}),
("part_1", "particles", {"p": "particles", "mode": 1, "n1": 3, "n2": 1, "n3": 4, "d": 10, "c": colors}),
("spark_0", "sparkle", {"p": "sparkle", "n6": 0, "n1": 4, "n2": 6, "d": 10, "c": colors}),
("spark_1", "sparkle", {"p": "sparkle", "n6": 1, "n1": 3, "n2": 4, "n3": 2, "d": 10, "c": colors}),
("aurora_0", "aurora", {"p": "aurora", "n6": 0, "n1": 3, "n2": 2, "n3": 0, "d": 12, "c": colors}),
("aurora_1", "aurora", {"p": "aurora", "mode": 1, "n1": 8, "n2": 2, "n3": 1, "d": 12, "c": colors}),
)
for name, pattern_id, data in cases:
if not _pattern_loaded(ctx, pattern_id):
continue
_smoke_preset(ctx, name, data, ms=60)
legacy_smoke = (
("leg_rainbow", "rainbow", {"p": "rainbow", "d": 8, "n1": 2}),
("leg_ice", "ice_sparkle", {"p": "ice_sparkle", "n1": 3, "n2": 2, "n3": 2, "d": 10, "c": colors}),
("leg_wave", "northern_wave", {"p": "northern_wave", "n1": 6, "n2": 2, "n3": 1, "d": 12, "c": colors}),
("leg_star", "starfall", {"p": "starfall", "n1": 3, "n2": 1, "n3": 3, "d": 10, "c": colors}),
)
for name, pattern_id, data in legacy_smoke:
if not _pattern_loaded(ctx, pattern_id):
continue
_smoke_preset(ctx, name, data, ms=60)
def test_patterns_do_not_use_blocking_sleep():
try:
import uos as os
except ImportError:
import os
pattern_dir = "patterns"
offenders = []
try:
files = os.listdir(pattern_dir)
except OSError:
raise AssertionError("patterns directory is missing")
skip = frozenset(("__init__.py", "main.py", "pattern_modes.py"))
for filename in files:
if not filename.endswith(".py") or filename in skip:
continue
path = pattern_dir + "/" + filename
try:
with open(path, "r") as f:
src = f.read()
except OSError:
offenders.append(filename + " (unreadable)")
continue
if (
"utime.sleep(" in src
or "utime.sleep_ms(" in src
or "time.sleep(" in src
or "time.sleep_ms(" in src
):
offenders.append(filename)
assert not offenders, "blocking sleep found in patterns: %s" % ", ".join(offenders)
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():
try:
import uos as os
except ImportError:
import os
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_preset_mode_alias_maps_to_n6,
test_style_mode_and_legacy_aliases,
test_colour_conversion_and_transition,
test_pattern_smoke,
test_merged_pattern_modes,
test_patterns_do_not_use_blocking_sleep,
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()

169
tests/bridge_ws_blink.py Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Send blink preset + select to a driver via the ESP-NOW bridge WebSocket.
Pairs with the on-device demo ``tests/patterns/blink.py``: same preset slot,
pattern, and colours; this script reaches the driver over ESP-NOW through
``espnow-sender`` (devices envelope, not legacy broadcast JSON).
Run from the **led-controller** repo (needs ``websockets`` in Pipenv)::
pipenv run python led-driver/tests/bridge_ws_blink.py
pipenv run python led-driver/tests/bridge_ws_blink.py \\
--url ws://192.168.4.1/ws --mac 18:8b:0e:15:60:a8
From **led-driver** (if Pipenv/env is the parent project)::
pipenv run python tests/bridge_ws_blink.py --dry-run
"""
from __future__ import annotations
import argparse
import asyncio
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, Optional
# led-driver/tests -> led-driver -> led-controller
LED_DRIVER_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = LED_DRIVER_ROOT.parent
def _load_bridge_url(explicit: Optional[str]) -> str:
if explicit and explicit.strip():
return explicit.strip()
for path in (PROJECT_ROOT / "settings.json", LED_DRIVER_ROOT / "settings.json"):
if not path.is_file():
continue
try:
data = json.loads(path.read_text(encoding="utf-8"))
url = str(data.get("bridge_ws_url") or "").strip()
if url:
return url
except (OSError, json.JSONDecodeError, TypeError):
pass
return "ws://192.168.4.1/ws"
def _format_mac(mac: str) -> str:
s = re.sub(r"[^0-9a-fA-F]", "", str(mac or "").strip().lower())
if len(s) != 12 or not re.fullmatch(r"[0-9a-f]{12}", s):
raise ValueError("MAC must be 12 hex digits (e.g. 188b0e1560a8)")
return ":".join(s[i : i + 2] for i in range(0, 12, 2))
def build_blink_envelope(
mac: str,
*,
preset_id: str = "2",
delay_ms: int = 200,
brightness: int = 64,
) -> Dict[str, Any]:
"""v1 devices envelope: preset body + list select (same shape as the Pi)."""
body = {
"p": {
preset_id: {
"p": "blink",
"b": max(0, min(255, int(brightness))),
"d": max(1, int(delay_ms)),
"c": ["#FF0000", "#0000FF"],
"a": True,
}
},
"s": [str(preset_id)],
}
return {"v": "1", "dv": {_format_mac(mac): body}}
async def _send(url: str, envelope: Dict[str, Any], hold_s: float) -> None:
import websockets
packet = json.dumps(envelope, separators=(",", ":")).encode("utf-8")
print(f"connecting to {url}")
async with websockets.connect(url, ping_interval=20, ping_timeout=20) as ws:
print(f"connected, sending {len(packet)} B")
print(packet.decode("utf-8"))
await ws.send(packet)
if hold_s > 0:
print(f"holding connection {hold_s}s …")
await asyncio.sleep(hold_s)
print("done")
def main() -> int:
parser = argparse.ArgumentParser(
description="Send blink preset+select to one driver via bridge WebSocket.",
)
parser.add_argument(
"--url",
default=None,
help="Bridge WebSocket URL (default: settings.json bridge_ws_url or ws://192.168.4.1/ws)",
)
parser.add_argument(
"--mac",
default="188b0e1560a8",
help="Driver MAC (12 hex, colons optional). Default: registry example id.",
)
parser.add_argument(
"--preset-id",
default="2",
help="Wire preset slot id (default: 2, matches zone push)",
)
parser.add_argument(
"--delay-ms",
type=int,
default=200,
help="Blink delay in ms (default: 200)",
)
parser.add_argument(
"--brightness",
type=int,
default=64,
help="Preset brightness 0255 (default: 64)",
)
parser.add_argument(
"--hold",
type=float,
default=2.0,
help="Seconds to keep WebSocket open after send (default: 2)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print envelope only; do not connect",
)
args = parser.parse_args()
url = _load_bridge_url(args.url)
try:
envelope = build_blink_envelope(
args.mac,
preset_id=args.preset_id,
delay_ms=args.delay_ms,
brightness=args.brightness,
)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"url={url!r} mac={_format_mac(args.mac)!r}")
if args.dry_run:
print(json.dumps(envelope, indent=2))
return 0
try:
asyncio.run(_send(url, envelope, args.hold))
except KeyboardInterrupt:
print("interrupted")
return 130
except Exception as e:
print(f"failed: {e!r}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,116 @@
"""Device-side radio diagnostic test (MicroPython).
Checks:
1) STA/AP bring-up on channel 5
2) ESP-NOW init and broadcast peer add
3) Broadcast TX test packet send
4) RX wait window to see any incoming ESP-NOW frames
"""
import espnow
import machine
import network
import time
import ubinascii
CHANNEL = 5
RX_WINDOW_MS = 3000
WDT_TIMEOUT_MS = 10000
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
TEST_PAYLOAD = b"\x4c\x05\x01\x00\x00\x00"
def _mac_hex(mac_bytes):
try:
return ubinascii.hexlify(mac_bytes).decode()
except Exception:
return "?"
def run_diag(channel=CHANNEL, rx_window_ms=RX_WINDOW_MS):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
wdt.feed()
print("diag start")
print("cpu freq", machine.freq())
sta = network.WLAN(network.STA_IF)
ap = network.WLAN(network.AP_IF)
# Clean start
try:
sta.active(False)
ap.active(False)
time.sleep_ms(100)
except Exception as e:
print("wifi reset failed", repr(e))
# STA setup
try:
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
print("sta ok ch", sta.config("channel"), "mac", _mac_hex(sta.config("mac")))
except Exception as e:
print("sta setup failed", repr(e))
# AP setup
try:
ap.active(True)
try:
ap.config(essid="diag-ap", channel=channel, hidden=True)
except TypeError:
ap.config(essid="diag-ap", channel=channel)
print("ap ok ch", ap.config("channel"), "mac", _mac_hex(ap.config("mac")))
except Exception as e:
print("ap setup failed", repr(e))
wdt.feed()
# ESP-NOW setup
try:
e = espnow.ESPNow()
e.active(True)
print("espnow active ok")
except Exception as e_err:
print("espnow init failed", repr(e_err))
return
# Add broadcast peer
try:
e.add_peer(BROADCAST_MAC, channel=channel)
print("add bcast peer ok")
except TypeError:
try:
e.add_peer(BROADCAST_MAC)
print("add bcast peer ok (no channel arg)")
except Exception as e_err:
print("add bcast peer failed", repr(e_err))
except Exception as e_err:
print("add bcast peer failed", repr(e_err))
# TX test
try:
ok = e.send(BROADCAST_MAC, TEST_PAYLOAD, True)
print("tx bcast", ok, "len", len(TEST_PAYLOAD))
except Exception as e_err:
print("tx bcast failed", repr(e_err))
# RX window
print("rx window ms", rx_window_ms)
t_end = time.ticks_add(time.ticks_ms(), rx_window_ms)
rx_count = 0
while time.ticks_diff(t_end, time.ticks_ms()) > 0:
wdt.feed()
host, msg = e.recv(100)
if host:
rx_count += 1
print("rx", rx_count, _mac_hex(host), "len", len(msg))
time.sleep_ms(5)
print("diag done rx_count", rx_count)
if __name__ == "__main__":
run_diag()

View File

@@ -0,0 +1,40 @@
"""Device test: receive ESP-NOW packets on channel 5 (MicroPython)."""
import espnow
import machine
import network
import ubinascii
import time
CHANNEL = 5
TIMEOUT_MS = 1000
WDT_TIMEOUT_MS = 10000
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def recv_loop(channel=CHANNEL, timeout_ms=TIMEOUT_MS):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
print("recv ready ch", channel)
while True:
wdt.feed()
host, msg = e.recv(timeout_ms)
if host:
mac_hex = ubinascii.hexlify(host).decode()
print("rx", mac_hex, "len", len(msg), "hex", ubinascii.hexlify(msg).decode())
else:
print("rx timeout")
time.sleep_ms(10)
if __name__ == "__main__":
recv_loop()

View File

@@ -0,0 +1,47 @@
"""Device test: send one ESP-NOW packet on channel 5 (MicroPython)."""
import espnow
import machine
import network
import ubinascii
CHANNEL = 5
DEST_HEX = "ffffffffffff"
PAYLOAD_HEX = "4c0501000000"
WDT_TIMEOUT_MS = 10000
def _set_channel(channel):
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
sta.config(channel=channel)
def _add_peer(esp, dest, channel):
try:
esp.add_peer(dest, channel=channel)
except TypeError:
esp.add_peer(dest)
except OSError:
pass
def send_once(dest_hex=DEST_HEX, payload_hex=PAYLOAD_HEX, channel=CHANNEL):
wdt = machine.WDT(timeout=WDT_TIMEOUT_MS)
wdt.feed()
dest = ubinascii.unhexlify(dest_hex)
pkt = ubinascii.unhexlify(payload_hex)
_set_channel(channel)
e = espnow.ESPNow()
e.active(True)
_add_peer(e, dest, channel)
wdt.feed()
ok = e.send(dest, pkt, True)
print("sent", ok, "ch", channel, "dest", dest_hex, "len", len(pkt))
return ok
if __name__ == "__main__":
send_once()

40
tests/patterns/aurora.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_aurora", {
"p": "aurora",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_aurora")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_bar_graph", {
"p": "bar_graph",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_bar_graph")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

74
tests/patterns/blink.py Normal file
View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Standalone blink pattern demo (WDT-fed tick loop).
Run on device::
mpremote connect <port> run tests/patterns/blink.py
"""
import sys
import utime
def _bootstrap_import_path():
"""Find ``settings`` / ``presets`` on device or when run via mpremote."""
try:
import uos as os
except ImportError:
import os
candidates = []
try:
here = __file__.rsplit("/", 1)[0]
if here:
candidates.append(here)
tests = here.rsplit("/", 1)[0]
if tests:
candidates.append(tests)
root = tests.rsplit("/", 1)[0]
if root:
candidates.append(root)
candidates.append(root + "/src")
except NameError:
pass
for p in (".", "..", "/", "src", "/src"):
candidates.append(p)
for p in candidates:
if p and p not in sys.path:
sys.path.insert(0, p)
_bootstrap_import_path()
from machine import WDT # noqa: E402
from settings import Settings # noqa: E402
from presets import Presets # noqa: E402
def _run_ms(presets, wdt, duration_ms, sleep_ms=10):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < duration_ms:
wdt.feed()
presets.tick()
utime.sleep_ms(sleep_ms)
def main():
settings = Settings()
presets = Presets(settings.get("led_pin", 10), settings.get("num_leds", 30))
wdt = WDT(timeout=10000)
presets.edit(
"test_blink",
{
"p": "blink",
"b": 64,
"d": 200,
"c": [(255, 0, 0), (0, 0, 255)],
},
)
presets.select("test_blink")
_run_ms(presets, wdt, 1500)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_breathing_dual", {
"p": "breathing_dual",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_breathing_dual")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
from presets import Presets, run_tick
def run_for(p, wdt, ms):
@@ -10,7 +10,7 @@ def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)
@@ -19,20 +19,20 @@ def main():
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
p = Presets(pin=pin, num_leds=num)
wdt = WDT(timeout=10000)
# 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", {
"pattern": "chase",
"brightness": 255,
"delay": 200,
"p": "chase",
"b": 255,
"d": 200,
"n1": 5,
"n2": 5,
"n3": 1,
"n4": 1,
"colors": [(255, 0, 0), (0, 255, 0)]
"c": [(255, 0, 0), (0, 255, 0)],
})
p.select("chase1")
run_for(p, wdt, 3000)
@@ -40,13 +40,13 @@ def main():
# Test 2: Forward and backward (n3=2, n4=-1)
print("Test 2: Forward and backward (n3=2, n4=-1)")
p.edit("chase2", {
"pattern": "chase",
"p": "chase",
"n1": 3,
"n2": 3,
"n3": 2,
"n4": -1,
"delay": 150,
"colors": [(0, 0, 255), (255, 255, 0)]
"d": 150,
"c": [(0, 0, 255), (255, 255, 0)],
})
p.select("chase2")
run_for(p, wdt, 3000)
@@ -54,13 +54,13 @@ def main():
# Test 3: Large segments (n1=10, n2=5)
print("Test 3: Large segments (n1=10, n2=5, n3=3, n4=3)")
p.edit("chase3", {
"pattern": "chase",
"p": "chase",
"n1": 10,
"n2": 5,
"n3": 3,
"n4": 3,
"delay": 200,
"colors": [(255, 128, 0), (128, 0, 255)]
"d": 200,
"c": [(255, 128, 0), (128, 0, 255)],
})
p.select("chase3")
run_for(p, wdt, 3000)
@@ -68,13 +68,13 @@ def main():
# Test 4: Fast movement (n3=5, n4=5)
print("Test 4: Fast movement (n3=5, n4=5)")
p.edit("chase4", {
"pattern": "chase",
"p": "chase",
"n1": 4,
"n2": 4,
"n3": 5,
"n4": 5,
"delay": 100,
"colors": [(255, 0, 255), (0, 255, 255)]
"d": 100,
"c": [(255, 0, 255), (0, 255, 255)],
})
p.select("chase4")
run_for(p, wdt, 2000)
@@ -82,13 +82,13 @@ def main():
# Test 5: Backward movement (n3=-2, n4=-2)
print("Test 5: Backward movement (n3=-2, n4=-2)")
p.edit("chase5", {
"pattern": "chase",
"p": "chase",
"n1": 6,
"n2": 4,
"n3": -2,
"n4": -2,
"delay": 200,
"colors": [(255, 255, 255), (0, 0, 0)]
"d": 200,
"c": [(255, 255, 255), (0, 0, 0)],
})
p.select("chase5")
run_for(p, wdt, 3000)
@@ -96,13 +96,13 @@ def main():
# Test 6: Alternating forward/backward (n3=3, n4=-2)
print("Test 6: Alternating forward/backward (n3=3, n4=-2)")
p.edit("chase6", {
"pattern": "chase",
"p": "chase",
"n1": 5,
"n2": 5,
"n3": 3,
"n4": -2,
"delay": 250,
"colors": [(255, 0, 0), (0, 255, 0)]
"d": 250,
"c": [(255, 0, 0), (0, 255, 0)],
})
p.select("chase6")
run_for(p, wdt, 4000)
@@ -110,20 +110,20 @@ def main():
# Test 7: Manual mode - advance one step per beat
print("Test 7: Manual mode chase (auto=False, n3=2, n4=1)")
p.edit("chase_manual", {
"pattern": "chase",
"p": "chase",
"n1": 4,
"n2": 4,
"n3": 2,
"n4": 1,
"delay": 200,
"colors": [(255, 255, 0), (0, 255, 255)],
"auto": False
"d": 200,
"c": [(255, 255, 0), (0, 255, 255)],
"a": False,
})
p.step = 0 # Reset step counter
print(" Advancing pattern with 10 beats (select + tick)...")
for i in range(10):
p.select("chase_manual") # Simulate beat - restarts generator
p.tick() # Advance one step
run_tick(p) # Advance one step
utime.sleep_ms(500) # Pause to see the pattern
wdt.feed()
print(f" Beat {i+1}: step={p.step}")
@@ -131,17 +131,17 @@ def main():
# Test 8: Verify step increments correctly in manual mode
print("Test 8: Verify step increments (auto=False)")
p.edit("chase_manual2", {
"pattern": "chase",
"p": "chase",
"n1": 3,
"n2": 3,
"n3": 1,
"n4": 1,
"auto": False
"a": False,
})
p.step = 0
initial_step = p.step
p.select("chase_manual2")
p.tick()
run_tick(p)
final_step = p.step
print(f" Step updated from {initial_step} to {final_step} (expected: 1)")
if final_step == 1:
@@ -151,7 +151,7 @@ def main():
# Cleanup
print("Test complete, turning off")
p.edit("cleanup_off", {"pattern": "off"})
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)

View File

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

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_clock_sweep", {
"p": "clock_sweep",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_clock_sweep")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_comet_dual", {
"p": "comet_dual",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_comet_dual")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_fireflies", {
"p": "fireflies",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_fireflies")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
print("Test gradient_scroll")
p.edit("gradient_test", {
"p": "gradient_scroll",
"b": 220,
"d": 60,
"c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
"n1": 2,
"a": True,
})
p.select("gradient_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_heartbeat", {
"p": "heartbeat",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_heartbeat")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/marquee.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_marquee", {
"p": "marquee",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_marquee")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
print("Test meteor_rain")
p.edit("meteor_test", {
"p": "meteor_rain",
"b": 200,
"d": 40,
"c": [(255, 80, 0), (0, 120, 255)],
"n1": 10,
"n2": 1,
"n3": 200,
"a": True,
})
p.select("meteor_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

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

View File

@@ -2,7 +2,7 @@
import utime
from machine import WDT
from settings import Settings
from patterns import Patterns
from presets import Presets, run_tick
def main():
@@ -10,24 +10,26 @@ def main():
pin = s.get("led_pin", 10)
num = s.get("num_leds", 30)
p = Patterns(pin=pin, num_leds=num)
p = Presets(pin=pin, num_leds=num)
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", {
"pattern": "on",
"brightness": 64,
"delay": 120,
"colors": [(255, 0, 0), (0, 0, 255)]
"p": "on",
"b": 64,
"d": 120,
"c": [(255, 0, 0), (0, 0, 255)],
})
p.edit("test_off", {"pattern": "off"})
p.edit("test_off", {"p": "off"})
# ON phase
p.select("test_on")
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 800:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)
# OFF phase
@@ -35,7 +37,7 @@ def main():
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < 100:
wdt.feed()
p.tick()
run_tick(p)
utime.sleep_ms(10)

40
tests/patterns/orbit.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_orbit", {
"p": "orbit",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_orbit")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_palette_morph", {
"p": "palette_morph",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_palette_morph")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/plasma.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_plasma", {
"p": "plasma",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_plasma")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

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

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_rain_drops", {
"p": "rain_drops",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_rain_drops")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

40
tests/patterns/scanner.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
print("Test scanner")
p.edit("scanner_test", {
"p": "scanner",
"b": 255,
"d": 30,
"c": [(255, 0, 0)],
"n1": 4,
"n2": 2,
"a": True,
})
p.select("scanner_test")
run_for(p, wdt, 4000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import utime
from machine import WDT
from settings import Settings
from presets import Presets, run_tick
def run_for(p, wdt, ms):
start = utime.ticks_ms()
while utime.ticks_diff(utime.ticks_ms(), start) < ms:
wdt.feed()
run_tick(p)
utime.sleep_ms(10)
def main():
s = Settings()
p = Presets(pin=s.get("led_pin", 10), num_leds=s.get("num_leds", 30))
wdt = WDT(timeout=10000)
p.edit("test_segment_chase", {
"p": "segment_chase",
"b": 200,
"d": 60,
"c": [(255, 0, 0), (0, 0, 255), (0, 255, 0)],
"n1": 4,
"n2": 2,
"n3": 120,
"a": True,
})
p.select("test_segment_chase")
run_for(p, wdt, 3000)
p.edit("cleanup_off", {"p": "off"})
p.select("cleanup_off")
run_for(p, wdt, 100)
if __name__ == "__main__":
main()

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