9 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
28 changed files with 675 additions and 141 deletions

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

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 +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}, "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 10\u201330 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1\u2013255, higher = more changes)", "n2": "Density (0\u2013255, 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}, "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}, "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}, "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}, "bar_graph": {"n1": "Level percent", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "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}, "rain_drops": {"n1": "Drop rate", "n2": "Ripple width", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "fireflies": {"n1": "Count", "n2": "Twinkle speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "clock_sweep": {"n1": "Hand width", "n2": "Marker interval", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "marquee": {"n1": "On length", "n2": "Off length", "n3": "Step", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "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}, "heartbeat": {"n1": "Pulse 1 ms", "n2": "Pulse 2 ms", "n3": "Pause ms", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "orbit": {"n1": "Orbit count", "n2": "Base speed", "max_colors": 10, "min_delay": 10, "max_delay": 10000}, "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, "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}}

File diff suppressed because one or more lines are too long

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"], ["43", "44", "45"], ["46", "47", "48"], ["49", "50", "51"], ["52", "53", "54"], ["55", "56", "57"], ["58", "59", "60"], ["61", "62"]], "presets_flat": ["4", "2", "7", "3", "14", "5", "8", "9", "1", "6", "38", "42", "39", "40", "41", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62"], "default_preset": "41"}, "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, "presets_flat": []}, "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`

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

@@ -255,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;
@@ -296,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');
@@ -424,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);
}); });
@@ -445,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')];

View File

@@ -550,14 +550,14 @@ 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;
@@ -1261,8 +1261,8 @@ 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: 1000px) { @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); padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 7rem);

View File

@@ -2,7 +2,41 @@
let currentZoneId = null; let currentZoneId = null;
let brightnessSendTimeout = null; let brightnessSendTimeout = null;
function sendZoneBrightness(value) { 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 val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
const headerSlider = document.getElementById('header-brightness-slider'); const headerSlider = document.getElementById('header-brightness-slider');
const menuSlider = document.getElementById('menu-brightness-slider'); const menuSlider = document.getElementById('menu-brightness-slider');
@@ -18,6 +52,7 @@ function sendZoneBrightness(value) {
brightnessSendTimeout = setTimeout(() => { brightnessSendTimeout = setTimeout(() => {
(async () => { (async () => {
try { try {
await saveZoneBrightnessToServer(zoneId, val);
const section = document.querySelector('.presets-section[data-zone-id]'); const section = document.querySelector('.presets-section[data-zone-id]');
const names = typeof window.parseTabDeviceNames === 'function' const names = typeof window.parseTabDeviceNames === 'function'
? window.parseTabDeviceNames(section) ? window.parseTabDeviceNames(section)
@@ -517,11 +552,16 @@ async function loadZoneContent(zoneId) {
`; `;
// Keep header and menu brightness controls in sync. // Keep header and menu brightness controls in sync.
const brightnessSlider = document.getElementById('header-brightness-slider'); const zoneBrightness =
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); typeof zone.brightness === 'number'
if (menuBrightnessSlider && brightnessSlider) { ? zone.brightness
menuBrightnessSlider.value = brightnessSlider.value; : parseInt(String(zone.brightness ?? ''), 10);
} const normalizedBrightness = Number.isFinite(zoneBrightness)
? Math.max(0, Math.min(255, Math.round(zoneBrightness)))
: 255;
applyBrightnessSliders(normalizedBrightness);
// Apply this zone's saved brightness when switching zones.
sendZoneBrightness(zoneId, normalizedBrightness);
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
@@ -990,19 +1030,21 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const menuBrightnessSlider = document.getElementById('menu-brightness-slider'); const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
(async () => {
if (menuBrightnessSlider) { if (menuBrightnessSlider) {
menuBrightnessSlider.addEventListener('input', (e) => { menuBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
} }
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
if (headerBrightnessSlider) { if (headerBrightnessSlider) {
headerBrightnessSlider.addEventListener('input', (e) => { headerBrightnessSlider.addEventListener('input', (e) => {
sendZoneBrightness(e.target.value); if (!currentZoneId) return;
sendZoneBrightness(currentZoneId, e.target.value);
}); });
// Initial sync so both controls start aligned.
sendZoneBrightness(headerBrightnessSlider.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) => {

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"]