Compare commits
8 Commits
c1b0c41ef2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d6ef5c7b4 | |||
| 78a4ce009c | |||
| 7ccab6fbc4 | |||
|
|
827eb97203 | ||
|
|
3cca0cffc5 | ||
|
|
d36828bde2 | ||
|
|
ed0048c795 | ||
|
|
b316edbaf9 |
18
.cursor/rules/submodules-led-driver-tool.mdc
Normal file
18
.cursor/rules/submodules-led-driver-tool.mdc
Normal 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
2
.gitignore
vendored
@@ -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
14
Pipfile
@@ -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
147
Pipfile.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": []}}
|
|
||||||
@@ -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 10–30 s, -1=off)", "n4": "Spark gap max (ms)", "min_delay": 10, "max_delay": 10000, "max_colors": 10}, "twinkle": {"n1": "Twinkle activity (1–255, higher = more changes)", "n2": "Density (0–255, 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
@@ -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"]}}
|
|
||||||
@@ -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 Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
| GET | `/settings/wifi/ap` | Saved Wi‑Fi AP fields: `saved_ssid`, `saved_password`, `saved_channel`, `active` (Pi: `active` is always false). |
|
||||||
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. |
|
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). 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`
|
||||||
|
|
||||||
|
|||||||
Submodule led-driver updated: 3ee89ce3b4...fbebe9f4f9
Submodule led-simulator updated: 7ce56b64df...42c14361e8
2
led-tool
2
led-tool
Submodule led-tool updated: 2f3db9272b...580fd11aca
@@ -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
|
||||||
|
|||||||
@@ -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; Wi‑Fi/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
253
scripts/pi-eth-lan-router.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 0–255 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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -57,9 +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).
|
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
|
||||||
if 'serial_enabled' not in self:
|
if 'serial_enabled' not in self:
|
||||||
self['serial_enabled'] = False
|
self['serial_enabled'] = False
|
||||||
|
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||||
|
if 'global_brightness' not in self:
|
||||||
|
self['global_brightness'] = 255
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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')];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user