17 Commits

Author SHA1 Message Date
3d6ef5c7b4 chore(git): stop tracking runtime db state files
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:35:50 +12:00
78a4ce009c feat(ui): refresh preset data flow and bump driver pointer
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 20:28:56 +12:00
7ccab6fbc4 feat(zones): persist per-zone brightness and update submodules
Store zone brightness in model/data flow, apply it in the zones UI, and record updated led-driver, led-simulator, and led-tool submodule pointers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 22:49:06 +12:00
pi
827eb97203 feat(settings): server global brightness and Wi-Fi driver resync
- Serve GET /settings as JSON by removing duplicate HTML route (use /settings/page for the standalone UI).

- Save global_brightness via PUT; broadcast to connected drivers; push saved level when outbound WS connects.

- Zones UI loads brightness from GET /settings only (no localStorage).

- Bump led-driver submodule for settings.save on brightness with save flag.

- Extend API doc and endpoint tests for global_brightness.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 22:15:30 +12:00
pi
3cca0cffc5 chore: bump led-tool and led-driver submodules
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:32 +12:00
pi
d36828bde2 feat(ui): persist header brightness slider in localStorage
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
ed0048c795 chore(service): avoid network-online stall and speed pipenv boot
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
pi
b316edbaf9 fix(wifi): stagger driver ws dials and extend initial retry window
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 21:27:31 +12:00
c1b0c41ef2 fix(transport): disable UART ESP-NOW bridge by default
Require serial_enabled true in settings to open serial_port; default false in
set_defaults for Wi-Fi-only and dev machines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 15:07:16 +12:00
3bb75d49de feat(util): add binary envelope packing and message helpers
Includes tests for v1/v2 envelope round-trips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:37 +12:00
3d77cb448a chore: add vertical stand OpenSCAD model
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
49383c0003 feat(espnow): add espnow-sender utility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
7d821b9c1c chore(db): add local preset fixtures
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:35 +12:00
9b7e387ea6 chore(scripts): add dev-run helper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:34 +12:00
b4f0d1891e chore(submodule): bump led-driver and led-tool; register led-simulator
led-simulator was already a gitlink; add the missing .gitmodules entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 14:56:30 +12:00
0da30b6d6b fix(submodule): update led-tool pointer to existing commit 2026-04-30 23:28:39 +12:00
6cbb728d9a feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
2026-04-23 20:07:55 +12:00
89 changed files with 1991 additions and 285 deletions

View File

@@ -0,0 +1,12 @@
---
description: Require test pattern, pattern metadata, and test preset for new patterns
alwaysApply: true
---
# Pattern workflow requirements
1. When creating a new pattern under `led-driver/src/patterns/`, also add/update a corresponding test file in `led-driver/tests/patterns/`.
2. When adding a new pattern, ensure led-controller has `db/pattern.json`; if it does not exist, create it. Add the new pattern metadata and parameter mappings there.
3. When adding a new pattern, add at least one test preset entry in `db/preset.json` in led-controller that uses the new pattern.

View File

@@ -0,0 +1,18 @@
---
description: Keep led-driver and led-tool git submodules in sync when updating led-controller
alwaysApply: true
---
# Submodule pointers (`led-driver`, `led-tool`)
This repo tracks **`led-driver`** and **`led-tool`** as git submodules (see `.gitmodules`).
When you **update led-controller** work that should ship with matching firmware or CLI behaviour—or when you finish changes **inside** those submodule directories—**record the new submodule commits in the parent repo**:
1. In each submodule, commit and push on its remote if there are local commits (or ensure the checkout is the intended revision).
2. From the **led-controller** root: `git add led-driver led-tool` after their HEADs point at the right commits.
3. Include the parent-repo commit that bumps the gitlinks (so CI and clones get consistent trees).
**Do not** leave submodule directories dirty or forgotten while presenting the parent repo as “done”: either commit the submodule pointer update in led-controller, or leave an explicit note if the user must push submodule remotes first.
If the user only asked for a submodule bump with no code edits, a single `chore(submodules): bump led-driver and led-tool` style commit is appropriate (see commit rule).

2
.gitignore vendored
View File

@@ -25,8 +25,10 @@ ENV/
Thumbs.db Thumbs.db
# Project specific # Project specific
scripts/.led-controller-venv
docs/.help-print.html docs/.help-print.html
settings.json settings.json
db/
*.log *.log
*.db *.db
*.sqlite *.sqlite

3
.gitmodules vendored
View File

@@ -4,3 +4,6 @@
[submodule "led-tool"] [submodule "led-tool"]
path = led-tool path = led-tool
url = git@git.technical.kiwi:technicalkiwi/led-tool.git url = git@git.technical.kiwi:technicalkiwi/led-tool.git
[submodule "led-simulator"]
path = led-simulator
url = git@git.technical.kiwi:technicalkiwi/led-simulator.git

14
Pipfile
View File

@@ -19,12 +19,14 @@ websockets = "*"
pytest = "*" pytest = "*"
[requires] [requires]
python_version = "3.12" python_version = "3.11"
[scripts] [scripts]
web = "python /home/pi/led-controller/tests/web.py" web = "python tests/web.py"
watch = "python -m watchfiles 'python tests/web.py' src tests" watch = "python -m watchfiles \"python tests/web.py\" src tests"
install = "pipenv install"
run = "sh -c 'cd src && python main.py'" run = "sh -c 'cd src && python main.py'"
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src" dev = "python -m watchfiles \"sh -c 'cd src && python main.py'\" src"
help-pdf = "sh scripts/build_help_pdf.sh" test = "python -m pytest"
test-browser = "sh -c 'python tests/web.py > /tmp/led-controller-web.log 2>&1 & pid=$!; trap \"kill $pid\" EXIT; sleep 2; LED_CONTROLLER_RUN_BROWSER_TESTS=1 LED_CONTROLLER_DEVICE_IP=http://127.0.0.1:5000 python -m pytest tests/test_browser.py'"
test-browser-device = "sh -c 'LED_CONTROLLER_RUN_BROWSER_TESTS=1 python -m pytest tests/test_browser.py'"

147
Pipfile.lock generated
View File

@@ -1,11 +1,11 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "18691f772c7660e4a087c90560c87a9217a09e9b6db97825d21c092a06d64b89" "sha256": "98da2012e549e7b62ed49a5e1717acaf535b71e8df61bf4108d25b9023be612e"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
"python_version": "3.12" "python_version": "3.11"
}, },
"sources": [ "sources": [
{ {
@@ -159,11 +159,11 @@
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a",
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7" "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2026.2.25" "version": "==2026.4.22"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@@ -392,73 +392,72 @@
}, },
"click": { "click": {
"hashes": [ "hashes": [
"sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2",
"sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d" "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613"
], ],
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==8.3.2" "version": "==8.3.3"
}, },
"cryptography": { "cryptography": {
"hashes": [ "hashes": [
"sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7",
"sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27",
"sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd",
"sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7",
"sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001",
"sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4",
"sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca",
"sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0",
"sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe",
"sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93",
"sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475",
"sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe",
"sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515",
"sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10",
"sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7",
"sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92",
"sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829",
"sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8",
"sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52",
"sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b",
"sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc",
"sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c",
"sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63",
"sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac",
"sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31",
"sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7",
"sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1",
"sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203",
"sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7",
"sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769",
"sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923",
"sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74",
"sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b",
"sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb",
"sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab",
"sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76",
"sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f",
"sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7",
"sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973",
"sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0",
"sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8",
"sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310",
"sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b",
"sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318",
"sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab",
"sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8",
"sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa",
"sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50",
"sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce" "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736"
], ],
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'", "markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
"version": "==46.0.7" "version": "==47.0.0"
}, },
"esptool": { "esptool": {
"hashes": [ "hashes": [
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6" "sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==5.2.0" "version": "==5.2.0"
}, },
"h11": { "h11": {
@@ -471,11 +470,11 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.11" "version": "==3.13"
}, },
"intelhex": { "intelhex": {
"hashes": [ "hashes": [
@@ -502,12 +501,11 @@
}, },
"microdot": { "microdot": {
"hashes": [ "hashes": [
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946", "sha256:3ba8bab39ae52bca08ee7024dfc71afb7cff089f0b6611d2a1f617abfcee749c",
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464" "sha256:d56824f4510e628dac711c86121957894ceaf833298e4d25f06c29d2b19a1721"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "version": "==2.6.1"
"version": "==2.6.0"
}, },
"mpremote": { "mpremote": {
"hashes": [ "hashes": [
@@ -515,7 +513,6 @@
"sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31" "sha256:fdb5626be83dff4e53c0184f8950814cb519b524dba7f1f8b1668aa477257a31"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.4'",
"version": "==1.28.0" "version": "==1.28.0"
}, },
"outcome": { "outcome": {
@@ -556,7 +553,6 @@
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b" "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.12.1" "version": "==2.12.1"
}, },
"pyserial": { "pyserial": {
@@ -675,7 +671,6 @@
"sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==2.33.1" "version": "==2.33.1"
}, },
"rich": { "rich": {
@@ -700,7 +695,6 @@
"sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e" "sha256:bada5c08a989f812728a4b5bea884d8e91894e939a441cc3a025201ce718581e"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==4.43.0" "version": "==4.43.0"
}, },
"sniffio": { "sniffio": {
@@ -780,9 +774,7 @@
"version": "==4.15.0" "version": "==4.15.0"
}, },
"urllib3": { "urllib3": {
"extras": [ "extras": [],
"socks"
],
"hashes": [ "hashes": [
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
@@ -903,7 +895,6 @@
"sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf" "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.1.1" "version": "==1.1.1"
}, },
"websocket-client": { "websocket-client": {
@@ -979,7 +970,6 @@
"sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==16.0" "version": "==16.0"
}, },
"wsproto": { "wsproto": {
@@ -1002,11 +992,11 @@
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e",
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==26.0" "version": "==26.2"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@@ -1030,7 +1020,6 @@
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==9.0.3" "version": "==9.0.3"
} }
} }

View File

@@ -1 +0,0 @@
{"188b0e1560a8": {"id": "188b0e1560a8", "name": "led-188b0e1560a8", "type": "led", "transport": "wifi", "address": "10.1.1.192", "default_pattern": null, "zones": []}, "f0f5bdfb9d30": {"id": "f0f5bdfb9d30", "name": "led-f0f5bdfb9d30", "type": "led", "transport": "wifi", "address": "10.1.1.232", "default_pattern": null, "zones": []}}

View File

@@ -1,92 +1 @@
{ {"on": {"min_delay": 10, "max_delay": 10000, "max_colors": 1}, "off": {"min_delay": 10, "max_delay": 10000, "max_colors": 0}, "rainbow": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 0}, "colour_cycle": {"n1": "Step Rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "transition": {"min_delay": 10, "max_delay": 10000, "max_colors": 10}, "chase": {"n1": "Colour 1 Length", "n2": "Colour 2 Length", "n3": "Step 1", "n4": "Step 2", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "pulse": {"n1": "Attack", "n2": "Hold", "n3": "Decay", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "circle": {"n1": "Head Rate", "n2": "Max Length", "n3": "Tail Rate", "n4": "Min Length", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "blink": {"min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "flicker": {"n1": "Min brightness", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "flame": {"n1": "Min brightness", "n2": "Breath period (ms)", "n3": "Spark gap min (ms, 0=default 1030 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1255, higher = more changes)", "n2": "Density (0255, higher = more of the strip lit)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "radiate": {"n1": "Node spacing (LEDs)", "n2": "Out time (ms)", "n3": "In time (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 2, "has_background": true}, "meteor_rain": {"n1": "Tail length", "n2": "Speed (LEDs per frame)", "n3": "Fade amount (1-255)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "scanner": {"n1": "Eye width", "n2": "End pause (frames)", "min_delay": 10, "max_delay": 10000, "max_colors": 10, "has_background": true}, "gradient_scroll": {"n1": "Scroll step rate", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "comet_dual": {"n1": "Tail length", "n2": "Speed", "n3": "Gap", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "sparkle_trail": {"n1": "Spark density", "n2": "Decay", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "wave": {"n1": "Wavelength", "n2": "Amplitude", "n3": "Drift speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "plasma": {"n1": "Scale", "n2": "Speed", "n3": "Contrast", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "segment_chase": {"n1": "Segment size", "n2": "Phase step", "n3": "Segment phase offset", "n4": "Gap per segment", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "breathing_dual": {"n1": "Phase offset", "n2": "Ease", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "strobe_burst": {"n1": "Burst count", "n2": "Burst gap", "n3": "Cooldown", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "aurora": {"n1": "Band count", "n2": "Shimmer", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "snowfall": {"n1": "Flake density", "n2": "Fall speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000, "has_background": true}, "palette_morph": {"n1": "Morph ms", "n2": "Warp rate", "n3": "Turbulence", "max_colors": 10, "min_delay": 10, "max_delay": 10000}}
"on": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 1
},
"off": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0
},
"colour_cycle": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"transition": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"chase": {
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
"n4": "Step 2",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"pulse": {
"n1": "Attack",
"n2": "Hold",
"n3": "Decay",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"circle": {
"n1": "Head Rate",
"n2": "Max Length",
"n3": "Tail Rate",
"n4": "Min Length",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
},
"blink": {
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"flicker": {
"n1": "Min brightness",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"flame": {
"n1": "Min brightness",
"n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)",
"n4": "Spark gap max (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10
},
"radiate": {
"n1": "Node spacing (LEDs)",
"n2": "Out time (ms)",
"n3": "In time (ms)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 2
}
}

File diff suppressed because one or more lines are too long

BIN
db/presets/1.bin Normal file

Binary file not shown.

3
db/presets/10.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ%ÎÁ
Â0Ð_ñšCSµJîæ'D$¶«
ÄݦˆˆÿntOovæ²opxz´zޱ ¦P

2
db/presets/11.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xњ%ОAВ …б»<·,J5\Е4
К $84SX4Ж»eхеНШЅ B

1
db/presets/12.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœ%ÎA л|·, ŠÐK˜ÆP;* 

2
db/presets/13.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎÁ
Â0Ð_9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c­¤ü¬»J-çèéþ¨LÅrï½ÃD9¾:¿uˆK„ª 9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Ãç <0B><>1

2
db/presets/14.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ=ÎÝ
!†á[‰¯StK[¼€½‰ˆ°v*ÁTü!"º÷Ü¤Žžá<C5BE>9˜¼¹4bu™VÙ…¢)…ÿåVÎÁ…”¡÷XO“RœãÀpJöz+žr[ R2ÌäÌzäœÁÔ KªÄàE;àKõ´èÓæß¶Ð ²£:»Îø%¦p±ŽŽvn? ¼?<3F>¨2ú

BIN
db/presets/15.bin Normal file

Binary file not shown.

BIN
db/presets/2.bin Normal file

Binary file not shown.

2
db/presets/3.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœUÎÁ
Â0ЙsM5Uò+"²µ«â¦lSDÄwiNž³3‡ý@èɈPJ2fª•Uþn×.ˆ§³Ã¨éþ¨Â‹å>‡‰3½}×9ÐZ bÕ•ÄÛÀè­]cß<08>¡qh7f-·”ù’&ûÁãûF9/.

2
db/presets/30.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎÁ
Â0Ð_9ç`«Qɯˆ”Ô®ˆ»e“RDüwsðô˜™Ë¼ÁñIИx”uS²¬p˜c­¤ü¬»J-çèéþ¨LÅrï½ÃD9¾:¿uˆK„ª 9pg¥Ñ#ØÂ»Æ¾á‡Æ±qú1«ÜR¦!Mö¡Çç <0B>“1

BIN
db/presets/31.bin Normal file

Binary file not shown.

2
db/presets/32.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/33.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœMÎ1! †á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-fÂìZó…xÓþÇ·œr©°' !h~<´î-Õg…k‰÷G#_ùØ­0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y

BIN
db/presets/34.bin Normal file

Binary file not shown.

2
db/presets/35.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ͽÂ0 àW©Ž5C~•&VÆ
¡@<40>)uª4K…xwR<}ç»Á° —ks <DjÎ)¦ …É•B™ë¸ž¯µža;l¼×Ú{Üž9 ïÂ4×Á­ÐSt l«kæ[a'ì…ƒpN¦œ|ˆô}ýmðý-‰

1
db/presets/36.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœMÎ1! †á¿b¾[=5ÌNÎnÆô@I°\€Åÿ»Å.²<oÚ¼Aîéa±?,ŽÅQ<C385>-fÂìZó…xÓþÇ·œr©°' !h~<´î-Õg…k‰÷G#_ùØ­0ùä^Ü#7-a;FX ka6ÂVØý˜K1ùKœø_Ÿ/ÐM4y

BIN
db/presets/37.bin Normal file

Binary file not shown.

BIN
db/presets/38.bin Normal file

Binary file not shown.

3
db/presets/39.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœUÎÁ0„áw¯=¤jú*†<>
[m\[²”ƒ1¾»…ž<}ÉÌåÿ ºÁÂsŸ$P˜]Î$ño'Y`¯88ÒÚ{ô
7 ÷GŽ´”£5Fa"voX£ÜšlbÛè2ÆvãXé*¦rªœ+—<>YLC˜JM³·1•ºAÈo5qeî¿?ªð9±

BIN
db/presets/4.bin Normal file

Binary file not shown.

4
db/presets/40.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xśMÎÁ0„áwŻ=$ű*†<>
[%Y[RÚ1ľ»…^<}ÉĚĺ˙Ŕ™7<E284A2>`ĺPa51rpËäŇ
tÇĹÚ©×<1A>Â#,ĎWtĽĺŁŞ{…™Ĺě V+<2B>=(†Ä
®5m¶՝ίk@×B[č

2
db/presets/41.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xśmŹÁ0 †ßĄ\wČ`ŮMQ^Â2ĄčâÜČ1Ćřîn̉—~í—?MűüC™F 0IďŃ™w¶ÚşÄ˛š7Ľm<C4BD>ËĺMęveýuUąo<v[şć:'§.Wop
Ć ¨ĺDN)ąx » <09><H¤)B2r"˘Śá@–Ć*ˇNŕ+&gGĄ±WC8<_ßĐéŽńpłhMţ”îýŹ!I°

2
db/presets/42.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xњUЋ;В0птТєp>°WAQґђ5X2Nд8BЬ;©hv¤·SМЃ_BдЙq(,њДр·Эg?ЗtEЕЅЦЦж­ТZіf
·иПdНJcЊВ$ћЯ “ЮТ Jq…PѓЪјt)ПР‚є] ЁАињњw,q¶ОЛи¦\Wп­^rнЕє°yЇКѕ?Эh>Ў

BIN
db/presets/43.bin Normal file

Binary file not shown.

2
db/presets/44.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœEÎM
Â0à«ÈsEÿ¢ôE$¶£â¤$Ó…ˆww0 góÁ{o1o°„ŠìÊì™)Ã`õ"”Y˜r<CB9C>°ÇFgƒk÷‡0-:k

3
db/presets/45.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ=ŽA0E¯B>Û.
€KC*ŒØ¤¶¤Æxw<1B>Í{™7y!ØÁ€)s5';9
\å1Eï¡°XfJA~mø·1ú˜ußkÙÕZo^ls\®ÉÍw”å¸mµÂDÞ>a:Q»r„á´Bh¤ Z)aW°/8tÇ‚ÓKŠ7çip“üÙàý)<¡

3
db/presets/46.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xś-ÎÁ0Đ_!õ‡Šdo˝ô'Ś!Ş’”–”ĺ`Ś˙î<˝ÍĚö<>čfű•‹!Ížqs
cö9J·Çý?RHy]QZkŚÖ•Zc-n
÷<=_ý*“Zk…Ń÷µrşŤ<13>óćbę„T

2
db/presets/47.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1x<EFBFBD>5־A0…ב«<D791>ַ¶ @Dׂ- —0ֶT©<54>X[2ֶxwG׳ש&‎»˜yXh°M\₪<>׀<EFBFBD><D780>ֹ8<>0[
’ור/חט#%ט=ֺ¾†q”·r\¹כ<C2B9>ƒMע¥©*…ֹzף„מd5 Gh¦ֵ*„Zz+6b-1l ¿´™m¦ֻל2ֺLסגה"7ֹy5<79>־ד:G

2
db/presets/48.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ-ÎÁ Ð_1ã•ÔZŽúÆ´«’ 4°Õã¿»Š§7;sÙ¢»,˜
/îNP˜3å(í¿8¥<38>r<EFBFBD>Ýa©õ¶ìŽÙ_®©ÈÐh­0RpOØN¢9ÁržI!ˆ<C393>ØËWö{­+]eSéL9<4C>} ƒåƒ÷ªù0¿

2
db/presets/49.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1x<EFBFBD>=ЮA
Т0аЋШw<D0A8>EZ5JаK<14>б<EFBFBD>ZH<5A><48>L"онС<D0BD>Ћ7ќџѓFЄ<46>с!\e<>е<>`<60>I<EFBFBD>KдќнRHЅТ<D085>и<0E>ЕЮсlp-ѓу)<29>ЋНЕzС;=i<>/ee<65>иiІє:Sv<53>=МютЁсЧЦщG.щ>ОЬ<D09E>Овсѓ,<2C>

BIN
db/presets/5.bin Normal file

Binary file not shown.

2
db/presets/50.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5ÎA0Ы<C390>϶ˆ¦è%Œ!F <20>´ÃÂïîhu6o2ÿ/æ ïVSâ"Ѹ’碟\"(lŽ™¢—ø—tÿ¤Kˆ æÒZ-#·ò£µ¸*Üâ<Nì)I¥ÖZa Å=`ZYÝΆãN
¾i„¦0RðMæ˜i3§ÌùËÃ}^¨›ù­Âë

BIN
db/presets/51.bin Normal file

Binary file not shown.

BIN
db/presets/52.bin Normal file

Binary file not shown.

2
db/presets/53.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ5Î=Â0 †á«Tk†þQ<C3BE>À%*T%Ô@¥TŽ; ÄÝIáå±ôzðÞ¾å¨ET Ž ·JT,V•ŧšÃð·0‰ ‡Ë>¸8™S¨ËÒ`äÙ¾A]Zíª¤²²<C2B2>¯@M¢ÎÉ7 v;÷-hã˜é2§ÌygpŸf¦1ýTáû^
7˜

3
db/presets/54.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x<EFBFBD>Ν
Β0ΰW)γ5‡ώh­Ήϊ"%ΪU5)νAΔww5xϊ™9μ Α=BI
v>Η%Α`q"ΔA»o<ώγK<CEB3>#'Ψ#6‡²ο'ƒ3ϋΫ]%-κ²4<C2B2>hvOΨVO·J„^Ι T°M­Φ<C2AD><CEA6>ΐκ"l3»LΩgΊ Η«<CE97>iτ“ώSαύ<01><>5%

4
db/presets/55.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xœMαÂ0 Ð_A×5CZ ´™Q~!¨ BR%î€ÿŽE¦gÝÝà7¢{ ˜
ofŸiž
ÇL9JõŸÞRH¹ÀœÐX{Ô½–¬µµ£ÆYášýýÁŠL:­&
îÓËéVN0œWRˆ­dB3[Ä]e_é+‡ÊðcÉiö<69>.~¿Z|¾¡ 61

1
db/presets/56.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xœ5ŽAƒ E¯b¾[¨U+WiŒ¡2¶¦ `š¦éÝ nxÌŒ|ùPÌÚÎ<C39A>¿ˆ60l2r&.?ýýlµuâRõ|àCt%Wuß5®n½Ýƒ!OjÎiùN¹ ÜN ¦¨¢35DÑ@¤é”Ñft}ÆùÀæì²jšVÓª#TSL<53>-)ËìZ³ôŒßQ•AÓ

1
db/presets/57.bin Normal file
View File

@@ -0,0 +1 @@
PRST1xњEО1В0 Р« ПљЎiЎ ЂK „5)MЪФвоXНЂ—gщяБD72ВlF—зВ ѓЙ‰pЋьoчR^@glOлаbpЛющИmУ ЬФлкЉ$ђдВС:ҐХљТЃ¬Іi/о+}еP9®L9=|а«ф‹пжg2д

2
db/presets/58.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ=ÎÍ
Â0àWé5‡ô?ìM"} ‰vÕBMJD|wSž¾afû†5O!rˆ;³zç

3
db/presets/59.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1x°Mна
б0 ЮW▒вз╘SzTЯ%D╓╨Lm├┬ЬНfКе\╬ДOЫ ╦'а┌)С"┤ЬЙ°ВP3╔ ⌡©П}LЖ└Й8≈dуNЖр²╝╘©?8P√⌠Zk┘√╪{ц6р╨▒#,╖▒┌≥Жb
k└%Л4╜

2
db/presets/6.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœMÎK
Â0…á½§ÜT£tR$Ú«âMÉc âÞm<C39E>ˆ£þ39Oˆ»3,¦2Car¥p¿rŽ!¦ {ÀЍï‰0(œ¿ÞŠpž‡Î…‘ƒ{À"WK„-©²hXMK•î;Ëú—6° ¦±mìûøèÇù’Æë

4
db/presets/60.bin Normal file
View File

@@ -0,0 +1,4 @@
PRST1xœMÎA0Ы˜ï¶RÉ€KcŠŒBR[Ò c¼»­l\½Éÿùɼáí“ANr˜ÙFÙ
V+ÂÑçê?½b
8ö½éj<EFBFBD>—Ç,žS.ŒÖ
µù´›<04>Ä<EFBFBD>|ªL½¨)

BIN
db/presets/61.bin Normal file

Binary file not shown.

3
db/presets/62.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœ5ŽA0E¯B>Û.
€KCªŒBRÚ¦ c¼»ÅÙ¼7óÿb>ðv"0Í\D눙Š)¤8@!ZÙ—xOºò抲mµŒÜJ­W϶:n
÷4¾ö4K¹ÖZ¡'gß0<C39F>¨]8ÀpZHÁW0ÕVðõÞô˜ÇŒSF“qθlˆ)<GGÝØË«¾?ð¹<

3
db/presets/7.bin Normal file
View File

@@ -0,0 +1,3 @@
PRST1xœMŽ1Â0 Eïò»fp
<EFBFBD>(K/<2F>
­<EFBFBD>H!©Òt@ˆ»cÈÂô¾Ÿ¿%¿<>üƒá0†2F†ÂìkåþÕ˜c. ÜÝ0 ‘¸Î.%Üî5ñ"•Þ…‰£J&RðkÍpµ¬¬<C2AC>´HA§e•6mÜÂÉQ2p_¹kØ7Øæ’¯!ò9LòÆû¼Ã1ó

BIN
db/presets/8.bin Normal file

Binary file not shown.

2
db/presets/9.bin Normal file
View File

@@ -0,0 +1,2 @@
PRST1xœ%ÎK
Ã0 Ы”éÖ‹$ýâ«”ÜFn ŽPJï^ÇÖæI£Í|Áf&hlFæÃ6¹HPXLŒ$œãÀù|d…~àhË WxŠ{O<69>®iFòæÝî»I1@GI¤À-tޏ«œ*çÊ¥r­Ü*÷Â"Á:Oƒs<>´ò”{

View File

@@ -1 +0,0 @@
{"1": {"name": "default", "names": ["led-188b0e1560a8", "led-f0f5bdfb9d30"], "presets": [["4", "2", "7"], ["3", "14", "5"], ["8", "9", "1"], ["6", "38", "42"], ["39", "40", "41"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41"], "default_preset": "4"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["led-188b0e1560a8"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}

View File

@@ -42,7 +42,7 @@ Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/` | Main UI (`templates/index.html`) | | GET | `/` | Main UI (`templates/index.html`) |
| GET | `/settings` | Settings page (`templates/settings.html`) | | GET | `/settings/page` | Standalone settings page (`templates/settings.html`) |
| GET | `/favicon.ico` | Empty response (204) | | GET | `/favicon.ico` | Empty response (204) |
| GET | `/static/<path>` | Static files under `src/static/` | | GET | `/static/<path>` | Static files under `src/static/` |
@@ -72,7 +72,7 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. | | PUT | `/settings/settings` | Merge keys into settings and save. Returns `{"message": "Settings updated successfully"}`. |
| GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). | | GET | `/settings/wifi/ap` | Saved WiFi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). | | GET | `/settings/page` | Serves `templates/settings.html`. |
### Devices — `/devices` ### Devices — `/devices`

7
espnow-sender/README.md Normal file
View File

@@ -0,0 +1,7 @@
# espnow-sender
Minimal MicroPython project for receiving JSON over Microdot WebSocket.
- WebSocket endpoint: `/ws`
- Entry point: `main.py`
- Message template: `msg.json`

120
espnow-sender/main.py Normal file
View File

@@ -0,0 +1,120 @@
import asyncio
import json
from microdot import Microdot
from microdot.websocket import WebSocketError, with_websocket
import espnow
import network
from util import format_mac, parse_mac
app = Microdot()
_esp = None
_known_peers = set()
_ws_clients = set()
def _init_espnow():
global _esp
sta = network.WLAN(network.STA_IF)
sta.active(True)
_esp = espnow.ESPNow()
_esp.active(True)
def _validate_envelope(obj):
if obj.get("v") != "1":
raise ValueError("message.v must be '1'")
devices = obj["devices"]
for address in devices.keys():
parse_mac(address)
return obj
def _send_espnow(address, payload):
if _esp is None:
raise ValueError("espnow is not initialized")
mac = parse_mac(address)
msg = json.dumps(payload, separators=(",", ":")).encode("utf-8")
if mac not in _known_peers:
_esp.add_peer(mac)
_known_peers.add(mac)
_esp.send(mac, msg)
return mac, len(msg)
async def _broadcast_ws(obj):
text = json.dumps(obj)
dead = []
for client in list(_ws_clients):
try:
await client.send(text)
except Exception:
dead.append(client)
for client in dead:
_ws_clients.discard(client)
async def _espnow_receive_loop():
while True:
host, msg = _esp.recv(0)
if not host:
await asyncio.sleep(0.01)
continue
await _broadcast_ws(
{
"from": format_mac(host),
"payload": msg.decode("utf-8"),
}
)
@app.route("/ws")
@with_websocket
async def ws(request, ws):
_ws_clients.add(ws)
while True:
try:
raw = await ws.receive()
except WebSocketError:
break
if not raw:
break
try:
parsed = json.loads(raw)
env = _validate_envelope(parsed)
sent = []
for address, payload in env["devices"].items():
mac, payload_size = _send_espnow(address, payload)
sent.append(
{
"address": format_mac(mac),
"bytes": payload_size,
}
)
except (ValueError, TypeError) as e:
await ws.send(json.dumps({"ok": False, "error": str(e)}))
continue
await ws.send(
json.dumps(
{
"ok": True,
"sent": sent,
}
)
)
_ws_clients.discard(ws)
async def main(port=80):
_init_espnow()
asyncio.create_task(_espnow_receive_loop())
await app.start_server(host="0.0.0.0", port=port)
if __name__ == "__main__":
asyncio.run(main(port=80))

24
espnow-sender/msg.json Normal file
View File

@@ -0,0 +1,24 @@
{
"v": "1",
"devices": {
"ff:ff:ff:ff:ff:ff": {
"presets": {
"preset_id": {
"pattern": "on",
"colors": ["#FF0000"],
"delay": 100,
"brightness": 255,
"auto": true
}
},
"select": {
"preset": "preset_id",
"step": 0
},
"save": true,
"default": "preset_id",
"b": 255
}
}
}

12
espnow-sender/util.py Normal file
View File

@@ -0,0 +1,12 @@
def parse_mac(value):
raw = value.strip().lower().replace(":", "").replace("-", "")
if len(raw) != 12:
raise ValueError("address must be 12 hex chars or aa:bb:cc:dd:ee:ff")
try:
return bytes.fromhex(raw)
except ValueError:
raise ValueError("address contains non-hex characters")
def format_mac(mac_bytes):
return ":".join("{:02x}".format(b) for b in mac_bytes)

1
led-simulator Submodule

Submodule led-simulator added at 42c14361e8

123
led_bar_vertical_stand.scad Normal file
View File

@@ -0,0 +1,123 @@
// Parametric LED bar vertical stand socket
// For a bar nominally 14 x 17 mm, 2 m long.
// This part is intended to be screwed to an MDF base.
// -------------------------
// User parameters
// -------------------------
bar_w = 14; // Bar width (mm)
bar_d = 17; // Bar depth (mm)
clearance = 0.4; // Total clearance added to each axis (mm)
socket_height = 36; // Height of printed socket body (mm)
wall = 3.2; // Socket wall thickness (mm)
base_thickness = 5; // Printed bottom plate thickness (mm)
// USB cable/connector side opening
usb_notch_enable = true;
usb_notch_w = 11;
usb_notch_h = 9;
usb_notch_from_bottom = 6;
usb_notch_side = "right"; // "right" or "left"
// Mounting ears for MDF screws
ear_enable = true;
ear_len = 16;
ear_w = 16;
ear_thickness = base_thickness;
screw_hole_d = 4.2; // M4 clearance. Use 3.4 for M3.
screw_hole_edge = 5.5; // Hole center offset from ear outer corner
// Optional clamp lip at top to reduce wobble
top_lip_enable = true;
top_lip_depth = 2.0; // Intrudes into opening on each side
top_lip_height = 3.0;
$fn = 48;
// -------------------------
// Derived
// -------------------------
inner_w = bar_w + clearance;
inner_d = bar_d + clearance;
outer_w = inner_w + wall * 2;
outer_d = inner_d + wall * 2;
outer_h = socket_height;
module screw_hole() {
cylinder(h = ear_thickness + 0.2, d = screw_hole_d);
}
module mounting_ear(sign_y = 1) {
translate([outer_w / 2, sign_y * (outer_d / 2), 0])
cube([ear_len, ear_w, ear_thickness], center = false);
}
module top_lip() {
if (top_lip_enable) {
// Front and back lips at the top of the socket.
translate([wall, wall, outer_h - top_lip_height])
cube([top_lip_depth, inner_d, top_lip_height]);
translate([outer_w - wall - top_lip_depth, wall, outer_h - top_lip_height])
cube([top_lip_depth, inner_d, top_lip_height]);
}
}
difference() {
union() {
// Main body
cube([outer_w, outer_d, outer_h], center = false);
// Base plate under socket for stiffness
translate([0, 0, -base_thickness])
cube([outer_w, outer_d, base_thickness], center = false);
// Mounting ears
if (ear_enable) {
translate([0, 0, -ear_thickness]) {
mounting_ear(1);
mounting_ear(-1);
}
}
top_lip();
}
// Main bar cavity
translate([wall, wall, 0])
cube([inner_w, inner_d, outer_h + 0.2], center = false);
// USB side notch
if (usb_notch_enable) {
if (usb_notch_side == "right") {
translate([outer_w - wall - 0.1, (outer_d - usb_notch_w) / 2, usb_notch_from_bottom])
cube([wall + 0.3, usb_notch_w, usb_notch_h], center = false);
} else {
translate([-0.2, (outer_d - usb_notch_w) / 2, usb_notch_from_bottom])
cube([wall + 0.3, usb_notch_w, usb_notch_h], center = false);
}
}
// Screw holes in ears
if (ear_enable) {
// Upper ear hole
translate([
outer_w / 2 + ear_len - screw_hole_edge,
outer_d / 2 + ear_w - screw_hole_edge,
-ear_thickness - 0.05
]) screw_hole();
// Lower ear hole
translate([
outer_w / 2 + ear_len - screw_hole_edge,
-outer_d / 2 + screw_hole_edge,
-ear_thickness - 0.05
]) screw_hole();
}
}
// Print orientation helper:
// Keep the base/ears on the bed.
// If fit is tight, increase clearance to 0.5 or 0.6.

16
scripts/dev-run.sh Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PORT="${PORT:-80}"
# On watchfiles restarts the previous process can linger briefly.
# Proactively terminate any listener on the target port before boot.
pids="$(ss -ltnp "sport = :$PORT" 2>/dev/null | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' | sort -u)"
if [ -n "${pids}" ]; then
kill -TERM ${pids} 2>/dev/null || true
sleep 0.3
fi
cd "$ROOT_DIR/src"
exec python main.py

View File

@@ -10,6 +10,18 @@ if [ ! -f "scripts/led-controller.service" ]; then
echo "Run this script from the repo root." echo "Run this script from the repo root."
exit 1 exit 1
fi fi
export PIPENV_VENV_IN_PROJECT="${PIPENV_VENV_IN_PROJECT:-1}"
if command -v pipenv >/dev/null 2>&1; then
PY="$(command -v python3)"
if [ -z "$PY" ]; then
echo "python3 not found; install python3." >&2
exit 1
fi
echo "Ensuring Pipenv deps with $PY (venv in project: .venv when PIPENV_VENV_IN_PROJECT=1)…"
# --skip-lock: install from Pipfile only (avoids lock/Python hash mismatches on device).
pipenv install --quiet --skip-lock --python "$PY"
pipenv --venv > scripts/.led-controller-venv
fi
chmod +x scripts/start.sh chmod +x scripts/start.sh
sudo cp "scripts/led-controller.service" "$UNIT_PATH" sudo cp "scripts/led-controller.service" "$UNIT_PATH"
sudo systemctl daemon-reload sudo systemctl daemon-reload

View File

@@ -1,7 +1,8 @@
[Unit] [Unit]
Description=LED Controller web server Description=LED Controller web server
After=network-online.target # Use network.target only. Ordering after network-online.target can block `systemctl start`
Wants=network-online.target # until wait-online finishes; WiFi/DHCP delays then look like a hung start job.
After=network.target
[Service] [Service]
Type=simple Type=simple
@@ -12,6 +13,8 @@ Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
# pipenv/first bind can be slow; avoid misleading "activating" forever if misconfigured
TimeoutStartSec=120
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

253
scripts/pi-eth-lan-router.sh Executable file
View File

@@ -0,0 +1,253 @@
#!/usr/bin/env bash
# Configure Raspberry Pi OS: Wi-Fi client on IF_WAN (default wlan0), Ethernet IF_LAN
# (default eth0) toward an external AP. Static LAN IP, DHCP via dnsmasq, NAT masquerade.
#
# Usage:
# sudo ./pi-eth-lan-router.sh install
# sudo ./pi-eth-lan-router.sh remove
#
# Environment overrides (optional):
# IF_WAN=wlan0 IF_LAN=eth0 LAN_IP=192.168.4.1 LAN_PREFIX=24 \
# DHCP_START=192.168.4.100 DHCP_END=192.168.4.200 \
# DNSMASQ_DNS=1.1.1.1,8.8.8.8 \
# sudo ./pi-eth-lan-router.sh install
set -euo pipefail
IF_WAN="${IF_WAN:-wlan0}"
IF_LAN="${IF_LAN:-eth0}"
LAN_IP="${LAN_IP:-192.168.4.1}"
LAN_PREFIX="${LAN_PREFIX:-24}"
DHCP_START="${DHCP_START:-192.168.4.100}"
DHCP_END="${DHCP_END:-192.168.4.200}"
# Comma-separated DNS for DHCP clients (Pi does not need to run a resolver).
DNSMASQ_DNS="${DNSMASQ_DNS:-1.1.1.1,8.8.8.8}"
NM_CON_NAME="pi-eth-lan-router"
MARK_BEGIN="# BEGIN pi-eth-lan-router (scripts/pi-eth-lan-router.sh)"
MARK_END="# END pi-eth-lan-router"
SYSCTL_FILE="/etc/sysctl.d/99-pi-eth-lan-router.conf"
DNSMASQ_SNIPPET="/etc/dnsmasq.d/pi-eth-lan-router.conf"
NFT_SNIPPET="/etc/nftables.d/50-pi-eth-lan-router.nft"
NFT_INCLUDE='include "/etc/nftables.d/50-pi-eth-lan-router.nft"'
NFTABLES_CONF="/etc/nftables.conf"
DHCPCD_CONF="/etc/dhcpcd.conf"
die() { echo "error: $*" >&2; exit 1; }
log() { echo "$*"; }
need_root() {
[[ "${EUID:-0}" -eq 0 ]] || die "run as root (sudo)"
}
have_cmd() { command -v "$1" >/dev/null 2>&1; }
apt_install() {
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq dnsmasq nftables
}
write_sysctl() {
cat >"$SYSCTL_FILE" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
net.ipv4.ip_forward=1
EOF
sysctl --system -q 2>/dev/null || sysctl -p "$SYSCTL_FILE" || true
}
remove_sysctl() {
rm -f "$SYSCTL_FILE"
sysctl --system -q 2>/dev/null || true
}
write_dnsmasq() {
local mask="255.255.255.0"
if [[ "$LAN_PREFIX" != "24" ]]; then
die "only LAN_PREFIX=24 is supported by this script (extend dnsmasq netmask manually)"
fi
cat >"$DNSMASQ_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
interface=$IF_LAN
bind-interfaces
dhcp-range=$DHCP_START,$DHCP_END,$mask,24h
dhcp-option=option:router,$LAN_IP
dhcp-option=option:dns-server,$DNSMASQ_DNS
EOF
}
remove_dnsmasq() {
rm -f "$DNSMASQ_SNIPPET"
}
write_nft() {
mkdir -p /etc/nftables.d
cat >"$NFT_SNIPPET" <<EOF
# Managed by scripts/pi-eth-lan-router.sh
table ip pi_eth_wlan_nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "$IF_WAN" masquerade
}
}
EOF
if [[ -f "$NFTABLES_CONF" ]] && ! grep -qF '50-pi-eth-lan-router.nft' "$NFTABLES_CONF" 2>/dev/null; then
printf '\n# pi-eth-lan-router\n%s\n' "$NFT_INCLUDE" >>"$NFTABLES_CONF"
elif [[ ! -f "$NFTABLES_CONF" ]]; then
log "warning: $NFTABLES_CONF missing; NAT was not added for boot persistence. Install/configure nftables, or add: $NFT_INCLUDE"
fi
}
remove_nft() {
rm -f "$NFT_SNIPPET"
if [[ -f "$NFTABLES_CONF" ]]; then
sed -i '/# pi-eth-lan-router/d;/50-pi-eth-lan-router\.nft/d' "$NFTABLES_CONF" || true
fi
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
}
apply_nft() {
if have_cmd nft; then
nft delete table ip pi_eth_wlan_nat 2>/dev/null || true
nft -f "$NFT_SNIPPET"
fi
}
configure_nm_eth() {
have_cmd nmcli || return 1
systemctl is-active --quiet NetworkManager 2>/dev/null || return 1
if nmcli -t -f NAME con show --active 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con down "$NM_CON_NAME" || true
fi
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con mod "$NM_CON_NAME" \
connection.interface-name "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
else
nmcli con add type ethernet con-name "$NM_CON_NAME" ifname "$IF_LAN" \
ipv4.method manual \
ipv4.addresses "${LAN_IP}/${LAN_PREFIX}" \
ipv4.gateway "" \
ipv4.dns "" \
ipv4.never-default yes \
ipv6.method ignore
fi
if ! nmcli con up "$NM_CON_NAME"; then
log "warning: could not activate '$NM_CON_NAME' (is $IF_LAN connected?); profile saved for next boot."
fi
return 0
}
remove_nm_eth() {
have_cmd nmcli || return 0
if nmcli -t -f NAME con show 2>/dev/null | grep -qxF "$NM_CON_NAME"; then
nmcli con delete "$NM_CON_NAME" || true
fi
}
configure_dhcpcd_eth() {
[[ -f "$DHCPCD_CONF" ]] || return 1
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
fi
{
echo "$MARK_BEGIN"
echo "interface $IF_LAN"
echo "static ip_address=${LAN_IP}/${LAN_PREFIX}"
echo "nohook wpa_supplicant"
echo "$MARK_END"
} >>"$DHCPCD_CONF"
systemctl restart dhcpcd 2>/dev/null || true
return 0
}
remove_dhcpcd_block() {
[[ -f "$DHCPCD_CONF" ]] || return 0
if grep -qF "$MARK_BEGIN" "$DHCPCD_CONF" 2>/dev/null; then
sed -i "/$MARK_BEGIN/,/$MARK_END/d" "$DHCPCD_CONF" || true
systemctl restart dhcpcd 2>/dev/null || true
fi
}
configure_eth_static() {
if configure_nm_eth; then
log "configured $IF_LAN via NetworkManager profile '$NM_CON_NAME'"
return 0
fi
if configure_dhcpcd_eth; then
log "configured $IF_LAN via dhcpcd ($DHCPCD_CONF)"
return 0
fi
die "neither NetworkManager (active) nor $DHCPCD_CONF found; set $IF_LAN to ${LAN_IP}/${LAN_PREFIX} manually"
}
remove_eth_static() {
remove_nm_eth
remove_dhcpcd_block
}
do_install() {
need_root
log "installing packages (dnsmasq, nftables)…"
apt_install
log "writing sysctl, dnsmasq, nftables snippets…"
write_sysctl
write_dnsmasq
write_nft
log "setting static IP on $IF_LAN"
configure_eth_static
log "restarting dnsmasq…"
systemctl enable dnsmasq
systemctl restart dnsmasq
log "loading NAT rules and enabling nftables…"
apply_nft
systemctl enable nftables 2>/dev/null || true
systemctl restart nftables 2>/dev/null || true
log "done. Connect $IF_LAN to the external AP (DHCP off on the AP)."
log "Join Wi-Fi on $IF_WAN to the uplink network and complete any captive portal on the Pi."
}
do_remove() {
need_root
remove_eth_static
remove_dnsmasq
systemctl restart dnsmasq 2>/dev/null || true
remove_nft
systemctl restart nftables 2>/dev/null || true
remove_sysctl
sysctl -w net.ipv4.ip_forward=0 2>/dev/null || true
log "removed pi-eth-lan-router configuration snippets and NM profile '$NM_CON_NAME' (if present)."
}
usage() {
cat <<EOF
Usage: sudo $0 install|remove
WAN (Wi-Fi client): $IF_WAN
LAN (Ethernet to AP): $IF_LAN
LAN address: ${LAN_IP}/${LAN_PREFIX}
DHCP range: $DHCP_START $DHCP_END
Override with environment variables (see script header).
EOF
}
case "${1:-}" in
install) do_install ;;
remove) do_remove ;;
*) usage; exit 1 ;;
esac

View File

@@ -1,5 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start the LED controller web server (port 80 by default). # Start the LED controller web server (port 80 by default).
cd "$(dirname "$0")/.." # Avoid `pipenv run` on the hot path — it re-resolves the env every time and is slow on a Pi.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export PORT="${PORT:-80}" export PORT="${PORT:-80}"
pipenv run run export PIPENV_VENV_IN_PROJECT="${PIPENV_VENV_IN_PROJECT:-1}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CACHE="$SCRIPT_DIR/.led-controller-venv"
PYTHON=""
if [ -x "$ROOT/.venv/bin/python" ]; then
PYTHON="$ROOT/.venv/bin/python"
elif [ -f "$CACHE" ]; then
_v="$(tr -d '\r\n' < "$CACHE")"
if [ -n "$_v" ] && [ -x "$_v/bin/python" ]; then
PYTHON="$_v/bin/python"
fi
fi
if [ -z "$PYTHON" ] && command -v pipenv >/dev/null 2>&1; then
_v="$(cd "$ROOT" && pipenv --venv 2>/dev/null || true)"
if [ -n "${_v:-}" ] && [ -x "$_v/bin/python" ]; then
PYTHON="$_v/bin/python"
printf '%s\n' "$_v" > "$CACHE" || true
fi
fi
if [ -z "$PYTHON" ]; then
echo 'led-controller: no venv resolved; using pipenv run (slow). Run: cd '"$ROOT"' && PIPENV_VENV_IN_PROJECT=1 pipenv install --skip-lock --python "$(command -v python3)"' >&2
exec pipenv run run
fi
cd "$ROOT/src"
exec "$PYTHON" -u main.py

View File

@@ -367,6 +367,7 @@ async def create_driver_pattern(request):
Body JSON: Body JSON:
name, code (required), name, code (required),
min_delay, max_delay, max_colors (optional numbers), min_delay, max_delay, max_colors (optional numbers),
has_background (optional bool),
n1..n8 (optional string labels), n1..n8 (optional string labels),
overwrite (optional, default true). overwrite (optional, default true).
""" """
@@ -409,6 +410,9 @@ async def create_driver_pattern(request):
"Content-Type": "application/json" "Content-Type": "application/json"
} }
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
for i in range(1, 9): for i in range(1, 9):
nk = "n%d" % i nk = "n%d" % i
if nk not in data: if nk not in data:

View File

@@ -1,7 +1,11 @@
from microdot import Microdot, send_file import asyncio
from settings import Settings
import json import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
controller = Microdot() controller = Microdot()
settings = Settings() settings = Settings()
@@ -63,17 +67,36 @@ def _validate_wifi_channel(value):
return ch return ch
def _validate_global_brightness(value):
"""Return int 0255 or raise ValueError."""
v = int(value)
if v < 0 or v > 255:
raise ValueError("global_brightness must be between 0 and 255")
return v
@controller.put('/settings') @controller.put('/settings')
async def update_settings(request): async def update_settings(request):
"""Update general settings.""" """Update general settings."""
try: try:
data = request.json data = request.json
global_brightness_changed = False
for key, value in data.items(): for key, value in data.items():
if key == 'wifi_channel' and value is not None: if key == 'wifi_channel' and value is not None:
settings[key] = _validate_wifi_channel(value) settings[key] = _validate_wifi_channel(value)
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
else: else:
settings[key] = value settings[key] = value
settings.save() settings.save()
if global_brightness_changed:
try:
asyncio.get_running_loop().create_task(
wifi_ws_clients.broadcast_global_brightness_to_tcp_drivers()
)
except RuntimeError:
pass
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
except ValueError as e: except ValueError as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400

View File

@@ -285,12 +285,6 @@ async def main(port=80):
"""Serve the main web UI.""" """Serve the main web UI."""
return send_file('templates/index.html') return send_file('templates/index.html')
# Serve settings page
@app.route('/settings')
def settings_page(request):
"""Serve the settings page."""
return send_file('templates/settings.html')
# Favicon: avoid 404 in browser console (no file needed) # Favicon: avoid 404 in browser console (no file needed)
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(request): def favicon(request):

View File

@@ -33,6 +33,13 @@ async def _to_thread(func, *args):
return await loop.run_in_executor(None, func, *args) return await loop.run_in_executor(None, func, *args)
class NullSender:
"""Used when no ESP-NOW UART bridge is configured or the port cannot be opened."""
async def send(self, data, addr=None):
return True
class SerialSender: class SerialSender:
def __init__(self, port, baudrate, default_addr=None): def __init__(self, port, baudrate, default_addr=None):
import serial import serial
@@ -62,7 +69,22 @@ def get_current_sender():
def get_sender(settings): def get_sender(settings):
# Serial ESP-NOW bridge is opt-in (serial_enabled true); default off for dev / Wi-Fi-only.
if not settings.get("serial_enabled"):
print("[startup] serial bridge disabled (set serial_enabled true in settings.json to enable)")
return NullSender()
port = settings.get("serial_port", "/dev/ttyS0") port = settings.get("serial_port", "/dev/ttyS0")
raw_port = str(port).strip() if port is not None else ""
if not raw_port or raw_port.lower() in ("none", "off"):
print("[startup] serial bridge disabled (empty serial_port)")
return NullSender()
baudrate = settings.get("serial_baudrate", 912000) baudrate = settings.get("serial_baudrate", 912000)
default_addr = settings.get("serial_destination_mac", "ffffffffffff") default_addr = settings.get("serial_destination_mac", "ffffffffffff")
return SerialSender(port, baudrate, default_addr=default_addr) try:
return SerialSender(raw_port, baudrate, default_addr=default_addr)
except Exception as e:
print(
f"[startup] serial open failed ({raw_port!r}): {e}; "
"continuing without ESP-NOW bridge (Wi-Fi drivers unchanged)"
)
return NullSender()

View File

@@ -84,6 +84,36 @@ def prune_stale_tcp_writers() -> None:
_schedule_status_broadcast(ip, False) _schedule_status_broadcast(ip, False)
def _global_brightness_message_text() -> str | None:
"""v1 JSON line for saved zone UI brightness; works with shipping driver firmware (applies ``b`` in RAM)."""
global _settings
if _settings is None:
return None
try:
b = int(_settings.get("global_brightness", 255))
except (TypeError, ValueError):
b = 255
b = max(0, min(255, b))
return json.dumps({"v": "1", "b": b})
async def sync_global_brightness_to_driver(ip: str) -> bool:
"""Push Pi-stored global brightness to one Wi-Fi driver over the outbound WebSocket."""
text = _global_brightness_message_text()
if not text:
return False
return await send_json_line_to_ip(ip, text)
async def broadcast_global_brightness_to_tcp_drivers() -> None:
"""Push saved global brightness to every connected Wi-Fi driver."""
text = _global_brightness_message_text()
if not text:
return
for ip in list_connected_ips():
await send_json_line_to_ip(ip, text)
def _register_ws(ip: str, ws) -> None: def _register_ws(ip: str, ws) -> None:
key = normalize_tcp_peer_ip(ip) key = normalize_tcp_peer_ip(ip)
if not key: if not key:
@@ -195,6 +225,27 @@ async def _recv_forward_loop(ip: str, ws) -> None:
pass pass
def _stagger_delay_s_for_ip(ip: str) -> float:
"""0 .. wifi_driver_connect_stagger_max_s based on last IPv4 octet (deterministic spread)."""
global _settings
if _settings is None:
return 0.0
try:
max_s = float(_settings.get("wifi_driver_connect_stagger_max_s", 2.5))
except (TypeError, ValueError):
max_s = 2.5
if max_s <= 0:
return 0.0
parts = str(ip).strip().split(".")
if len(parts) != 4:
return 0.0
try:
last = int(parts[3]) % 256
except ValueError:
return 0.0
return (last / 255.0) * max_s
async def _driver_connection_loop(ip: str) -> None: async def _driver_connection_loop(ip: str) -> None:
global _settings global _settings
if _settings is None: if _settings is None:
@@ -204,16 +255,37 @@ async def _driver_connection_loop(ip: str) -> None:
if not path.startswith("/"): if not path.startswith("/"):
path = "/" + path path = "/" + path
uri = f"ws://{ip}:{port}{path}" uri = f"ws://{ip}:{port}{path}"
try:
retry_interval_s = float(_settings.get("wifi_driver_connect_retry_interval_s", 2.0))
except (TypeError, ValueError):
retry_interval_s = 2.0 retry_interval_s = 2.0
retry_window_s = 30.0 retry_interval_s = max(0.2, retry_interval_s)
deadline = asyncio.get_running_loop().time() + retry_window_s try:
retry_window_s = float(_settings.get("wifi_driver_connect_retry_window_s", 120.0))
except (TypeError, ValueError):
retry_window_s = 120.0
retry_window_s = max(5.0, retry_window_s)
try:
open_timeout = float(_settings.get("wifi_driver_ws_open_timeout", 45.0))
except (TypeError, ValueError):
open_timeout = 45.0
open_timeout = max(5.0, open_timeout)
loop = asyncio.get_running_loop()
stagger = _stagger_delay_s_for_ip(ip)
if stagger > 0:
await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
deadline = loop.time() + retry_window_s
try: try:
while True: while True:
now = asyncio.get_running_loop().time() now = loop.time()
if now >= deadline: if not connected_once and now >= deadline:
print( print(
f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s; " f"[WS] driver {ip} still unreachable after {int(retry_window_s)}s "
"stopping retries until next hello" f"(initial window); stopping until next UDP hello / registry prime"
) )
break break
try: try:
@@ -222,8 +294,9 @@ async def _driver_connection_loop(ip: str) -> None:
uri, uri,
ping_interval=20, ping_interval=20,
ping_timeout=15, ping_timeout=15,
open_timeout=30, open_timeout=open_timeout,
) as ws: ) as ws:
connected_once = True
_register_ws(ip, ws) _register_ws(ip, ws)
try: try:
await _recv_forward_loop(ip, ws) await _recv_forward_loop(ip, ws)
@@ -239,7 +312,9 @@ async def _driver_connection_loop(ip: str) -> None:
n = _unreachable_counts.get(ip, 0) + 1 n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n _unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0: if n == 1 or (n % 30) == 0:
print(f"[WS] driver {ip} unreachable, retry in 2s: {e} (x{n})") print(
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
)
else: else:
print(f"[WS] driver {ip} session error: {e!r}") print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__) traceback.print_exception(type(e), e, e.__traceback__)

View File

@@ -34,6 +34,7 @@ class Zone(Model):
"names": names if names else [], "names": names if names else [],
"presets": presets if presets else [], "presets": presets if presets else [],
"default_preset": None, "default_preset": None,
"brightness": 255,
} }
self.save() self.save()
return next_id return next_id

View File

@@ -57,6 +57,25 @@ class Settings(dict):
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766. # down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self: if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0 self['wifi_driver_hello_interval_s'] = 10.0
# Outbound WebSocket dial: total seconds to keep trying before first success
# (many devices booting at once need more than a short window).
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
if 'wifi_driver_connect_stagger_max_s' not in self:
self['wifi_driver_connect_stagger_max_s'] = 2.5
# TCP/WebSocket open timeout per attempt (seconds).
if 'wifi_driver_ws_open_timeout' not in self:
self['wifi_driver_ws_open_timeout'] = 45.0
# Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
if 'serial_enabled' not in self:
self['serial_enabled'] = False
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
def save(self): def save(self):
try: try:

View File

@@ -4,6 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
const patternsCloseButton = document.getElementById('patterns-close-btn'); const patternsCloseButton = document.getElementById('patterns-close-btn');
const patternsList = document.getElementById('patterns-list'); const patternsList = document.getElementById('patterns-list');
const patternAddButton = document.getElementById('pattern-add-btn'); const patternAddButton = document.getElementById('pattern-add-btn');
const patternSendAllButton = document.getElementById('pattern-send-all-btn');
const patternEditorModal = document.getElementById('pattern-editor-modal'); const patternEditorModal = document.getElementById('pattern-editor-modal');
const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn'); const patternEditorCloseButton = document.getElementById('pattern-editor-close-btn');
const patternCreateBtn = document.getElementById('pattern-create-btn'); const patternCreateBtn = document.getElementById('pattern-create-btn');
@@ -24,6 +25,71 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
const getCurrentProfileId = async () => {
try {
const response = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
if (!response.ok) {
return null;
}
const data = await response.json();
return data && (data.id || (data.profile && data.profile.id)) ? String(data.id || data.profile.id) : null;
} catch (_) {
return null;
}
};
const filterPresetsForCurrentProfile = async (presetsObj) => {
const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {};
const currentProfileId = await getCurrentProfileId();
if (!currentProfileId) {
return scoped;
}
return Object.fromEntries(
Object.entries(scoped).filter(([, preset]) => {
if (!preset || typeof preset !== 'object') return false;
if (!('profile_id' in preset)) return true;
return String(preset.profile_id) === String(currentProfileId);
}),
);
};
const tabDeviceNamesFromSection = (section) => {
if (typeof window.parseTabDeviceNames === 'function') {
return window.parseTabDeviceNames(section);
}
const namesAttr = section && section.getAttribute('data-device-names');
return namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
};
const postDriverSequence = async (sequence, targetMacs, delayS = 0.05) => {
const body = {
sequence,
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
delay_s: delayS,
};
const res = await fetch('/presets/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText || 'Send failed');
}
return res.json().catch(() => ({}));
};
const nReadableStringFromMeta = (meta, key) => { const nReadableStringFromMeta = (meta, key) => {
if (!meta || typeof meta !== 'object') { if (!meta || typeof meta !== 'object') {
return ''; return '';
@@ -424,4 +490,93 @@ document.addEventListener('DOMContentLoaded', () => {
patternsCloseButton.addEventListener('click', closeModal); patternsCloseButton.addEventListener('click', closeModal);
} }
if (patternSendAllButton) {
patternSendAllButton.addEventListener('click', async () => {
const section = document.querySelector('.presets-section[data-zone-id]');
const zoneId = section ? section.dataset.zoneId : null;
if (!zoneId) {
alert('Could not determine current zone.');
return;
}
const deviceNames = tabDeviceNamesFromSection(section);
if (!deviceNames.length) {
alert('No devices found in the current zone.');
return;
}
try {
const [zoneRes, presetsRes] = await Promise.all([
fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }),
fetch('/presets', { headers: { Accept: 'application/json' } }),
]);
if (!zoneRes.ok || !presetsRes.ok) {
throw new Error('Failed to load zone presets');
}
const zoneData = await zoneRes.json();
const allPresetsRaw = await presetsRes.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const zonePresetIds = Array.isArray(zoneData.presets_flat)
? zoneData.presets_flat.map((id) => String(id))
: [];
if (!zonePresetIds.length) {
alert('No presets found in this zone.');
return;
}
const wirePresets = {};
zonePresetIds.forEach((presetId) => {
const preset = allPresets[presetId];
if (!preset) {
return;
}
const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
: ['#FFFFFF'];
wirePresets[presetId] = {
pattern: preset.pattern || 'off',
colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number'
? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
n1: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
};
});
if (!Object.keys(wirePresets).length) {
alert('No matching presets found to send.');
return;
}
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = zonePresetIds.slice();
}
});
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: [];
const sequence = [
{ v: '1', clear_presets: true, save: true },
{ v: '1', presets: wirePresets, save: true },
];
if (Object.keys(select).length) {
sequence.push({ v: '1', select });
}
await postDriverSequence(sequence, targetMacs, 0.05);
} catch (error) {
console.error('Send all patterns failed:', error);
alert('Failed to send all patterns.');
}
});
}
}); });

View File

@@ -175,39 +175,6 @@ async function postDriverSequence(sequence, targetMacs, delayS) {
return res.json().catch(() => ({})); return res.json().catch(() => ({}));
} }
// Send a select message for a preset to all devices on the current zone (ESP-NOW or Wi-Fi).
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
const section = sectionEl || document.querySelector('.presets-section[data-zone-id]');
if (!section || !presetId) {
return;
}
const deviceNames = tabDeviceNamesFromSection(section);
if (!deviceNames.length) {
return;
}
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = [presetId];
}
});
const targetMacs =
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
: [];
try {
await postDriverSequence([{ v: '1', select }], targetMacs);
} catch (err) {
console.error('sendSelectForCurrentTabDevices:', err);
alert('Failed to send preset selection to devices.');
}
};
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const presetsButton = document.getElementById('presets-btn'); const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal'); const presetsModal = document.getElementById('presets-modal');
@@ -288,6 +255,18 @@ document.addEventListener('DOMContentLoaded', () => {
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
}; };
const patternSupportsBackgroundColor = () => {
if (!presetPatternInput || !presetPatternInput.value) {
return false;
}
const pattern = String(presetPatternInput.value).trim();
const meta =
(cachedPatterns && cachedPatterns[pattern]) ||
(cachedPatterns && cachedPatterns[pattern.toLowerCase()]) ||
null;
return !!(meta && typeof meta === 'object' && meta.has_background === true);
};
const renderPresetColors = (colors, paletteRefs) => { const renderPresetColors = (colors, paletteRefs) => {
if (!presetColorsContainer) return; if (!presetColorsContainer) return;
@@ -329,14 +308,21 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const swatchContainer = document.createElement('div'); const swatchContainer = document.createElement('div');
swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; swatchContainer.style.cssText = 'display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;';
swatchContainer.classList.add('color-swatches-container'); swatchContainer.classList.add('color-swatches-container');
const showBackgroundLabel = patternSupportsBackgroundColor() && currentPresetColors.length > 1;
currentPresetColors.forEach((color, index) => { currentPresetColors.forEach((color, index) => {
const isBackgroundColor = showBackgroundLabel && index === currentPresetColors.length - 1;
const swatchWrapper = document.createElement('div'); const swatchWrapper = document.createElement('div');
swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
if (isBackgroundColor) {
// Keep the background color swatch at the far right.
swatchWrapper.style.marginLeft = 'auto';
}
swatchWrapper.draggable = true; swatchWrapper.draggable = true;
swatchWrapper.dataset.colorIndex = index; swatchWrapper.dataset.colorIndex = index;
swatchWrapper.dataset.backgroundColor = isBackgroundColor ? '1' : '0';
const refAtIndex = currentPresetPaletteRefs[index]; const refAtIndex = currentPresetPaletteRefs[index];
swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : ''; swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : '';
swatchWrapper.classList.add('draggable-color-swatch'); swatchWrapper.classList.add('draggable-color-swatch');
@@ -457,6 +443,18 @@ document.addEventListener('DOMContentLoaded', () => {
swatchWrapper.appendChild(swatch); swatchWrapper.appendChild(swatch);
swatchWrapper.appendChild(colorPicker); swatchWrapper.appendChild(colorPicker);
swatchWrapper.appendChild(removeBtn); swatchWrapper.appendChild(removeBtn);
if (isBackgroundColor) {
const bgLabel = document.createElement('div');
bgLabel.textContent = 'Background';
bgLabel.style.cssText = `
margin-top: 0.25rem;
text-align: center;
font-size: 0.72rem;
color: #cfcfcf;
letter-spacing: 0.02em;
`;
swatchWrapper.appendChild(bgLabel);
}
swatchContainer.appendChild(swatchWrapper); swatchContainer.appendChild(swatchWrapper);
}); });
@@ -478,6 +476,10 @@ document.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
const dragging = swatchContainer.querySelector('.dragging-color'); const dragging = swatchContainer.querySelector('.dragging-color');
if (!dragging) return; if (!dragging) return;
const backgroundEl = swatchContainer.querySelector('.draggable-color-swatch[data-background-color="1"]');
if (backgroundEl) {
swatchContainer.appendChild(backgroundEl);
}
// Get new order of colors from DOM // Get new order of colors from DOM
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')]; const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
@@ -1332,7 +1334,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device. // Try sends preset first, then select; never persist on device.
await sendPresetViaEspNow(presetId, payload, deviceNames, false, false); await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2');
}); });
} }
@@ -1346,8 +1348,9 @@ document.addEventListener('DOMContentLoaded', () => {
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name; const presetId = currentEditId || payload.name;
await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1');
await updateTabDefaultPreset(presetId); await updateTabDefaultPreset(presetId);
await sendDefaultPreset(presetId, deviceNames); await sendDefaultPreset('1', deviceNames);
}); });
} }
@@ -1379,7 +1382,7 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to save preset'); throw new Error('Failed to save preset');
} }
// Same device targeting as Try: zone tab supplies names → /presets/push gets targets + select. // Same device targeting as Try: zone tab supplies names and selection without persistence.
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section); const deviceNames = tabDeviceNamesFromSection(section);
@@ -1388,18 +1391,18 @@ document.addEventListener('DOMContentLoaded', () => {
if (saved && typeof saved === 'object') { if (saved && typeof saved === 'object') {
if (currentEditId) { if (currentEditId) {
// PUT returns the preset object directly; use the existing ID // PUT returns the preset object directly; use the existing ID
await sendPresetViaEspNow(currentEditId, saved, deviceNames, true, false); await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2');
} else { } else {
// POST returns { id: preset } // POST returns { id: preset }
const entries = Object.entries(saved); const entries = Object.entries(saved);
if (entries.length > 0) { if (entries.length > 0) {
const [newId, presetData] = entries[0]; const [newId, presetData] = entries[0];
await sendPresetViaEspNow(newId, presetData, deviceNames, true, false); await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2');
} }
} }
} else { } else {
// Fallback: send what we just built // Fallback: send what we just built
await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, true, false); await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2');
} }
await loadPresets(); await loadPresets();
@@ -1454,7 +1457,14 @@ const coercePresetInt = (v, def = 0) => {
// 1) preset payload (optionally with save) // 1) preset payload (optionally with save)
// 2) optional select for device names (never with save) // 2) optional select for device names (never with save)
// saveToDevice defaults to true. // saveToDevice defaults to true.
const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { const sendPresetViaEspNow = async (
presetId,
preset,
deviceNames,
saveToDevice = true,
setDefault = false,
devicePresetId = null,
) => {
try { try {
const baseColors = Array.isArray(preset.colors) && preset.colors.length const baseColors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
@@ -1462,10 +1472,11 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const paletteColors = await getCurrentProfilePaletteColors(); const paletteColors = await getCurrentProfilePaletteColors();
const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors);
const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId);
const presetMessage = { const presetMessage = {
v: '1', v: '1',
presets: { presets: {
[presetId]: { [wirePresetId]: {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100, delay: typeof preset.delay === 'number' ? preset.delay : 100,
@@ -1486,7 +1497,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
presetMessage.save = true; presetMessage.save = true;
} }
if (setDefault) { if (setDefault) {
presetMessage.default = presetId; presetMessage.default = wirePresetId;
} }
const names = Array.isArray(deviceNames) ? deviceNames : []; const names = Array.isArray(deviceNames) ? deviceNames : [];
@@ -1502,7 +1513,7 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
const select = {}; const select = {};
names.forEach((name) => { names.forEach((name) => {
if (name) { if (name) {
select[name] = [presetId]; select[name] = [wirePresetId];
} }
}); });
if (Object.keys(select).length > 0) { if (Object.keys(select).length > 0) {
@@ -1879,7 +1890,8 @@ const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
button.classList.add('active'); button.classList.add('active');
selectedPresets[zoneId] = presetId; selectedPresets[zoneId] = presetId;
const section = row.closest('.presets-section'); const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section).catch((err) => { const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
console.error(err); console.error(err);
}); });
}); });

View File

@@ -149,6 +149,40 @@ header h1 {
background-color: #333; background-color: #333;
} }
.menu-brightness-control {
padding: 0.45rem 0.75rem 0.55rem;
border-bottom: 1px solid #333;
}
.menu-brightness-control label {
display: block;
font-size: 0.78rem;
color: #bdbdbd;
margin-bottom: 0.3rem;
}
.menu-brightness-control input[type="range"] {
width: 100%;
}
.header-brightness-control {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 13rem;
padding: 0.2rem 0.1rem;
}
.header-brightness-control label {
font-size: 0.8rem;
color: #bdbdbd;
white-space: nowrap;
}
.header-brightness-control input[type="range"] {
width: 8.5rem;
}
/* Header/menu actions that should only appear in Edit mode */ /* Header/menu actions that should only appear in Edit mode */
body.preset-ui-run .edit-mode-only { body.preset-ui-run .edit-mode-only {
display: none !important; display: none !important;
@@ -248,7 +282,8 @@ body.preset-ui-run .edit-mode-only {
display: block; display: block;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 0.5rem 1rem 1rem; padding: 0.5rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px) + 3.5rem);
-webkit-overflow-scrolling: touch;
} }
.presets-toolbar { .presets-toolbar {
@@ -515,19 +550,25 @@ body.preset-ui-run .edit-mode-only {
padding: 0; padding: 0;
} }
/* Zone preset selecting area: 3 columns, vertical scroll only */ /* Zone preset selecting area: 8 columns on desktop, vertical scroll only */
#presets-list-zone { #presets-list-zone {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 5rem; grid-auto-rows: 5rem;
column-gap: 0.3rem; column-gap: 0.3rem;
row-gap: 0.3rem; row-gap: 0.3rem;
align-content: start; align-content: start;
width: 100%; width: 100%;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 5.5rem);
}
#presets-list-zone > :last-child {
margin-bottom: calc(env(safe-area-inset-bottom, 0px) + 2.5rem);
} }
/* Settings modal layout */ /* Settings modal layout */
@@ -949,7 +990,7 @@ body.preset-ui-run .edit-mode-only {
} }
/* Mobile-friendly layout */ /* Mobile-friendly layout */
@media (max-width: 800px) { @media (max-width: 1000px) {
header { header {
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@@ -1001,6 +1042,9 @@ body.preset-ui-run .edit-mode-only {
min-width: 280px; min-width: 280px;
max-width: 95vw; max-width: 95vw;
padding: 1.25rem; padding: 1.25rem;
max-height: calc(100dvh - 1rem);
overflow-y: auto;
padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
} }
.form-row { .form-row {
@@ -1018,6 +1062,10 @@ body.preset-ui-run .edit-mode-only {
width: 100%; width: 100%;
height: 100%; height: 100%;
background-color: rgba(0,0,0,0.7); background-color: rgba(0,0,0,0.7);
overflow-y: auto;
padding: 1rem;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
} }
.modal.active { .modal.active {
display: flex; display: flex;
@@ -1030,6 +1078,20 @@ body.preset-ui-run .edit-mode-only {
border-radius: 8px; border-radius: 8px;
min-width: 400px; min-width: 400px;
max-width: 600px; max-width: 600px;
max-height: calc(100dvh - 2rem);
overflow-y: auto;
padding-bottom: calc(2rem + env(safe-area-inset-bottom, 0px));
-webkit-overflow-scrolling: touch;
}
/* Real-phone viewport fallback for browsers with unstable 100dvh behavior. */
@supports (-webkit-touch-callout: none) {
.modal {
min-height: -webkit-fill-available;
}
.modal-content {
max-height: calc(-webkit-fill-available - 2rem);
}
} }
.modal-content label { .modal-content label {
display: block; display: block;
@@ -1199,10 +1261,12 @@ body.preset-ui-run .edit-mode-only {
.color-swatches-container { .color-swatches-container {
min-height: 80px; min-height: 80px;
} }
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */ /* Presets list: 3 columns on phone-sized screens */
@media (max-width: 800px) { @media (max-width: 600px) {
#presets-list-zone { #presets-list-zone {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
scroll-padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);
} }
} }
/* Help modal readability */ /* Help modal readability */

View File

@@ -1,5 +1,82 @@
// Zone management JavaScript // Zone management JavaScript
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null;
function clamp255(n) {
const v = parseInt(n, 10);
if (Number.isNaN(v)) return null;
return Math.max(0, Math.min(255, v));
}
function applyBrightnessSliders(val) {
const v = clamp255(val);
if (v === null) return;
const headerSlider = document.getElementById("header-brightness-slider");
const menuSlider = document.getElementById("menu-brightness-slider");
if (headerSlider) headerSlider.value = String(v);
if (menuSlider) menuSlider.value = String(v);
}
async function saveZoneBrightnessToServer(zoneId, val) {
if (!zoneId) return;
try {
const res = await fetch(`/zones/${zoneId}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify({ brightness: val }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
console.warn("zone brightness save failed:", err.error || res.status);
}
} catch (e) {
console.warn("zone brightness save failed:", e);
}
}
function sendZoneBrightness(zoneId, value) {
if (!zoneId) return;
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider');
if (headerSlider && String(headerSlider.value) !== String(val)) {
headerSlider.value = String(val);
}
if (menuSlider && String(menuSlider.value) !== String(val)) {
menuSlider.value = String(val);
}
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
(async () => {
try {
await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section)
: [];
const targetMacs =
names.length > 0 &&
typeof window.tabsManager !== 'undefined' &&
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
? await window.tabsManager.resolveTabDeviceMacs(names)
: [];
if (typeof window.postDriverSequence === 'function') {
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
return;
}
// Fallback to raw websocket sender if presets.js helper isn't available yet.
if (typeof window.sendEspnowRaw === 'function') {
window.sendEspnowRaw({ v: '1', b: val, save: true });
}
} catch (err) {
console.error('Failed to send brightness via driver sequence:', err);
}
})();
}, 150);
}
const isEditModeActive = () => { const isEditModeActive = () => {
const toggle = document.querySelector('.ui-mode-toggle'); const toggle = document.querySelector('.ui-mode-toggle');
@@ -468,38 +545,23 @@ async function loadZoneContent(zoneId) {
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : ""; const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}> <div class="presets-section" data-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="zone-brightness-group">
<label for="zone-brightness-slider">Brightness</label>
<input type="range" id="zone-brightness-slider" min="0" max="255" value="255">
</div>
</div>
<div id="presets-list-zone" class="presets-list"> <div id="presets-list-zone" class="presets-list">
<!-- Presets will be loaded here by presets.js --> <!-- Presets will be loaded here by presets.js -->
</div> </div>
</div> </div>
`; `;
// Wire up per-zone brightness slider to send global brightness via ESPNow. // Keep header and menu brightness controls in sync.
const brightnessSlider = container.querySelector('#zone-brightness-slider'); const zoneBrightness =
let brightnessSendTimeout = null; typeof zone.brightness === 'number'
if (brightnessSlider) { ? zone.brightness
brightnessSlider.addEventListener('input', (e) => { : parseInt(String(zone.brightness ?? ''), 10);
const val = parseInt(e.target.value, 10) || 0; const normalizedBrightness = Number.isFinite(zoneBrightness)
if (brightnessSendTimeout) { ? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
clearTimeout(brightnessSendTimeout); : 255;
} applyBrightnessSliders(normalizedBrightness);
brightnessSendTimeout = setTimeout(() => { // Apply this zone's saved brightness when switching zones.
if (typeof window.sendEspnowRaw === 'function') { sendZoneBrightness(zoneId, normalizedBrightness);
try {
window.sendEspnowRaw({ v: '1', b: val, save: true });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
}
}
}, 150);
});
}
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -967,6 +1029,23 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => {
if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => {
if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
});
}
if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => {
if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
});
}
})();
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately. // When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {

View File

@@ -15,6 +15,10 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="header-brightness-control">
<label for="header-brightness-slider">Brightness</label>
<input type="range" id="header-brightness-slider" min="0" max="255" value="255">
</div>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button> <button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button> <button class="btn btn-secondary edit-mode-only" id="zones-btn">Zones</button>
@@ -30,6 +34,10 @@
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label>
<input type="range" id="menu-brightness-slider" min="0" max="255" value="255">
</div>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button> <button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button> <button type="button" class="edit-mode-only" data-target="zones-btn">Tabs</button>
@@ -245,6 +253,7 @@
<h2>Patterns</h2> <h2>Patterns</h2>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button> <button type="button" class="btn btn-primary" id="pattern-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="pattern-send-all-btn">Send All Patterns</button>
</div> </div>
<div id="patterns-list" class="profiles-list"></div> <div id="patterns-list" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">

508
src/util/binary_envelope.py Normal file
View File

@@ -0,0 +1,508 @@
"""
Compact binary controller → led-driver messages (ESP-NOW friendly).
Header (5 bytes), same for v1 (legacy) and v2 (native binary):
0: version — 1 = legacy (JSON text blobs); 2 = native binary blobs
1: brightness — 0127 scales to device 0255; 128255 = leave unchanged
2: byte length of presets section (0255)
3: byte length of select section
4: byte length of default section
v2 presets blob (no JSON):
u8 preset_count
each preset:
u8 name_len; name utf-8
u8 pattern_len; pattern utf-8 (``p``)
u8 color_count; color_count × (u8 r, u8 g, u8 b)
u16 delay_le (``d``)
u8 preset_brightness (``b``)
u8 auto (0/1) (``a``)
i16 n1..n6 little-endian (``n1````n6``)
v2 select blob:
u8 entry_count
each:
u8 device_len; device utf-8
u8 preset_name_len; preset name utf-8
u8 has_step (0/1); optional u16 step_le
v2 default blob:
u8 default_name_len; name utf-8
u8 target_count
each: u8 len; target name utf-8
Legacy v1: sections are UTF-8 JSON text (see ``parse_binary_envelope_v1``).
Keep ``5 + lp + ls + ld`` ≤ 245 for a single ESP-NOW frame body.
"""
from __future__ import annotations
import json
import struct
from typing import Any, Dict, List, Optional, Tuple
BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5
def brightness_wire_from_0_255(value: int) -> int:
"""Map device brightness 0255 to wire 0127."""
v = max(0, min(255, int(value)))
return (v * 127 + 127) // 255
def brightness_0_255_from_wire(wire: int) -> int:
"""Map wire 0127 to device brightness 0255."""
w = max(0, min(127, int(wire)))
return min(255, (w * 255) // 127)
def _clamp_i16(x: int) -> int:
x = int(x)
return max(-32768, min(32767, x))
def _colors_to_rgb_list(colors: Any) -> List[Tuple[int, int, int]]:
out: List[Tuple[int, int, int]] = []
if not colors:
return out
for c in colors:
if isinstance(c, str):
h = c.strip().lstrip("#")
if len(h) >= 6:
out.append(
(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
)
elif isinstance(c, (list, tuple)) and len(c) >= 3:
out.append((int(c[0]), int(c[1]), int(c[2])))
return out
def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
pname = name.encode("utf-8")
if len(pname) > 250:
raise ValueError("preset name too long")
pattern = str(preset.get("p") or preset.get("pattern", "off")).encode("utf-8")
if len(pattern) > 250:
raise ValueError("pattern string too long")
rgbs = _colors_to_rgb_list(preset.get("c") or preset.get("colors") or [])
if len(rgbs) > 255:
raise ValueError("too many colours")
delay = max(0, min(65535, int(preset.get("d") or preset.get("delay", 100))))
br = max(0, min(255, int(preset.get("b") or preset.get("brightness", 127))))
auto = 1 if preset.get("a", preset.get("auto", True)) else 0
parts = [
bytes([len(pname)]),
pname,
bytes([len(pattern)]),
pattern,
bytes([len(rgbs)]),
]
for r, g, b in rgbs:
parts.append(bytes([r & 255, g & 255, b & 255]))
n1 = _clamp_i16(preset.get("n1", 0))
n2 = _clamp_i16(preset.get("n2", 0))
n3 = _clamp_i16(preset.get("n3", 0))
n4 = _clamp_i16(preset.get("n4", 0))
n5 = _clamp_i16(preset.get("n5", 0))
n6 = _clamp_i16(preset.get("n6", 0))
parts.append(
struct.pack(
"<HBBhhhhhh",
delay,
br,
auto,
n1,
n2,
n3,
n4,
n5,
n6,
)
)
return b"".join(parts)
def _pack_presets_blob(presets: Dict[str, Any]) -> bytes:
items = [(k, v) for k, v in presets.items() if isinstance(v, dict)]
out = [bytes([len(items)])]
for name, pdata in items:
out.append(_pack_preset_dict(str(name), pdata))
return b"".join(out)
def _pack_select_blob(select: Dict[str, Any]) -> bytes:
out = [bytes([len(select)])]
for device, sel in select.items():
dev_b = str(device).encode("utf-8")
if len(dev_b) > 250:
raise ValueError("device name too long")
if isinstance(sel, (list, tuple)) and sel:
pn = str(sel[0]).encode("utf-8")
step = sel[1] if len(sel) > 1 else None
else:
pn = str(sel).encode("utf-8")
step = None
if len(pn) > 250:
raise ValueError("preset name too long")
if step is None:
out.append(
bytes([len(dev_b)])
+ dev_b
+ bytes([len(pn)])
+ pn
+ bytes([0])
)
else:
s = int(step)
if s < 0 or s > 65535:
raise ValueError("step out of range")
out.append(
bytes([len(dev_b)])
+ dev_b
+ bytes([len(pn)])
+ pn
+ bytes([1])
+ struct.pack("<H", s)
)
return b"".join(out)
def _pack_default_blob(default: str, targets: Optional[list]) -> bytes:
name_b = str(default).encode("utf-8")
if len(name_b) > 250:
raise ValueError("default name too long")
tlist = list(targets) if targets else []
if len(tlist) > 255:
raise ValueError("too many targets")
out = [bytes([len(name_b)]), name_b, bytes([len(tlist)])]
for t in tlist:
tb = str(t).encode("utf-8")
if len(tb) > 250:
raise ValueError("target name too long")
out.append(bytes([len(tb)]))
out.append(tb)
return b"".join(out)
def pack_binary_envelope_v2(
*,
presets: Optional[Dict[str, Any]] = None,
select: Optional[Dict[str, Any]] = None,
default: Optional[str] = None,
default_targets: Optional[list] = None,
brightness_0_255: Optional[int] = None,
) -> bytes:
"""Build a v2 envelope (native binary sections, no JSON)."""
presets_bytes = (
_pack_presets_blob(presets) if presets is not None and presets else b""
)
select_bytes = (
_pack_select_blob(select) if select is not None and select else b""
)
default_bytes = (
_pack_default_blob(default, default_targets)
if default is not None
else b""
)
lp = len(presets_bytes)
ls = len(select_bytes)
ld = len(default_bytes)
if lp > 255 or ls > 255 or ld > 255:
raise ValueError("binary envelope section exceeds 255 bytes")
br_wire = (
255
if brightness_0_255 is None
else brightness_wire_from_0_255(brightness_0_255)
)
header = bytes([BINARY_ENVELOPE_VERSION_2, br_wire, lp, ls, ld])
return header + presets_bytes + select_bytes + default_bytes
def pack_binary_envelope_v1(
*,
presets: Optional[Dict[str, Any]] = None,
select: Optional[Dict[str, Any]] = None,
default: Optional[str] = None,
default_targets: Optional[list] = None,
brightness_0_255: Optional[int] = None,
) -> bytes:
"""Legacy: JSON UTF-8 fragments (version byte 1). Prefer ``pack_binary_envelope_v2``."""
if presets is None:
presets_bytes = b""
else:
presets_bytes = json.dumps(presets, separators=(",", ":")).encode("utf-8")
if select is None:
select_bytes = b""
else:
select_bytes = json.dumps(select, separators=(",", ":")).encode("utf-8")
default_obj: Optional[Dict[str, Any]] = None
if default is not None:
default_obj = {
"default": default,
"targets": list(default_targets) if default_targets else [],
}
default_bytes = (
json.dumps(default_obj, separators=(",", ":")).encode("utf-8")
if default_obj is not None
else b""
)
lp = len(presets_bytes)
ls = len(select_bytes)
ld = len(default_bytes)
if lp > 255 or ls > 255 or ld > 255:
raise ValueError("binary envelope fragment exceeds 255 bytes")
br_wire = (
255
if brightness_0_255 is None
else brightness_wire_from_0_255(brightness_0_255)
)
header = bytes([BINARY_ENVELOPE_VERSION_1, br_wire, lp, ls, ld])
return header + presets_bytes + select_bytes + default_bytes
def _decode_preset_record(
buf: bytes, off: int
) -> Tuple[str, Dict[str, Any], int]:
if off + 1 > len(buf):
raise ValueError("truncated")
nl = buf[off]
off += 1
if off + nl > len(buf):
raise ValueError("truncated")
name = buf[off : off + nl].decode("utf-8")
off += nl
if off + 1 > len(buf):
raise ValueError("truncated")
pl = buf[off]
off += 1
if off + pl > len(buf):
raise ValueError("truncated")
pattern = buf[off : off + pl].decode("utf-8")
off += pl
if off + 1 > len(buf):
raise ValueError("truncated")
nc = buf[off]
off += 1
if off + nc * 3 > len(buf):
raise ValueError("truncated")
colors: List[str] = []
for _ in range(nc):
r, g, b = buf[off], buf[off + 1], buf[off + 2]
off += 3
colors.append(f"#{r:02x}{g:02x}{b:02x}")
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: bytes) -> Dict[str, Any]:
if not chunk:
return {}
off = 0
if off + 1 > len(chunk):
raise ValueError("truncated")
count = chunk[off]
off += 1
out: Dict[str, Any] = {}
for _ in range(count):
name, preset, off = _decode_preset_record(chunk, off)
out[name] = preset
if off != len(chunk):
raise ValueError("presets blob length mismatch")
return out
def _decode_select_blob(chunk: bytes) -> Dict[str, Any]:
if not chunk:
return {}
off = 0
if off + 1 > len(chunk):
raise ValueError("truncated")
count = chunk[off]
off += 1
out: Dict[str, Any] = {}
for _ in range(count):
if off + 1 > len(chunk):
raise ValueError("truncated")
dl = chunk[off]
off += 1
if off + dl > len(chunk):
raise ValueError("truncated")
device = chunk[off : off + dl].decode("utf-8")
off += dl
if off + 1 > len(chunk):
raise ValueError("truncated")
pl = chunk[off]
off += 1
if off + pl > len(chunk):
raise ValueError("truncated")
pname = chunk[off : off + pl].decode("utf-8")
off += pl
if off + 1 > len(chunk):
raise ValueError("truncated")
has_step = chunk[off]
off += 1
if has_step:
if off + 2 > len(chunk):
raise ValueError("truncated")
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 length mismatch")
return out
def _decode_default_blob(chunk: bytes) -> Tuple[Optional[str], list]:
if not chunk:
return None, []
off = 0
if off + 1 > len(chunk):
raise ValueError("truncated")
nl = chunk[off]
off += 1
if off + nl > len(chunk):
raise ValueError("truncated")
default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
off += nl
if off + 1 > len(chunk):
raise ValueError("truncated")
nt = chunk[off]
off += 1
targets: List[str] = []
for _ in range(nt):
if off + 1 > len(chunk):
raise ValueError("truncated")
tl = chunk[off]
off += 1
if off + tl > len(chunk):
raise ValueError("truncated")
targets.append(chunk[off : off + tl].decode("utf-8"))
off += tl
if off != len(chunk):
raise ValueError("default blob length mismatch")
return default_name, targets
def parse_binary_envelope_v2(buf: bytes) -> Optional[Dict[str, Any]]:
"""Decode native-binary v2 envelope into the v1 API dict shape."""
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_2:
return None
lp, ls, ld = buf[2], buf[3], 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: Dict[str, Any] = {"v": "1"}
br = buf[1]
if br < 128:
data["b"] = brightness_0_255_from_wire(br)
try:
if lp:
data["presets"] = _decode_presets_blob(bytes(presets_chunk))
if ls:
data["select"] = _decode_select_blob(bytes(select_chunk))
if ld:
dname, targets = _decode_default_blob(bytes(default_chunk))
data["default"] = dname
data["targets"] = targets
except (ValueError, UnicodeError):
return None
return data
def parse_binary_envelope_v1(buf: bytes) -> Optional[Dict[str, Any]]:
"""
Decode legacy v1 bytes (JSON text blobs) into a v1 API dict.
Returns None if ``buf`` is not a valid v1 envelope.
"""
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
return None
if buf[0] != BINARY_ENVELOPE_VERSION_1:
return None
lp, ls, ld = buf[2], buf[3], 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: Dict[str, Any] = {"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: bytes) -> Optional[Dict[str, Any]]:
"""Try v2 (native binary), then v1 (JSON fragments)."""
d = parse_binary_envelope_v2(buf)
if d is not None:
return d
return parse_binary_envelope_v1(buf)

25
src/util/message.py Normal file
View File

@@ -0,0 +1,25 @@
"""JSON wire representation for controller messages (binary packing can replace later)."""
from __future__ import annotations
import json
from typing import Any, Dict, Union
class Message:
"""Round-trip API dicts as compact UTF-8 JSON."""
def encode(self, data: Dict[str, Any]) -> bytes:
"""Encode a JSON-serialisable mapping (typically a v1 API dict) to bytes."""
return json.dumps(data, separators=(",", ":")).encode("utf-8")
def decode(self, payload: Union[str, bytes, bytearray]) -> Dict[str, Any]:
"""Decode UTF-8 JSON bytes or string into a dict."""
if isinstance(payload, (bytes, bytearray)):
text = payload.decode("utf-8")
else:
text = payload
obj = json.loads(text)
if not isinstance(obj, dict):
raise TypeError("JSON root must be an object")
return obj

View File

@@ -0,0 +1,93 @@
"""Tests for compact binary controller envelopes (host util)."""
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from util.binary_envelope import ( # noqa: E402
BINARY_ENVELOPE_VERSION_2,
brightness_wire_from_0_255,
brightness_0_255_from_wire,
pack_binary_envelope_v2,
parse_binary_envelope,
parse_binary_envelope_v2,
parse_binary_envelope_v1,
)
def test_brightness_round_trip_extremes():
assert brightness_0_255_from_wire(brightness_wire_from_0_255(0)) == 0
assert brightness_0_255_from_wire(brightness_wire_from_0_255(255)) == 255
def test_pack_parse_v2_brightness_only():
raw = pack_binary_envelope_v2(brightness_0_255=128)
assert raw[0] == BINARY_ENVELOPE_VERSION_2
data = parse_binary_envelope_v2(raw)
assert data == {"v": "1", "b": 128}
def test_pack_parse_v2_full():
raw = pack_binary_envelope_v2(
presets={
"a": {
"p": "on",
"c": ["#ffffff"],
"d": 10,
"b": 255,
"a": True,
"n1": 1,
"n2": -2,
"n3": 3,
"n4": 4,
"n5": 5,
"n6": 6,
}
},
select={"dev": ["a"]},
default="a",
default_targets=["dev"],
brightness_0_255=64,
)
assert len(raw) <= 250
data = parse_binary_envelope_v2(raw)
assert data["v"] == "1"
assert data["b"] == 64
assert data["presets"]["a"]["p"] == "on"
assert data["presets"]["a"]["n2"] == -2
assert data["select"]["dev"] == ["a"]
assert data["default"] == "a"
assert data["targets"] == ["dev"]
merged = parse_binary_envelope(raw)
assert merged == data
def test_v2_wire_not_utf8_json():
raw = pack_binary_envelope_v2(
presets={"x": {"p": "blink", "c": ["#112233"]}},
brightness_0_255=None,
)
assert raw[0] == BINARY_ENVELOPE_VERSION_2
assert parse_binary_envelope_v1(raw) is None
def test_dont_change_brightness_v2():
raw = pack_binary_envelope_v2(brightness_0_255=None)
data = parse_binary_envelope_v2(raw)
assert "b" not in data
def test_json_wire_not_v2():
assert parse_binary_envelope_v2(b'{"v":"1"}') is None
def test_legacy_v1_parse_via_dispatcher():
import json
inner = json.dumps({"x": {"p": "on"}}, separators=(",", ":")).encode()
raw = bytes([1, 255, len(inner), 0, 0]) + inner
d = parse_binary_envelope(raw)
assert d["presets"]["x"]["p"] == "on"

View File

@@ -2,7 +2,7 @@
""" """
Browser automation tests using Selenium. Browser automation tests using Selenium.
Tests run against the device in an actual browser. Target host defaults to Tests run against the device in an actual browser. Target host defaults to
``192.168.4.1``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname, ``127.0.0.1:5000``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname,
or a full ``http://`` / ``https://`` base URL). or a full ``http://`` / ``https://`` base URL).
Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE`` Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE``
@@ -49,7 +49,7 @@ from selenium.common.exceptions import (
ElementNotInteractableException, ElementNotInteractableException,
) )
_DEFAULT_DEVICE_HOST = "192.168.4.1" _DEFAULT_DEVICE_HOST = "127.0.0.1:5000"
def _device_base_url() -> str: def _device_base_url() -> str:

View File

@@ -347,6 +347,15 @@ def test_settings_controller(server):
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12}) resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
assert resp.status_code == 400 assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
assert resp.status_code == 400
def test_profiles_presets_zones_endpoints(server, monkeypatch): def test_profiles_presets_zones_endpoints(server, monkeypatch):
c: requests.Session = server["client"] c: requests.Session = server["client"]