Compare commits
66 Commits
97ffc69b12
...
pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09a87b79d2 | ||
|
|
ec39df00fc | ||
|
|
43d494bcb9 | ||
|
|
fed312a397 | ||
| 63235c7822 | |||
| 5badf17719 | |||
| 4597573ac5 | |||
| 1550122ced | |||
| b7c45fd72c | |||
| 9479d0d292 | |||
| 3698385af4 | |||
| ef968ebe39 | |||
| a5432db99a | |||
| 764d918d5b | |||
| edadb40cb6 | |||
| 9323719a85 | |||
| 91de705647 | |||
| 3ee7b74152 | |||
| 98bbdcbb3d | |||
| a2abd3e833 | |||
| 550217c443 | |||
| 2d2032e8b9 | |||
| 81bf4dded5 | |||
| a75e27e3d2 | |||
| 13538c39a6 | |||
| 7b724e9ce1 | |||
| aaca5435e9 | |||
| b64dacc1c3 | |||
| 8689bdb6ef | |||
| c178e87966 | |||
| dfe7ae50d2 | |||
| 8e87559af6 | |||
| aa3546e9ac | |||
| b56af23cbf | |||
| ac9fca8d4b | |||
| 0fdc11c0b0 | |||
| 91bd78ab31 | |||
| 2be0640622 | |||
| 0e96223bf6 | |||
| d8b33923d5 | |||
| 4ce515be1c | |||
| f88bf03939 | |||
| 7cd4a91350 | |||
| d907ca37ad | |||
| 6c6ed22dbe | |||
| 00514f0525 | |||
| cf1d831b5a | |||
| fd37183400 | |||
| 5fdeb57b74 | |||
| 1576383d09 | |||
| 8503315bef | |||
| 928263fbd8 | |||
| 7e33f7db6a | |||
| e74ef6d64f | |||
| 3ed435824c | |||
| d7fabf58a4 | |||
| a7e921805a | |||
| c56739c5fa | |||
| fd52e40d17 | |||
| f48c8789c7 | |||
| 80ff216e54 | |||
| 1fb3dee942 | |||
| a4502055fb | |||
| 6e61ec8de6 | |||
| 48d02f0e70 | |||
| cacaa3505e |
26
.cursor/rules/commit.mdc
Normal file
26
.cursor/rules/commit.mdc
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
description: Git commit messages and how to split work into commits
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Commits
|
||||||
|
|
||||||
|
When preparing commits (especially when the user asks to commit):
|
||||||
|
|
||||||
|
1. **Prefer multiple commits** over one large commit when changes span distinct concerns (e.g. UI vs docs vs API). One logical unit per commit.
|
||||||
|
2. **Message format:** `type(scope): short imperative subject` (lowercase subject after the colon; no trailing period).
|
||||||
|
- **Types:** `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf` (use what fits).
|
||||||
|
- **Scope:** optional but encouraged — e.g. `ui`, `api`, `profiles`, `presets`, `esp32`.
|
||||||
|
3. **Subject line:** ~50 characters or less; describe *what* changed, not the ticket number alone.
|
||||||
|
4. **Body:** only when needed (breaking change, non-obvious rationale, or multiple bullets). Otherwise subject is enough.
|
||||||
|
|
||||||
|
**Examples**
|
||||||
|
|
||||||
|
- `feat(ui): gate profile delete to edit mode`
|
||||||
|
- `docs: document run vs edit in API`
|
||||||
|
- `fix(api): resolve preset delete route argument clash`
|
||||||
|
|
||||||
|
**Do not**
|
||||||
|
|
||||||
|
- Squash unrelated fixes and doc tweaks into one commit unless the user explicitly wants a single commit.
|
||||||
|
- Use vague messages like `update`, `fixes`, or `wip`.
|
||||||
10
.cursor/rules/spelling.mdc
Normal file
10
.cursor/rules/spelling.mdc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: British spelling for user-facing text; technical identifiers stay as-is
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spelling: colour
|
||||||
|
|
||||||
|
- **User-facing strings** (Help modal, button labels, README prose, `docs/`, error messages shown in the UI): use **British English** — **colour**, **favour**, **behaviour**, etc., unless quoting existing product names.
|
||||||
|
- **Do not rename** existing code for spelling: **identifiers**, file names, URL paths, JSON keys, CSS properties (`color`), HTML attributes (`type="color"`), and API field names stay as they are (`color`, `colors`, `palette`, etc.) so nothing breaks.
|
||||||
|
- **New** UI copy and docs should follow **colour** in prose; new code symbols may still use `color` when matching surrounding APIs or conventions.
|
||||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
docs/.help-print.html
|
||||||
|
settings.json
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "led-driver"]
|
||||||
|
path = led-driver
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-driver.git
|
||||||
|
[submodule "led-tool"]
|
||||||
|
path = led-tool
|
||||||
|
url = git@git.technical.kiwi:technicalkiwi/led-tool.git
|
||||||
11
Pipfile
11
Pipfile
@@ -9,12 +9,21 @@ pyserial = "*"
|
|||||||
esptool = "*"
|
esptool = "*"
|
||||||
pyjwt = "*"
|
pyjwt = "*"
|
||||||
watchfiles = "*"
|
watchfiles = "*"
|
||||||
|
requests = "*"
|
||||||
|
selenium = "*"
|
||||||
|
adafruit-ampy = "*"
|
||||||
|
microdot = "*"
|
||||||
|
|
||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
pytest = "*"
|
||||||
|
|
||||||
[requires]
|
[requires]
|
||||||
python_version = "3.12"
|
python_version = "3.12"
|
||||||
|
|
||||||
[scripts]
|
[scripts]
|
||||||
web = "python /home/pi/led-controller/tests/web.py"
|
web = "python /home/pi/led-controller/tests/web.py"
|
||||||
watch = "python -m watchfiles 'python /home/pi/led-controller/tests/web.py' /home/pi/led-controller/src /home/pi/led-controller/tests"
|
watch = "python -m watchfiles 'python tests/web.py' src tests"
|
||||||
|
install = "pipenv install"
|
||||||
|
run = "sh -c 'cd src && python main.py'"
|
||||||
|
dev = "watchfiles \"sh -c 'cd src && python main.py'\" src"
|
||||||
|
help-pdf = "sh scripts/build_help_pdf.sh"
|
||||||
|
|||||||
505
Pipfile.lock
generated
505
Pipfile.lock
generated
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"hash": {
|
"hash": {
|
||||||
"sha256": "24a0e63d49a769fb2bbc35d7d361aeb0c8563f2d65cbeb24acfae9e183d1c0ca"
|
"sha256": "6cec0fe6dec67c9177363a558131f333153b6caa47e1ddeca303cb0d19954cf8"
|
||||||
},
|
},
|
||||||
"pipfile-spec": 6,
|
"pipfile-spec": 6,
|
||||||
"requires": {
|
"requires": {
|
||||||
@@ -16,13 +16,29 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"default": {
|
"default": {
|
||||||
|
"adafruit-ampy": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4a74812226e53c17d01eb828633424bc4f4fe76b9499a7b35eba6fc2532635b7",
|
||||||
|
"sha256:f4cba36f564096f2aafd173f7fbabb845365cc3bb3f41c37541edf98b58d3976"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==1.1.0"
|
||||||
|
},
|
||||||
"anyio": {
|
"anyio": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703",
|
"sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708",
|
||||||
"sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"
|
"sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==4.13.0"
|
||||||
|
},
|
||||||
|
"attrs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309",
|
||||||
|
"sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.9'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==4.12.1"
|
"version": "==26.1.0"
|
||||||
},
|
},
|
||||||
"bitarray": {
|
"bitarray": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -135,11 +151,19 @@
|
|||||||
},
|
},
|
||||||
"bitstring": {
|
"bitstring": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:69d1587f0ac18dc7d93fc7e80d5f447161a33e57027e726dc18a0a8bacf1711a",
|
"sha256:e682ac522bb63e041d16cbc9d0ca86a4f00194db16d0847c7efe066f836b2e37",
|
||||||
"sha256:a08bc09d3857216d4c0f412a1611056f1cc2b64fd254fb1e8a0afba7cfa1a95a"
|
"sha256:feac49524fcf3ef27e6081e86f02b10d2adf6c3773bf22fbe0e7eea9534bc737"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==4.3.1"
|
"version": "==4.4.0"
|
||||||
|
},
|
||||||
|
"certifi": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa",
|
||||||
|
"sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==2026.2.25"
|
||||||
},
|
},
|
||||||
"cffi": {
|
"cffi": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -231,6 +255,141 @@
|
|||||||
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
|
"markers": "python_full_version >= '3.9' and platform_python_implementation != 'PyPy'",
|
||||||
"version": "==2.0.0"
|
"version": "==2.0.0"
|
||||||
},
|
},
|
||||||
|
"charset-normalizer": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e",
|
||||||
|
"sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c",
|
||||||
|
"sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5",
|
||||||
|
"sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815",
|
||||||
|
"sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f",
|
||||||
|
"sha256:150b8ce8e830eb7ccb029ec9ca36022f756986aaaa7956aad6d9ec90089338c0",
|
||||||
|
"sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484",
|
||||||
|
"sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407",
|
||||||
|
"sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6",
|
||||||
|
"sha256:1cf0a70018692f85172348fe06d3a4b63f94ecb055e13a00c644d368eb82e5b8",
|
||||||
|
"sha256:1ed80ff870ca6de33f4d953fda4d55654b9a2b340ff39ab32fa3adbcd718f264",
|
||||||
|
"sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815",
|
||||||
|
"sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2",
|
||||||
|
"sha256:259695e2ccc253feb2a016303543d691825e920917e31f894ca1a687982b1de4",
|
||||||
|
"sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579",
|
||||||
|
"sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f",
|
||||||
|
"sha256:2bd9d128ef93637a5d7a6af25363cf5dec3fa21cf80e68055aad627f280e8afa",
|
||||||
|
"sha256:2e1d8ca8611099001949d1cdfaefc510cf0f212484fe7c565f735b68c78c3c95",
|
||||||
|
"sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab",
|
||||||
|
"sha256:2f7fdd9b6e6c529d6a2501a2d36b240109e78a8ceaef5687cfcfa2bbe671d297",
|
||||||
|
"sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a",
|
||||||
|
"sha256:31215157227939b4fb3d740cd23fe27be0439afef67b785a1eb78a3ae69cba9e",
|
||||||
|
"sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84",
|
||||||
|
"sha256:3516bbb8d42169de9e61b8520cbeeeb716f12f4ecfe3fd30a9919aa16c806ca8",
|
||||||
|
"sha256:3778fd7d7cd04ae8f54651f4a7a0bd6e39a0cf20f801720a4c21d80e9b7ad6b0",
|
||||||
|
"sha256:39f5068d35621da2881271e5c3205125cc456f54e9030d3f723288c873a71bf9",
|
||||||
|
"sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f",
|
||||||
|
"sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1",
|
||||||
|
"sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843",
|
||||||
|
"sha256:4482481cb0572180b6fd976a4d5c72a30263e98564da68b86ec91f0fe35e8565",
|
||||||
|
"sha256:461598cd852bfa5a61b09cae2b1c02e2efcd166ee5516e243d540ac24bfa68a7",
|
||||||
|
"sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c",
|
||||||
|
"sha256:48696db7f18afb80a068821504296eb0787d9ce239b91ca15059d1d3eaacf13b",
|
||||||
|
"sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7",
|
||||||
|
"sha256:4d1d02209e06550bdaef34af58e041ad71b88e624f5d825519da3a3308e22687",
|
||||||
|
"sha256:4f41da960b196ea355357285ad1316a00099f22d0929fe168343b99b254729c9",
|
||||||
|
"sha256:517ad0e93394ac532745129ceabdf2696b609ec9f87863d337140317ebce1c14",
|
||||||
|
"sha256:51fb3c322c81d20567019778cb5a4a6f2dc1c200b886bc0d636238e364848c89",
|
||||||
|
"sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f",
|
||||||
|
"sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0",
|
||||||
|
"sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9",
|
||||||
|
"sha256:54fae94be3d75f3e573c9a1b5402dc593de19377013c9a0e4285e3d402dd3a2a",
|
||||||
|
"sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389",
|
||||||
|
"sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0",
|
||||||
|
"sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30",
|
||||||
|
"sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd",
|
||||||
|
"sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e",
|
||||||
|
"sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9",
|
||||||
|
"sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc",
|
||||||
|
"sha256:659a1e1b500fac8f2779dd9e1570464e012f43e580371470b45277a27baa7532",
|
||||||
|
"sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d",
|
||||||
|
"sha256:69dd852c2f0ad631b8b60cfbe25a28c0058a894de5abb566619c205ce0550eae",
|
||||||
|
"sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2",
|
||||||
|
"sha256:71be7e0e01753a89cf024abf7ecb6bca2c81738ead80d43004d9b5e3f1244e64",
|
||||||
|
"sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f",
|
||||||
|
"sha256:74a2e659c7ecbc73562e2a15e05039f1e22c75b7c7618b4b574a3ea9118d1557",
|
||||||
|
"sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e",
|
||||||
|
"sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff",
|
||||||
|
"sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398",
|
||||||
|
"sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db",
|
||||||
|
"sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a",
|
||||||
|
"sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43",
|
||||||
|
"sha256:802168e03fba8bbc5ce0d866d589e4b1ca751d06edee69f7f3a19c5a9fe6b597",
|
||||||
|
"sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c",
|
||||||
|
"sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e",
|
||||||
|
"sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2",
|
||||||
|
"sha256:8761ac29b6c81574724322a554605608a9960769ea83d2c73e396f3df896ad54",
|
||||||
|
"sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e",
|
||||||
|
"sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4",
|
||||||
|
"sha256:8bc5f0687d796c05b1e28ab0d38a50e6309906ee09375dd3aff6a9c09dd6e8f4",
|
||||||
|
"sha256:8bea55c4eef25b0b19a0337dc4e3f9a15b00d569c77211fa8cde38684f234fb7",
|
||||||
|
"sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6",
|
||||||
|
"sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5",
|
||||||
|
"sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194",
|
||||||
|
"sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69",
|
||||||
|
"sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f",
|
||||||
|
"sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316",
|
||||||
|
"sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e",
|
||||||
|
"sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73",
|
||||||
|
"sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8",
|
||||||
|
"sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923",
|
||||||
|
"sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88",
|
||||||
|
"sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f",
|
||||||
|
"sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21",
|
||||||
|
"sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4",
|
||||||
|
"sha256:aa9cccf4a44b9b62d8ba8b4dd06c649ba683e4bf04eea606d2e94cfc2d6ff4d6",
|
||||||
|
"sha256:ab30e5e3e706e3063bc6de96b118688cb10396b70bb9864a430f67df98c61ecc",
|
||||||
|
"sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2",
|
||||||
|
"sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866",
|
||||||
|
"sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021",
|
||||||
|
"sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2",
|
||||||
|
"sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d",
|
||||||
|
"sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8",
|
||||||
|
"sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de",
|
||||||
|
"sha256:bf625105bb9eef28a56a943fec8c8a98aeb80e7d7db99bd3c388137e6eb2d237",
|
||||||
|
"sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4",
|
||||||
|
"sha256:c45a03a4c69820a399f1dda9e1d8fbf3562eda46e7720458180302021b08f778",
|
||||||
|
"sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb",
|
||||||
|
"sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc",
|
||||||
|
"sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602",
|
||||||
|
"sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4",
|
||||||
|
"sha256:d08ec48f0a1c48d75d0356cea971921848fb620fdeba805b28f937e90691209f",
|
||||||
|
"sha256:d1a2ee9c1499fc8f86f4521f27a973c914b211ffa87322f4ee33bb35392da2c5",
|
||||||
|
"sha256:d5f5d1e9def3405f60e3ca8232d56f35c98fb7bf581efcc60051ebf53cb8b611",
|
||||||
|
"sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8",
|
||||||
|
"sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf",
|
||||||
|
"sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d",
|
||||||
|
"sha256:dad6e0f2e481fffdcf776d10ebee25e0ef89f16d691f1e5dee4b586375fdc64b",
|
||||||
|
"sha256:dda86aba335c902b6149a02a55b38e96287157e609200811837678214ba2b1db",
|
||||||
|
"sha256:df01808ee470038c3f8dc4f48620df7225c49c2d6639e38f96e6d6ac6e6f7b0e",
|
||||||
|
"sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077",
|
||||||
|
"sha256:e25369dc110d58ddf29b949377a93e0716d72a24f62bad72b2b39f155949c1fd",
|
||||||
|
"sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef",
|
||||||
|
"sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e",
|
||||||
|
"sha256:e68c14b04827dd76dcbd1aeea9e604e3e4b78322d8faf2f8132c7138efa340a8",
|
||||||
|
"sha256:e8aeb10fcbe92767f0fa69ad5a72deca50d0dca07fbde97848997d778a50c9fe",
|
||||||
|
"sha256:e985a16ff513596f217cee86c21371b8cd011c0f6f056d0920aa2d926c544058",
|
||||||
|
"sha256:ecbbd45615a6885fe3240eb9db73b9e62518b611850fdf8ab08bd56de7ad2b17",
|
||||||
|
"sha256:ee4ec14bc1680d6b0afab9aea2ef27e26d2024f18b24a2d7155a52b60da7e833",
|
||||||
|
"sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421",
|
||||||
|
"sha256:f0cdaecd4c953bfae0b6bb64910aaaca5a424ad9c72d85cb88417bb9814f7550",
|
||||||
|
"sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff",
|
||||||
|
"sha256:f50498891691e0864dc3da965f340fada0771f6142a378083dc4608f4ea513e2",
|
||||||
|
"sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc",
|
||||||
|
"sha256:f61aa92e4aad0be58eb6eb4e0c21acf32cf8065f4b2cae5665da756c4ceef982",
|
||||||
|
"sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d",
|
||||||
|
"sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed",
|
||||||
|
"sha256:f98059e4fcd3e3e4e2d632b7cf81c2faae96c43c60b569e9c621468082f1d104",
|
||||||
|
"sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==3.4.6"
|
||||||
|
},
|
||||||
"click": {
|
"click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
"sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a",
|
||||||
@@ -241,70 +400,73 @@
|
|||||||
},
|
},
|
||||||
"cryptography": {
|
"cryptography": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217",
|
"sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72",
|
||||||
"sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d",
|
"sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235",
|
||||||
"sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc",
|
"sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9",
|
||||||
"sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71",
|
"sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356",
|
||||||
"sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971",
|
"sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257",
|
||||||
"sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a",
|
"sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad",
|
||||||
"sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926",
|
"sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4",
|
||||||
"sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc",
|
"sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c",
|
||||||
"sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d",
|
"sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614",
|
||||||
"sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b",
|
"sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed",
|
||||||
"sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20",
|
"sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31",
|
||||||
"sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044",
|
"sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229",
|
||||||
"sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3",
|
"sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0",
|
||||||
"sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715",
|
"sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731",
|
||||||
"sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4",
|
"sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b",
|
||||||
"sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506",
|
"sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4",
|
||||||
"sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f",
|
"sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4",
|
||||||
"sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0",
|
"sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263",
|
||||||
"sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683",
|
"sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595",
|
||||||
"sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3",
|
"sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1",
|
||||||
"sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21",
|
"sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678",
|
||||||
"sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91",
|
"sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48",
|
||||||
"sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c",
|
"sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76",
|
||||||
"sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8",
|
"sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0",
|
||||||
"sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df",
|
"sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18",
|
||||||
"sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c",
|
"sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d",
|
||||||
"sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb",
|
"sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d",
|
||||||
"sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7",
|
"sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1",
|
||||||
"sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04",
|
"sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981",
|
||||||
"sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db",
|
"sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7",
|
||||||
"sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459",
|
"sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82",
|
||||||
"sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea",
|
"sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2",
|
||||||
"sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914",
|
"sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4",
|
||||||
"sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717",
|
"sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663",
|
||||||
"sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9",
|
"sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c",
|
||||||
"sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac",
|
"sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d",
|
||||||
"sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32",
|
"sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a",
|
||||||
"sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec",
|
"sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a",
|
||||||
"sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1",
|
"sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d",
|
||||||
"sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb",
|
"sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b",
|
||||||
"sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac",
|
"sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a",
|
||||||
"sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665",
|
"sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826",
|
||||||
"sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e",
|
"sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee",
|
||||||
"sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb",
|
"sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9",
|
||||||
"sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5",
|
"sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648",
|
||||||
"sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936",
|
"sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da",
|
||||||
"sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de",
|
"sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2",
|
||||||
"sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372",
|
"sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2",
|
||||||
"sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54",
|
"sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"
|
||||||
"sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422",
|
|
||||||
"sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849",
|
|
||||||
"sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c",
|
|
||||||
"sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963",
|
|
||||||
"sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"
|
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
"markers": "python_version >= '3.8' and python_full_version not in '3.9.0, 3.9.1'",
|
||||||
"version": "==46.0.3"
|
"version": "==46.0.5"
|
||||||
},
|
},
|
||||||
"esptool": {
|
"esptool": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:2ea9bcd7eb263d380a4fe0170856a10e4c65e3f38c757ebdc73584c8dd8322da"
|
"sha256:9c355b7d6331cc92979cc710ae5c41f59830d1ea29ec24c467c6005a092c06d6"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==5.1.0"
|
"version": "==5.2.0"
|
||||||
|
},
|
||||||
|
"h11": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
|
||||||
|
"sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.16.0"
|
||||||
},
|
},
|
||||||
"idna": {
|
"idna": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -337,6 +499,14 @@
|
|||||||
"markers": "python_version >= '3.7'",
|
"markers": "python_version >= '3.7'",
|
||||||
"version": "==0.1.2"
|
"version": "==0.1.2"
|
||||||
},
|
},
|
||||||
|
"microdot": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:363e3ebfc80b7e0415779848c9332e4e7fb7bd365ee54d3620abffe42ed82946",
|
||||||
|
"sha256:abfb82ca31cc430174e4761cc7356adc4bff00ea758d437c2b258883dc63f464"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.6.0"
|
||||||
|
},
|
||||||
"mpremote": {
|
"mpremote": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
"sha256:11d134c69b21b487dae3d03eed54c8ccbf84c916c8732a3e069a97cae47be3d4",
|
||||||
@@ -345,21 +515,29 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.27.0"
|
"version": "==1.27.0"
|
||||||
},
|
},
|
||||||
|
"outcome": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:9dcf02e65f2971b80047b377468e72a268e15c0af3cf1238e6ff14f7f91143b8",
|
||||||
|
"sha256:e771c5ce06d1415e356078d3bdd68523f284b4ce5419828922b6871e65eda82b"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==1.3.0.post0"
|
||||||
|
},
|
||||||
"platformdirs": {
|
"platformdirs": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda",
|
"sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934",
|
||||||
"sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31"
|
"sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.10'",
|
"markers": "python_version >= '3.10'",
|
||||||
"version": "==4.5.1"
|
"version": "==4.9.4"
|
||||||
},
|
},
|
||||||
"pycparser": {
|
"pycparser": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
"sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29",
|
||||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
"sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"
|
||||||
],
|
],
|
||||||
"markers": "implementation_name != 'PyPy'",
|
"markers": "implementation_name != 'PyPy'",
|
||||||
"version": "==2.23"
|
"version": "==3.0"
|
||||||
},
|
},
|
||||||
"pygments": {
|
"pygments": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -371,11 +549,11 @@
|
|||||||
},
|
},
|
||||||
"pyjwt": {
|
"pyjwt": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953",
|
"sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c",
|
||||||
"sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"
|
"sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"
|
||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==2.10.1"
|
"version": "==2.12.1"
|
||||||
},
|
},
|
||||||
"pyserial": {
|
"pyserial": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
@@ -385,6 +563,22 @@
|
|||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==3.5"
|
"version": "==3.5"
|
||||||
},
|
},
|
||||||
|
"pysocks": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
|
||||||
|
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
|
||||||
|
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
|
||||||
|
],
|
||||||
|
"version": "==1.7.1"
|
||||||
|
},
|
||||||
|
"python-dotenv": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a",
|
||||||
|
"sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==1.2.2"
|
||||||
|
},
|
||||||
"pyyaml": {
|
"pyyaml": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||||
@@ -471,30 +665,122 @@
|
|||||||
],
|
],
|
||||||
"version": "==1.7.0"
|
"version": "==1.7.0"
|
||||||
},
|
},
|
||||||
|
"requests": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||||
|
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==2.32.5"
|
||||||
|
},
|
||||||
"rich": {
|
"rich": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4",
|
"sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d",
|
||||||
"sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"
|
"sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b"
|
||||||
],
|
],
|
||||||
"markers": "python_full_version >= '3.8.0'",
|
"markers": "python_full_version >= '3.8.0'",
|
||||||
"version": "==14.2.0"
|
"version": "==14.3.3"
|
||||||
},
|
},
|
||||||
"rich-click": {
|
"rich-click": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:48120531493f1533828da80e13e839d471979ec8d7d0ca7b35f86a1379cc74b6",
|
"sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc",
|
||||||
"sha256:9b195721a773b1acf0e16ff9ec68cef1e7d237e53471e6e3f7ade462f86c403a"
|
"sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b"
|
||||||
],
|
],
|
||||||
"markers": "python_version >= '3.8'",
|
"markers": "python_version >= '3.8'",
|
||||||
"version": "==1.9.5"
|
"version": "==1.9.7"
|
||||||
|
},
|
||||||
|
"selenium": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:003e971f805231ad63e671783a2b91a299355d10cefb9de964c36ff3819115aa",
|
||||||
|
"sha256:b8ccde8d2e7642221ca64af184a92c19eee6accf2e27f20f30472f5efae18eb1"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==4.41.0"
|
||||||
|
},
|
||||||
|
"sniffio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2",
|
||||||
|
"sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.7'",
|
||||||
|
"version": "==1.3.1"
|
||||||
|
},
|
||||||
|
"sortedcontainers": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88",
|
||||||
|
"sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"
|
||||||
|
],
|
||||||
|
"version": "==2.4.0"
|
||||||
|
},
|
||||||
|
"tibs": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:01ea5258bdf942d21560dc07d532082cd04f07cfef65fedd58ae84f7d0d2562a",
|
||||||
|
"sha256:0a7ce857ef05c59dc61abadc31c4b9b1e3c62f9e5fb29217988c308936aea71e",
|
||||||
|
"sha256:130bc68ff500fc8185677df7a97350b5d5339e6ba7e325bc3031337f6424ede7",
|
||||||
|
"sha256:173dfbecb2309edd9771f453580c88cf251e775613461566b23dbd756b3d54cb",
|
||||||
|
"sha256:1906729038b85c3b4c040aa28a456d85bc976d0c5007177350eb73374ffa0fd0",
|
||||||
|
"sha256:1b56583db148e5094d781c3d746815dbcbb6378c6f813c8ce291efd4ab21da8b",
|
||||||
|
"sha256:1d5521cc6768bfa6282a0c591ba06b079ab91b5c7d5696925ad2abac59779a54",
|
||||||
|
"sha256:1f95d5db62960205a1e9eba73ce67dc14e7366ae080cd4e5b6f005ebd90faf02",
|
||||||
|
"sha256:29480bf03e3372a5f9cc59ea0541f76f8efd696d4f0d214715e94247c342a037",
|
||||||
|
"sha256:2a618de62004d9217d2d2ab0f7f9bbdd098c12642dc01f07b3fb00f0b5f3131a",
|
||||||
|
"sha256:42725200f1b02687ed6e6a1c01e0ec150dc829d21d901ffc74cc0ac4d821f57f",
|
||||||
|
"sha256:477608f9b87e24a22ab6d50b81da04a5cb59bfa49598ff7ec5165035a18fb392",
|
||||||
|
"sha256:4b7510235379368b7523f624d46e0680f3706e3a3965877a6583cdcb598b8bac",
|
||||||
|
"sha256:501728d096e10d9a165aa526743d47418a6bbfd7b084fa47ecb22be7641d3edb",
|
||||||
|
"sha256:63255749f937c5e6fedcc7d54e7bd359aef711017e6855f373b0510a14ee2215",
|
||||||
|
"sha256:6a9feed5931b881809a950eca0e01e757113e2383a2af06a3e6982f110c869e2",
|
||||||
|
"sha256:76746f01b3db9dbd802f5e615f11f68df7a29ecef521b082dca53f3fa7d0084f",
|
||||||
|
"sha256:77103a9f1af72ac4cf5006828d0fb21578d19ce55fd990e9a1c8e46fd549561f",
|
||||||
|
"sha256:7d6592ed93c6748acd39df484c1ee24d40ee247c2a20ca38ba03363506fd24f3",
|
||||||
|
"sha256:847709c108800ad6a45efaf9a040628278956938a4897f7427a2587013dc3b98",
|
||||||
|
"sha256:859f05315ffb307d3474c505d694f3a547f00730a024c982f5f60316a5505b3c",
|
||||||
|
"sha256:a61d36155f8ab8642e1b6744e13822f72050fc7ec4f86ec6965295afa04949e2",
|
||||||
|
"sha256:a883ca13a922a66b2c1326a9c188123a574741a72510a4bf52fd6f97db191e44",
|
||||||
|
"sha256:ac0aa2aae38f7325c91c261ce1d18f769c4c7033c98d6ea3ea5534585cf16452",
|
||||||
|
"sha256:ace018a057459e3dccd06a4aae1c5c8cd57e352b263dcef534ae39bf3e03b5cf",
|
||||||
|
"sha256:ad61df93b50f875b277ab736c5d37b6bce56f9abce489a22f4e02d9daa2966e3",
|
||||||
|
"sha256:b9535dc7b7484904a58b51bd8e64da7efbf1d8466ff7e84ed1d78f4ddc561c99",
|
||||||
|
"sha256:d4f3ff613d486650816bc5516760c0382a2cc0ca8aeddd8914d011bc3b81d9a2",
|
||||||
|
"sha256:e13b9c7ff2604b0146772025e1ac6f85c8c625bf6ac73736ff671eaf357dda41",
|
||||||
|
"sha256:f5eea45851c960628a2bd29847765d55e19a687c5374456ad2c8cf6410eb1efa",
|
||||||
|
"sha256:f70bd250769381c73110d6f24feaf8b6fcd44f680b3cb28a20ea06db3d04fb6f"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.5.7"
|
||||||
|
},
|
||||||
|
"trio": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:3bd5d87f781d9b0192d592aef28691f8951d6c2e41b7e1da4c25cde6c180ae9b",
|
||||||
|
"sha256:a29b92b73f09d4b48ed249acd91073281a7f1063f09caba5dc70465b5c7aa970"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==0.33.0"
|
||||||
|
},
|
||||||
|
"trio-websocket": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:22c72c436f3d1e264d0910a3951934798dcc5b00ae56fc4ee079d46c7cf20fae",
|
||||||
|
"sha256:df605665f1db533f4a386c94525870851096a223adcb97f72a07e8b4beba45b6"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==0.12.2"
|
||||||
},
|
},
|
||||||
"typing-extensions": {
|
"typing-extensions": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||||
],
|
],
|
||||||
"markers": "python_version < '3.13'",
|
"markers": "python_version >= '3.9'",
|
||||||
"version": "==4.15.0"
|
"version": "==4.15.0"
|
||||||
},
|
},
|
||||||
|
"urllib3": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed",
|
||||||
|
"sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==2.6.3"
|
||||||
|
},
|
||||||
"watchfiles": {
|
"watchfiles": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
"sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c",
|
||||||
@@ -609,7 +895,64 @@
|
|||||||
],
|
],
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==1.1.1"
|
"version": "==1.1.1"
|
||||||
|
},
|
||||||
|
"websocket-client": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98",
|
||||||
|
"sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==1.9.0"
|
||||||
|
},
|
||||||
|
"wsproto": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584",
|
||||||
|
"sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==1.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"develop": {}
|
"develop": {
|
||||||
|
"iniconfig": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730",
|
||||||
|
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.10'",
|
||||||
|
"version": "==2.3.0"
|
||||||
|
},
|
||||||
|
"packaging": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4",
|
||||||
|
"sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==26.0"
|
||||||
|
},
|
||||||
|
"pluggy": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
|
||||||
|
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.9'",
|
||||||
|
"version": "==1.6.0"
|
||||||
|
},
|
||||||
|
"pygments": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887",
|
||||||
|
"sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.8'",
|
||||||
|
"version": "==2.19.2"
|
||||||
|
},
|
||||||
|
"pytest": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b",
|
||||||
|
"sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==9.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -1,2 +1,37 @@
|
|||||||
# led-controller
|
# led-controller
|
||||||
|
|
||||||
|
LED controller web app for managing profiles, tabs, presets, and colour palettes, and sending commands to LED devices over the serial -> ESP-NOW bridge.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
- One-time setup for port 80 without root: `sudo scripts/setup-port80.sh`
|
||||||
|
- Start app: `pipenv run run`
|
||||||
|
- Dev watcher (auto-restart on `src/` changes): `pipenv run dev`
|
||||||
|
- Regenerate **`docs/help.pdf`** from **`docs/help.md`**: `pipenv run help-pdf` (requires **pandoc** and **chromium** on the host)
|
||||||
|
|
||||||
|
## UI modes
|
||||||
|
|
||||||
|
- **Run mode**: focused control view. Select tabs/presets and apply profiles. Editing actions are hidden.
|
||||||
|
- **Edit mode**: management view. Shows Tabs, Presets, Patterns, Colour Palette, and Send Presets controls, plus per-tile preset edit/remove and drag-reorder.
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
- Applying a profile updates session scope and refreshes the active tab content.
|
||||||
|
- In **Run mode**, Profiles supports apply-only behavior (no create/clone/delete).
|
||||||
|
- In **Edit mode**, Profiles supports create/clone/delete.
|
||||||
|
- Creating a profile always creates a populated `default` tab (starter presets).
|
||||||
|
- Optional **DJ tab** seeding creates:
|
||||||
|
- `dj` tab bound to device name `dj`
|
||||||
|
- starter DJ presets (rainbow, single colour, transition)
|
||||||
|
|
||||||
|
## Preset colours and palette linking
|
||||||
|
|
||||||
|
- In preset editor, selecting a colour picker value auto-adds it when the picker closes.
|
||||||
|
- Use **From Palette** to add a palette-linked preset colour.
|
||||||
|
- Linked colours are stored as palette references and shown with a `P` badge.
|
||||||
|
- When profile palette colours change, linked preset colours update across that profile.
|
||||||
|
|
||||||
|
## API docs
|
||||||
|
|
||||||
|
- Main API reference: `docs/API.md`
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
rm -f /home/pi/led-controller/.cursor/debug.log
|
|
||||||
1
db/device.json
Normal file
1
db/device.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -1,17 +1 @@
|
|||||||
{
|
{"1": {"name": "Main Group", "devices": ["1", "2", "3"]}, "2": {"name": "Accent Group", "devices": ["4", "5"]}}
|
||||||
"1": {
|
|
||||||
"name": "Main Group",
|
|
||||||
"devices": [
|
|
||||||
"1",
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "Accent Group",
|
|
||||||
"devices": [
|
|
||||||
"4",
|
|
||||||
"5"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +1 @@
|
|||||||
{
|
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]}
|
||||||
"1": {
|
|
||||||
"name": "Default Colors",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#FFFF00",
|
|
||||||
"#FF00FF",
|
|
||||||
"#00FFFF",
|
|
||||||
"#FFFFFF",
|
|
||||||
"#000000",
|
|
||||||
"#FFA500",
|
|
||||||
"#800080"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "Warm Colors",
|
|
||||||
"colors": [
|
|
||||||
"#FF6B6B",
|
|
||||||
"#FF8E53",
|
|
||||||
"#FFA07A",
|
|
||||||
"#FFD700",
|
|
||||||
"#FFA500",
|
|
||||||
"#FF6347"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"name": "Cool Colors",
|
|
||||||
"colors": [
|
|
||||||
"#4ECDC4",
|
|
||||||
"#44A08D",
|
|
||||||
"#96CEB4",
|
|
||||||
"#A8E6CF",
|
|
||||||
"#5F9EA0",
|
|
||||||
"#4682B4"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +1 @@
|
|||||||
{
|
{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFFFF", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 5000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, 6, 2, 3]}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 200, "auto": true, "n1": 5, "n2": 5, "n3": 1, "n4": 1, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 1000, "n2": 500, "n3": 1000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "rainbow", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "rainbow", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}}
|
||||||
"1": {
|
|
||||||
"name": "Warm White",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": [
|
|
||||||
"#FFE5B4",
|
|
||||||
"#FFDAB9",
|
|
||||||
"#FFE4B5"
|
|
||||||
],
|
|
||||||
"brightness": 200,
|
|
||||||
"delay": 100,
|
|
||||||
"n1": 10,
|
|
||||||
"n2": 10,
|
|
||||||
"n3": 10,
|
|
||||||
"n4": 10,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"name": "Rainbow",
|
|
||||||
"pattern": "rainbow",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#FF7F00",
|
|
||||||
"#FFFF00",
|
|
||||||
"#00FF00",
|
|
||||||
"#0000FF",
|
|
||||||
"#4B0082",
|
|
||||||
"#9400D3"
|
|
||||||
],
|
|
||||||
"brightness": 255,
|
|
||||||
"delay": 50,
|
|
||||||
"n1": 20,
|
|
||||||
"n2": 15,
|
|
||||||
"n3": 10,
|
|
||||||
"n4": 5,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"name": "Pulse Red",
|
|
||||||
"pattern": "pulse",
|
|
||||||
"colors": [
|
|
||||||
"#FF0000",
|
|
||||||
"#CC0000",
|
|
||||||
"#990000"
|
|
||||||
],
|
|
||||||
"brightness": 180,
|
|
||||||
"delay": 200,
|
|
||||||
"n1": 30,
|
|
||||||
"n2": 20,
|
|
||||||
"n3": 10,
|
|
||||||
"n4": 5,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "Default", "tabs": ["1", "2"], "scenes": ["1", "2"], "palette": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"]}, "2": {"name": "test", "type": "tabs", "tabs": ["12", "13"], "scenes": [], "palette": ["#b93c3c", "#3cb961"], "color_palette": ["#b93c3c", "#3cb961"]}}
|
{"1": {"name": "default", "type": "tabs", "tabs": ["1", "8"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "tabs", "tabs": ["6", "7"], "scenes": [], "palette_id": "12"}}
|
||||||
@@ -1,30 +1 @@
|
|||||||
{
|
{"1": {"group_name": "Main Group", "presets": ["1", "2"], "sequence_duration": 3000, "sequence_transition": 500, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}, "2": {"group_name": "Accent Group", "presets": ["2", "3"], "sequence_duration": 2000, "sequence_transition": 300, "sequence_loop": true, "sequence_repeat_count": 0, "sequence_active": false, "sequence_index": 0, "sequence_start_time": 0}}
|
||||||
"1": {
|
|
||||||
"group_name": "Main Group",
|
|
||||||
"presets": [
|
|
||||||
"1",
|
|
||||||
"2"
|
|
||||||
],
|
|
||||||
"sequence_duration": 3000,
|
|
||||||
"sequence_transition": 500,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
},
|
|
||||||
"2": {
|
|
||||||
"group_name": "Accent Group",
|
|
||||||
"presets": [
|
|
||||||
"2",
|
|
||||||
"3"
|
|
||||||
],
|
|
||||||
"sequence_duration": 2000,
|
|
||||||
"sequence_transition": 300,
|
|
||||||
"sequence_loop": true,
|
|
||||||
"sequence_repeat_count": 0,
|
|
||||||
"sequence_active": false,
|
|
||||||
"sequence_index": 0,
|
|
||||||
"sequence_start_time": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
{"1": {"name": "Main", "names": ["1", "2", "3"], "presets": ["1", "2"]}, "2": {"name": "Accent", "names": ["4", "5"], "presets": ["2", "3"]}, "3": {"name": "", "names": [], "presets": []}, "4": {"name": "", "names": [], "presets": []}, "5": {"name": "", "names": [], "presets": []}, "6": {"name": "", "names": [], "presets": []}, "7": {"name": "", "names": [], "presets": []}, "8": {"name": "", "names": [], "presets": []}, "9": {"name": "", "names": [], "presets": []}, "10": {"name": "", "names": [], "presets": []}, "11": {"name": "", "names": [], "presets": []}, "12": {"name": "test2", "names": ["1"], "presets": [], "colors": ["#b93c3c", "#761e1e", "#ffffff"]}, "13": {"name": "test5", "names": ["1"], "presets": []}}
|
{"1": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["4", "2", "7"], ["15", "3", "14"], ["5", "8", "10"], ["11", "9", "12"], ["1", "13", "37"]], "presets_flat": ["4", "2", "7", "15", "3", "14", "5", "8", "10", "11", "9", "12", "1", "13", "37"], "default_preset": "15"}, "2": {"name": "default", "names": ["1", "2", "3", "4", "5", "6", "7", "8", "0", "a"], "presets": [["16", "17", "18"], ["19", "20", "21"], ["22", "23", "24"], ["25", "26", "27"], ["28", "29", "30"]], "presets_flat": ["16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"]}, "3": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "4": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "5": {"name": "dj", "names": ["dj"], "presets": [["31", "32", "33"]], "default_preset": "31", "presets_flat": ["31", "32", "33"]}, "6": {"name": "default", "names": ["1"], "presets": [], "default_preset": null}, "7": {"name": "dj", "names": ["dj"], "presets": [["34", "35", "36"]], "default_preset": "34", "presets_flat": ["34", "35", "36"]}, "8": {"name": "test", "names": ["11"], "presets": [["1", "2", "3"], ["4", "5"]], "default_preset": "1", "presets_flat": ["1", "2", "3", "4", "5"]}}
|
||||||
768
docs/API.md
768
docs/API.md
@@ -1,504 +1,318 @@
|
|||||||
# LED Controller API Specification
|
# LED Controller API
|
||||||
|
|
||||||
**Base URL:** `http://device-ip/` or `http://192.168.4.1/` (when in AP mode)
|
This document covers:
|
||||||
**Protocol:** HTTP/1.1
|
|
||||||
**Content-Type:** `application/json`
|
|
||||||
|
|
||||||
## Presets API
|
1. **HTTP and WebSocket** exposed by the Raspberry Pi app (`src/main.py`) — profiles, presets, transport send, and related resources.
|
||||||
|
2. **LED driver JSON** — the compact message format sent over the serial→ESP-NOW bridge to devices (same logical API as ESP-NOW payloads).
|
||||||
|
|
||||||
### GET /presets
|
Default listen address: `0.0.0.0`. Port defaults to **80**; override with the `PORT` environment variable (see `pipenv run run`).
|
||||||
|
|
||||||
List all presets.
|
All JSON APIs use `Content-Type: application/json` for bodies and responses unless noted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI behavior notes
|
||||||
|
|
||||||
|
The main UI has two modes controlled by the mode toggle:
|
||||||
|
|
||||||
|
- **Run mode**: optimized for operation (tab/preset selection and profile apply).
|
||||||
|
- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions).
|
||||||
|
|
||||||
|
Profiles are available in both modes, but behavior differs:
|
||||||
|
|
||||||
|
- **Run mode**: profile **apply** only.
|
||||||
|
- **Edit mode**: profile **create/clone/delete/apply**.
|
||||||
|
|
||||||
|
`POST /presets/send` is wired to the **Send Presets** UI action, which is exposed in Edit mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session and scoping
|
||||||
|
|
||||||
|
Several routes use **`@with_session`**: the server stores a **current profile** in the session (cookie). Endpoints that scope data to “the current profile” (notably **`/presets`**) only return or mutate presets whose `profile_id` matches that session value.
|
||||||
|
|
||||||
|
Profiles are selected with **`POST /profiles/<id>/apply`**, which sets `current_profile` in the session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Static pages and assets
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/` | Main UI (`templates/index.html`) |
|
||||||
|
| GET | `/settings` | Settings page (`templates/settings.html`) |
|
||||||
|
| GET | `/favicon.ico` | Empty response (204) |
|
||||||
|
| GET | `/static/<path>` | Static files under `src/static/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket: `/ws`
|
||||||
|
|
||||||
|
Connect to **`ws://<host>:<port>/ws`**.
|
||||||
|
|
||||||
|
- Send **JSON**: the object is forwarded to the transport (serial bridge → ESP-NOW) as JSON. Optional key **`to`**: 12-character hex MAC address; if present it is removed from the object and the payload is sent to that peer; otherwise the default destination is used.
|
||||||
|
- Send **non-JSON text**: forwarded as raw bytes with the default address.
|
||||||
|
- On send failure, the server may reply with `{"error": "Send failed"}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP API by resource
|
||||||
|
|
||||||
|
Below, `<id>` values are string identifiers used by the JSON stores (numeric strings in practice).
|
||||||
|
|
||||||
|
### Settings — `/settings`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/settings` | Full settings object (from `settings.json` / `Settings` model). |
|
||||||
|
| 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). |
|
||||||
|
| 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). |
|
||||||
|
|
||||||
|
### Profiles — `/profiles`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/profiles` | `{"profiles": {...}, "current_profile_id": "<id>"}`. Ensures a default current profile when possible. |
|
||||||
|
| GET | `/profiles/current` | `{"id": "...", "profile": {...}}` |
|
||||||
|
| GET | `/profiles/<id>` | Single profile. If `<id>` is `current`, same as `/profiles/current`. |
|
||||||
|
| POST | `/profiles` | Create profile. Body may include `name` and other fields. Optional `seed_dj_tab` (request-only) seeds a DJ tab + presets. New profiles always get a populated `default` tab. Returns `{ "<id>": { ... } }` with status 201. |
|
||||||
|
| POST | `/profiles/<id>/apply` | Sets session current profile to `<id>`. |
|
||||||
|
| POST | `/profiles/<id>/clone` | Clone profile (tabs, palettes, presets). Body may include `name`. |
|
||||||
|
| PUT | `/profiles/current` | Update the current profile (from session). |
|
||||||
|
| PUT | `/profiles/<id>` | Update profile by id. |
|
||||||
|
| DELETE | `/profiles/<id>` | Delete profile. |
|
||||||
|
|
||||||
|
### Presets — `/presets`
|
||||||
|
|
||||||
|
Scoped to **current profile** in session (see above).
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/presets` | Map of preset id → preset object for the current profile only. |
|
||||||
|
| GET | `/presets/<id>` | One preset, 404 if missing or wrong profile. |
|
||||||
|
| POST | `/presets` | Create preset; server assigns id and sets `profile_id`. Body fields stored on the preset. Returns `{ "<id>": { ... } }`, 201. |
|
||||||
|
| PUT | `/presets/<id>` | Update preset (must belong to current profile). |
|
||||||
|
| DELETE | `/presets/<id>` | Delete preset. |
|
||||||
|
| POST | `/presets/send` | Push presets to the LED driver over the configured transport (see below). |
|
||||||
|
|
||||||
|
**`POST /presets/send` body:**
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"preset1": {
|
"preset_ids": ["1", "2"],
|
||||||
"name": "preset1",
|
"save": true,
|
||||||
"pattern": "on",
|
"default": "1",
|
||||||
"colors": [[255, 0, 0]],
|
"destination_mac": "aabbccddeeff"
|
||||||
"delay": 100,
|
}
|
||||||
"n1": 0,
|
```
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
- **`preset_ids`** (or **`ids`**): non-empty list of preset ids to include.
|
||||||
"n4": 0,
|
- **`save`**: if true, the outgoing message includes `"save": true` so the driver may persist presets (default true).
|
||||||
"n5": 0,
|
- **`default`**: optional preset id string; forwarded as top-level `"default"` in the driver message (startup selection on device).
|
||||||
"n6": 0,
|
- **`destination_mac`** (or **`to`**): optional 12-character hex MAC for unicast; omitted uses the transport default (e.g. broadcast).
|
||||||
"n7": 0,
|
|
||||||
"n8": 0
|
Response on success includes `presets_sent`, `messages_sent` (chunking splits payloads so each JSON string stays ≤ 240 bytes).
|
||||||
|
|
||||||
|
Stored preset records can include:
|
||||||
|
|
||||||
|
- `colors`: resolved hex colours for editor/display.
|
||||||
|
- `palette_refs`: optional array of palette indexes parallel to `colors`. If a slot contains an integer index, the colour is linked to the current profile palette at that index.
|
||||||
|
|
||||||
|
### Tabs — `/tabs`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/tabs` | `tabs`, `tab_order`, `current_tab_id`, `profile_id` for the session-backed profile. |
|
||||||
|
| GET | `/tabs/current` | Current tab from cookie/session. |
|
||||||
|
| POST | `/tabs` | Create tab; optional JSON `name`, `names`, `presets`; can append to current profile’s tab list. |
|
||||||
|
| GET | `/tabs/<id>` | Tab JSON. |
|
||||||
|
| PUT | `/tabs/<id>` | Update tab. |
|
||||||
|
| DELETE | `/tabs/<id>` | Delete tab; can delete `current` to remove the active tab; updates profile tab list. |
|
||||||
|
| POST | `/tabs/<id>/set-current` | Sets `current_tab` cookie. |
|
||||||
|
| POST | `/tabs/<id>/clone` | Clone tab into current profile. |
|
||||||
|
|
||||||
|
### Palettes — `/palettes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/palettes` | Map of id → colour list. |
|
||||||
|
| GET | `/palettes/<id>` | `{"colors": [...], "id": "<id>"}` |
|
||||||
|
| POST | `/palettes` | Body may include `colors`. Returns palette object with `id`, 201. |
|
||||||
|
| PUT | `/palettes/<id>` | Update colours (`name` ignored). |
|
||||||
|
| DELETE | `/palettes/<id>` | Delete palette. |
|
||||||
|
|
||||||
|
### Groups — `/groups`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/groups` | All groups. |
|
||||||
|
| GET | `/groups/<id>` | One group. |
|
||||||
|
| POST | `/groups` | Create; optional `name` and fields. |
|
||||||
|
| PUT | `/groups/<id>` | Update. |
|
||||||
|
| DELETE | `/groups/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Scenes — `/scenes`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/scenes` | All scenes. |
|
||||||
|
| GET | `/scenes/<id>` | One scene. |
|
||||||
|
| POST | `/scenes` | Create (body JSON stored on scene). |
|
||||||
|
| PUT | `/scenes/<id>` | Update. |
|
||||||
|
| DELETE | `/scenes/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Sequences — `/sequences`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/sequences` | All sequences. |
|
||||||
|
| GET | `/sequences/<id>` | One sequence. |
|
||||||
|
| POST | `/sequences` | Create; may use `group_name`, `presets` in body. |
|
||||||
|
| PUT | `/sequences/<id>` | Update. |
|
||||||
|
| DELETE | `/sequences/<id>` | Delete. |
|
||||||
|
|
||||||
|
### Patterns — `/patterns`
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/patterns/definitions` | Contents of `pattern.json` (pattern metadata for the UI). |
|
||||||
|
| GET | `/patterns` | All pattern records. |
|
||||||
|
| GET | `/patterns/<id>` | One pattern. |
|
||||||
|
| POST | `/patterns` | Create (`name`, optional `data`). |
|
||||||
|
| PUT | `/patterns/<id>` | Update. |
|
||||||
|
| DELETE | `/patterns/<id>` | Delete. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LED driver message format (transport / ESP-NOW)
|
||||||
|
|
||||||
|
Messages are JSON objects. The Pi **`build_message()`** helper (`src/util/espnow_message.py`) produces the same shape sent over serial and forwarded by the ESP32 bridge.
|
||||||
|
|
||||||
|
### Top-level fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": "1",
|
||||||
|
"presets": { },
|
||||||
|
"select": { },
|
||||||
|
"save": true,
|
||||||
|
"default": "preset_id",
|
||||||
|
"b": 255
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`v`** (required): Must be `"1"` or the driver ignores the message.
|
||||||
|
- **`presets`**: Map of **preset id** (string) → preset object (see below). Optional **`name`** field on each value is accepted for display; the driver keys presets by map key.
|
||||||
|
- **`select`**: Map of **device name** (as in device settings) → `[ "preset_id" ]` or `[ "preset_id", step ]`.
|
||||||
|
- **`save`**: If present (e.g. true), the driver may persist presets to flash after applying.
|
||||||
|
- **`default`**: Preset id string to use as startup default on the device.
|
||||||
|
- **`b`**: Optional **global** brightness 0–255 (driver applies this in addition to per-preset brightness).
|
||||||
|
|
||||||
|
### Preset object (wire / driver keys)
|
||||||
|
|
||||||
|
On the wire, presets use **short keys** (saves space in the ≤240-byte chunks):
|
||||||
|
|
||||||
|
| Key | Meaning | Notes |
|
||||||
|
|-----|---------|--------|
|
||||||
|
| `p` | Pattern id | `off`, `on`, `blink`, `rainbow`, `pulse`, `transition`, `chase`, `circle` |
|
||||||
|
| `c` | Colours | Array of `"#RRGGBB"` hex strings; converted to RGB on device |
|
||||||
|
| `d` | Delay ms | Default 100 |
|
||||||
|
| `b` | Preset brightness | 0–255; combined with global `b` on the device |
|
||||||
|
| `a` | Auto | `true`: run continuously; `false`: one step/cycle per “beat” |
|
||||||
|
| `n1`–`n6` | Pattern parameters | See below |
|
||||||
|
|
||||||
|
The HTTP app’s **`POST /presets/send`** path builds this from stored presets via **`build_preset_dict()`** (long names like `pattern` / `colors` in the DB are translated to `p` / `c` / …).
|
||||||
|
|
||||||
|
### Pattern-specific parameters (`n1`–`n6`)
|
||||||
|
|
||||||
|
#### Rainbow
|
||||||
|
- **`n1`**: Step increment on the colour wheel per update (default 1).
|
||||||
|
|
||||||
|
#### Pulse
|
||||||
|
- **`n1`**: Attack (fade in) ms
|
||||||
|
- **`n2`**: Hold ms
|
||||||
|
- **`n3`**: Decay (fade out) ms
|
||||||
|
- **`d`**: Off time between pulses ms
|
||||||
|
|
||||||
|
#### Transition
|
||||||
|
- **`d`**: Transition duration ms
|
||||||
|
|
||||||
|
#### Chase
|
||||||
|
- **`n1`**: LEDs with first colour
|
||||||
|
- **`n2`**: LEDs with second colour
|
||||||
|
- **`n3`**: Movement on even steps (may be negative)
|
||||||
|
- **`n4`**: Movement on odd steps (may be negative)
|
||||||
|
|
||||||
|
#### Circle
|
||||||
|
- **`n1`**: Head speed (LEDs/s)
|
||||||
|
- **`n2`**: Max length
|
||||||
|
- **`n3`**: Tail speed (LEDs/s)
|
||||||
|
- **`n4`**: Min length
|
||||||
|
|
||||||
|
### Select messages
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"select": {
|
||||||
|
"device_name": ["preset_id"],
|
||||||
|
"other_device": ["preset_id", 10]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /presets/{name}
|
- One element: select preset; step behavior follows driver rules (reset on `off`, etc.).
|
||||||
|
- Two elements: explicit **step** for sync.
|
||||||
|
|
||||||
Get a specific preset by name.
|
### Beat and sync behavior
|
||||||
|
|
||||||
|
- Sending **`select`** again with the **same** preset name acts as a **beat** (advances manual patterns / restarts generators per driver logic).
|
||||||
|
- Choosing **`off`** resets step as a sync point; then selecting a pattern aligns step 0 across devices unless a step is passed explicitly.
|
||||||
|
|
||||||
|
### Example (compact preset map)
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "preset1",
|
"v": "1",
|
||||||
"pattern": "on",
|
"save": true,
|
||||||
"colors": [[255, 0, 0]],
|
"presets": {
|
||||||
"delay": 100,
|
"1": {
|
||||||
"n1": 0,
|
"name": "Red blink",
|
||||||
"n2": 0,
|
"p": "blink",
|
||||||
"n3": 0,
|
"c": ["#FF0000"],
|
||||||
"n4": 0,
|
"d": 200,
|
||||||
"n5": 0,
|
"b": 255,
|
||||||
"n6": 0,
|
"a": true,
|
||||||
"n7": 0,
|
"n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0
|
||||||
"n8": 0
|
}
|
||||||
}
|
},
|
||||||
```
|
"select": {
|
||||||
|
"living-room": ["1"]
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Preset not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /presets
|
|
||||||
|
|
||||||
Create a new preset.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "preset1",
|
|
||||||
"pattern": "on",
|
|
||||||
"colors": [[255, 0, 0]],
|
|
||||||
"delay": 100,
|
|
||||||
"n1": 0,
|
|
||||||
"n2": 0,
|
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0,
|
|
||||||
"n7": 0,
|
|
||||||
"n8": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the created preset
|
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Preset already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /presets/{name}
|
|
||||||
|
|
||||||
Update an existing preset.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"delay": 200,
|
|
||||||
"colors": [[0, 255, 0]]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated preset
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Preset not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE /presets/{name}
|
|
||||||
|
|
||||||
Delete a preset.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Preset deleted successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Preset not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Profiles API
|
|
||||||
|
|
||||||
### GET /profiles
|
|
||||||
|
|
||||||
List all profiles.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"profile1": {
|
|
||||||
"name": "profile1",
|
|
||||||
"description": "Profile description",
|
|
||||||
"scenes": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### GET /profiles/{name}
|
---
|
||||||
|
|
||||||
Get a specific profile by name.
|
## Processing summary (driver)
|
||||||
|
|
||||||
**Response:** `200 OK`
|
1. Reject if `v != "1"`.
|
||||||
```json
|
2. Apply optional top-level **`b`** (global brightness).
|
||||||
{
|
3. For each entry in **`presets`**, normalize colours and upsert preset by id.
|
||||||
"name": "profile1",
|
4. If this device’s **`name`** appears in **`select`**, run selection (optional step).
|
||||||
"description": "Profile description",
|
5. If **`default`** is set, store startup preset id.
|
||||||
"scenes": []
|
6. If **`save`** is set, persist presets.
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
---
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /profiles
|
## Error handling (HTTP)
|
||||||
|
|
||||||
Create a new profile.
|
Controllers typically return JSON with an **`error`** string and 4xx/5xx status codes. Invalid JSON bodies often yield `{"error": "Invalid JSON"}`.
|
||||||
|
|
||||||
**Request Body:**
|
---
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "profile1",
|
|
||||||
"description": "Profile description",
|
|
||||||
"scenes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the created profile
|
## Notes
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
- **Human-readable preset fields** (`pattern`, `colors`, `delay`, …) are fine in the **web app / database**; the **send path** converts them to **`p` / `c` / `d`** for the driver.
|
||||||
```json
|
- For a copy of the older long-key reference, see **`led-driver/docs/API.md`** in this repo (conceptually the same behavior; wire format prefers short keys).
|
||||||
{
|
|
||||||
"error": "Name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /profiles/{name}
|
|
||||||
|
|
||||||
Update an existing profile.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"description": "Updated description"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated profile
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE /profiles/{name}
|
|
||||||
|
|
||||||
Delete a profile.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Profile deleted successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scenes API
|
|
||||||
|
|
||||||
### GET /scenes
|
|
||||||
|
|
||||||
List all scenes. Optionally filter by profile using query parameter.
|
|
||||||
|
|
||||||
**Query Parameters:**
|
|
||||||
- `profile` (optional): Filter scenes by profile name
|
|
||||||
|
|
||||||
**Example:** `GET /scenes?profile=profile1`
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"profile1:scene1": {
|
|
||||||
"name": "scene1",
|
|
||||||
"profile_name": "profile1",
|
|
||||||
"description": "Scene description",
|
|
||||||
"transition_time": 0,
|
|
||||||
"devices": [
|
|
||||||
{"device_name": "device1", "preset_name": "preset1"},
|
|
||||||
{"device_name": "device2", "preset_name": "preset2"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET /scenes/{profile_name}/{scene_name}
|
|
||||||
|
|
||||||
Get a specific scene.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "scene1",
|
|
||||||
"profile_name": "profile1",
|
|
||||||
"description": "Scene description",
|
|
||||||
"transition_time": 0,
|
|
||||||
"devices": [
|
|
||||||
{"device_name": "device1", "preset_name": "preset1"},
|
|
||||||
{"device_name": "device2", "preset_name": "preset2"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /scenes
|
|
||||||
|
|
||||||
Create a new scene.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "scene1",
|
|
||||||
"profile_name": "profile1",
|
|
||||||
"description": "Scene description",
|
|
||||||
"transition_time": 0,
|
|
||||||
"devices": [
|
|
||||||
{"device_name": "device1", "preset_name": "preset1"},
|
|
||||||
{"device_name": "device2", "preset_name": "preset2"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the created scene
|
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
or
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Profile name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### PUT /scenes/{profile_name}/{scene_name}
|
|
||||||
|
|
||||||
Update an existing scene.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"transition_time": 500,
|
|
||||||
"description": "Updated description"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated scene
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE /scenes/{profile_name}/{scene_name}
|
|
||||||
|
|
||||||
Delete a scene.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Scene deleted successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /scenes/{profile_name}/{scene_name}/devices
|
|
||||||
|
|
||||||
Add a device assignment to a scene.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"device_name": "device1",
|
|
||||||
"preset_name": "preset1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated scene
|
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Device name and preset name are required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE /scenes/{profile_name}/{scene_name}/devices/{device_name}
|
|
||||||
|
|
||||||
Remove a device assignment from a scene.
|
|
||||||
|
|
||||||
**Response:** `200 OK` - Returns the updated scene
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Scene not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Patterns API
|
|
||||||
|
|
||||||
### GET /patterns
|
|
||||||
|
|
||||||
Get the list of available pattern names.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
["on", "bl", "cl", "rb", "sb", "o"]
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST /patterns
|
|
||||||
|
|
||||||
Add a new pattern name to the list.
|
|
||||||
|
|
||||||
**Request Body:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "new_pattern"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `201 Created` - Returns the updated list of patterns
|
|
||||||
```json
|
|
||||||
["on", "bl", "cl", "rb", "sb", "o", "new_pattern"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `400 Bad Request`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Name is required"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `409 Conflict`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Pattern already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### DELETE /patterns/{name}
|
|
||||||
|
|
||||||
Remove a pattern name from the list.
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Pattern deleted successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `404 Not Found`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Pattern not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
|
||||||
|
|
||||||
All endpoints may return the following error responses:
|
|
||||||
|
|
||||||
**400 Bad Request** - Invalid request data
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Error message"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**404 Not Found** - Resource not found
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Resource not found"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**409 Conflict** - Resource already exists
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Resource already exists"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**500 Internal Server Error** - Server error
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Error message"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ The LED Driver system is a MicroPython-based application for controlling LED str
|
|||||||
- Pattern configuration and control (patterns run on remote devices)
|
- Pattern configuration and control (patterns run on remote devices)
|
||||||
- Real-time brightness and speed control
|
- Real-time brightness and speed control
|
||||||
- Global brightness setting (system-wide brightness multiplier)
|
- Global brightness setting (system-wide brightness multiplier)
|
||||||
- Multi-color support with customizable color palettes
|
- Multi-colour support with customizable colour palettes
|
||||||
- Device grouping for synchronized control
|
- Device grouping for synchronized control
|
||||||
- Preset system for saving and loading pattern configurations
|
- Preset system for saving and loading pattern configurations
|
||||||
- Profile and Scene system for complex lighting setups
|
- Profile and Scene system for complex lighting setups
|
||||||
@@ -239,7 +239,7 @@ Primary interface for real-time LED control and monitoring.
|
|||||||
- **Grid Layout:** 4-column responsive grid
|
- **Grid Layout:** 4-column responsive grid
|
||||||
- Pattern Selection Card
|
- Pattern Selection Card
|
||||||
- Brightness & Speed Card
|
- Brightness & Speed Card
|
||||||
- Color Selection Card
|
- Colour Selection Card
|
||||||
- Device Status Card
|
- Device Status Card
|
||||||
- **Action Bar:** Apply and Save buttons
|
- **Action Bar:** Apply and Save buttons
|
||||||
|
|
||||||
@@ -273,12 +273,12 @@ Primary interface for real-time LED control and monitoring.
|
|||||||
- **Default:** 100ms
|
- **Default:** 100ms
|
||||||
- **Step:** 10ms increments
|
- **Step:** 10ms increments
|
||||||
|
|
||||||
**Color Selection**
|
**Colour Selection**
|
||||||
- **Type:** Color picker inputs (HTML5 color input)
|
- **Type:** Colour picker inputs (HTML5 colour input)
|
||||||
- **Quantity:** Multiple colors (minimum 2, expandable)
|
- **Quantity:** Multiple colours (minimum 2, expandable)
|
||||||
- **Format:** Hex color codes (e.g., #FF0000)
|
- **Format:** Hex colour codes (e.g., #FF0000)
|
||||||
- **Display:** Large color swatches (60x60px)
|
- **Display:** Large colour swatches (60x60px)
|
||||||
- **Action:** "Add Color" button for additional colors
|
- **Action:** "Add Colour" button for additional colours
|
||||||
|
|
||||||
**Device Status List**
|
**Device Status List**
|
||||||
- **Type:** List of connected devices
|
- **Type:** List of connected devices
|
||||||
@@ -295,7 +295,7 @@ Primary interface for real-time LED control and monitoring.
|
|||||||
- **Save to Device:** Persist settings to device storage
|
- **Save to Device:** Persist settings to device storage
|
||||||
|
|
||||||
#### Design Specifications
|
#### Design Specifications
|
||||||
- **Color Scheme:** Purple gradient background (#667eea to #764ba2)
|
- **Colour Scheme:** Purple gradient background (#667eea to #764ba2)
|
||||||
- **Cards:** White background, rounded corners (12px), shadow
|
- **Cards:** White background, rounded corners (12px), shadow
|
||||||
- **Hover Effects:** Card lift (translateY -2px), increased shadow
|
- **Hover Effects:** Card lift (translateY -2px), increased shadow
|
||||||
- **Typography:** System font stack, 1.25rem headings
|
- **Typography:** System font stack, 1.25rem headings
|
||||||
@@ -509,7 +509,7 @@ Comprehensive device configuration interface.
|
|||||||
- Device Name (text input)
|
- Device Name (text input)
|
||||||
- LED Pin (number input, 0-40)
|
- LED Pin (number input, 0-40)
|
||||||
- Number of LEDs (number input, 1-1000)
|
- Number of LEDs (number input, 1-1000)
|
||||||
- Color Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
|
- Colour Order (visual selector with 6 options: RGB, RBG, GRB, GBR, BRG, BGR)
|
||||||
|
|
||||||
**2. Pattern Settings**
|
**2. Pattern Settings**
|
||||||
- Pattern (dropdown selection)
|
- Pattern (dropdown selection)
|
||||||
@@ -577,16 +577,16 @@ Comprehensive device configuration interface.
|
|||||||
- Range: Slider with real-time value display
|
- Range: Slider with real-time value display
|
||||||
- Select: Dropdown menu
|
- Select: Dropdown menu
|
||||||
- Checkbox: Toggle switch
|
- Checkbox: Toggle switch
|
||||||
- Color: HTML5 color picker
|
- Colour: HTML5 colour picker
|
||||||
|
|
||||||
**Color Order Selector**
|
**Colour Order Selector**
|
||||||
- **Type:** Visual button grid
|
- **Type:** Visual button grid
|
||||||
- **Options:** RGB, RBG, GRB, GBR, BRG, BGR
|
- **Options:** RGB, RBG, GRB, GBR, BRG, BGR
|
||||||
- **Display:** Color boxes showing order (R=red, G=green, B=blue)
|
- **Display:** Colour boxes showing order (R=red, G=green, B=blue)
|
||||||
- **Selection:** Single selection with visual feedback
|
- **Selection:** Single selection with visual feedback
|
||||||
|
|
||||||
#### Design Specifications
|
#### Design Specifications
|
||||||
- **Section Headers:** Purple color (#667eea), 1.5rem font, bottom border
|
- **Section Headers:** Purple colour (#667eea), 1.5rem font, bottom border
|
||||||
- **Form Groups:** 24px spacing between fields
|
- **Form Groups:** 24px spacing between fields
|
||||||
- **Labels:** Bold, 500 weight, dark gray (#333)
|
- **Labels:** Bold, 500 weight, dark gray (#333)
|
||||||
- **Help Text:** Small gray text below inputs
|
- **Help Text:** Small gray text below inputs
|
||||||
@@ -611,7 +611,7 @@ Save, load, and manage preset configurations for quick pattern switching.
|
|||||||
Each preset card displays:
|
Each preset card displays:
|
||||||
- **Name:** Preset name (bold, 1.25rem)
|
- **Name:** Preset name (bold, 1.25rem)
|
||||||
- **Pattern Badge:** Current pattern type
|
- **Pattern Badge:** Current pattern type
|
||||||
- **Color Preview:** Swatches showing preset colors
|
- **Colour Preview:** Swatches showing preset colours
|
||||||
- **Quick Info:** Delay and brightness values
|
- **Quick Info:** Delay and brightness values
|
||||||
- **Actions:** Apply, Edit, Delete buttons
|
- **Actions:** Apply, Edit, Delete buttons
|
||||||
|
|
||||||
@@ -620,7 +620,7 @@ Each preset card displays:
|
|||||||
**Fields:**
|
**Fields:**
|
||||||
- Preset Name (text input, required)
|
- Preset Name (text input, required)
|
||||||
- Pattern (dropdown selection)
|
- Pattern (dropdown selection)
|
||||||
- Colors (multiple color pickers, minimum 2)
|
- Colours (multiple colour pickers, minimum 2)
|
||||||
- Delay (slider, 10-1000ms)
|
- Delay (slider, 10-1000ms)
|
||||||
- Step Offset (number input, optional, default: 0)
|
- Step Offset (number input, optional, default: 0)
|
||||||
- Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group."
|
- Tooltip: "Step offset for group synchronization. Applied per device when preset is used in a group."
|
||||||
@@ -667,7 +667,7 @@ Each preset card displays:
|
|||||||
#### Design Specifications
|
#### Design Specifications
|
||||||
- **Card Style:** White background, rounded corners, shadow
|
- **Card Style:** White background, rounded corners, shadow
|
||||||
- **Pattern Badge:** Colored pill with pattern name
|
- **Pattern Badge:** Colored pill with pattern name
|
||||||
- **Color Swatches:** 40x40px squares in card header
|
- **Colour Swatches:** 40x40px squares in card header
|
||||||
- **Hover Effect:** Card lift, border highlight
|
- **Hover Effect:** Card lift, border highlight
|
||||||
- **Selected State:** Purple border, subtle background tint
|
- **Selected State:** Purple border, subtle background tint
|
||||||
|
|
||||||
@@ -681,7 +681,7 @@ Patterns are configured on the controller and sent to remote devices for executi
|
|||||||
|
|
||||||
- **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.)
|
- **Pattern Type:** Identifier for the pattern (e.g., "on", "off", "blink", "chase", "pulse", "rainbow", etc.)
|
||||||
- **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior
|
- **Pattern Parameters:** Numeric parameters (N1-N8) that configure pattern-specific behavior
|
||||||
- **Colors:** Color palette for the pattern
|
- **Colours:** Colour palette for the pattern
|
||||||
- **Timing:** Delay and speed settings
|
- **Timing:** Delay and speed settings
|
||||||
|
|
||||||
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
|
**Note:** Pattern execution happens on the remote devices. The controller sends configuration commands to devices.
|
||||||
@@ -698,7 +698,7 @@ Pattern-specific numeric parameters:
|
|||||||
|
|
||||||
#### Overview
|
#### Overview
|
||||||
|
|
||||||
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colors, timing, and all pattern parameters.
|
Presets allow users to save complete pattern configurations for quick recall and application. A preset encapsulates all pattern settings including pattern type, colours, timing, and all pattern parameters.
|
||||||
|
|
||||||
**Note:** Presets are optional. Devices can be controlled directly without presets.
|
**Note:** Presets are optional. Devices can be controlled directly without presets.
|
||||||
|
|
||||||
@@ -708,7 +708,7 @@ A preset contains the following fields:
|
|||||||
|
|
||||||
- **name** (string, required): Unique identifier for the preset
|
- **name** (string, required): Unique identifier for the preset
|
||||||
- **pattern** (string, required): Pattern type identifier (sent to remote devices)
|
- **pattern** (string, required): Pattern type identifier (sent to remote devices)
|
||||||
- **colors** (array of strings, required): Array of hex color codes (minimum 2 colors)
|
- **colours** (array of strings, required): Array of hex colour codes (minimum 2 colours)
|
||||||
- **delay** (integer, required): Delay in milliseconds (10-1000)
|
- **delay** (integer, required): Delay in milliseconds (10-1000)
|
||||||
- **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0)
|
- **n1** (integer, optional): Pattern parameter 1 (0-255, default: 0)
|
||||||
- **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0)
|
- **n2** (integer, optional): Pattern parameter 2 (0-255, default: 0)
|
||||||
@@ -889,7 +889,7 @@ A preset contains the following fields:
|
|||||||
#### Group Properties
|
#### Group Properties
|
||||||
- **Name:** Unique group identifier
|
- **Name:** Unique group identifier
|
||||||
- **Devices:** List of device names (can include master and/or slaves)
|
- **Devices:** List of device names (can include master and/or slaves)
|
||||||
- **Settings:** Pattern, delay, colors
|
- **Settings:** Pattern, delay, colours
|
||||||
- **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative)
|
- **Step Offset:** Per-device step offset sent to devices for synchronized patterns (integer, can be negative)
|
||||||
- Each device in group can receive different step offset
|
- Each device in group can receive different step offset
|
||||||
- Creates wave/chase effect across multiple LED strips
|
- Creates wave/chase effect across multiple LED strips
|
||||||
@@ -953,7 +953,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|
|||||||
|-----|------|-------------|--------------|
|
|-----|------|-------------|--------------|
|
||||||
| `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition |
|
| `pt` | string | Pattern type | on, off, blink, chase, circle, pulse, rainbow, transition |
|
||||||
| `pm` | string | Pattern mode | auto, single_shot |
|
| `pm` | string | Pattern mode | auto, single_shot |
|
||||||
| `cl` | array | Colors (hex strings) | Array of hex color codes |
|
| `cl` | array | Colours (hex strings) | Array of hex colour codes |
|
||||||
| `br` | int | Global brightness | 0-100 |
|
| `br` | int | Global brightness | 0-100 |
|
||||||
| `dl` | int | Delay (ms) | 10-1000 |
|
| `dl` | int | Delay (ms) | 10-1000 |
|
||||||
| `n1` | int | Parameter 1 | 0-255 |
|
| `n1` | int | Parameter 1 | 0-255 |
|
||||||
@@ -966,7 +966,7 @@ Byte 1: Flags (bit 0: names, bit 1: groups, bit 2: settings, bit 3: save)
|
|||||||
| `n8` | int | Parameter 8 | 0-255 |
|
| `n8` | int | Parameter 8 | 0-255 |
|
||||||
| `led_pin` | int | GPIO pin | 0-40 |
|
| `led_pin` | int | GPIO pin | 0-40 |
|
||||||
| `num_leds` | int | LED count | 1-1000 |
|
| `num_leds` | int | LED count | 1-1000 |
|
||||||
| `color_order` | string | Color order | rgb, rbg, grb, gbr, brg, bgr |
|
| `color_order` | string | Colour order | rgb, rbg, grb, gbr, brg, bgr |
|
||||||
| `name` | string | Device name | Any string |
|
| `name` | string | Device name | Any string |
|
||||||
| `brightness` | int | Global brightness | 0-100 |
|
| `brightness` | int | Global brightness | 0-100 |
|
||||||
| `delay` | int | Delay | 10-1000 |
|
| `delay` | int | Delay | 10-1000 |
|
||||||
@@ -1247,7 +1247,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
|||||||
**Preset Fields:**
|
**Preset Fields:**
|
||||||
- `name` (string, required): Unique preset identifier
|
- `name` (string, required): Unique preset identifier
|
||||||
- `pattern` (string, required): Pattern type
|
- `pattern` (string, required): Pattern type
|
||||||
- `colors` (array of strings, required): Hex color codes (minimum 2)
|
- `colors` (array of strings, required): Hex colour codes (minimum 2)
|
||||||
- `delay` (integer, required): Delay in milliseconds (10-1000)
|
- `delay` (integer, required): Delay in milliseconds (10-1000)
|
||||||
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
|
- `n1` through `n8` (integer, optional): Pattern parameters (0-255, default: 0)
|
||||||
|
|
||||||
@@ -1289,7 +1289,7 @@ CREATE TABLE IF NOT EXISTS presets (
|
|||||||
|
|
||||||
**POST /api/presets**
|
**POST /api/presets**
|
||||||
- Create a new preset
|
- Create a new preset
|
||||||
- Body: Preset object (name, pattern, colors, delay, n1-n8)
|
- Body: Preset object (name, pattern, colours, delay, n1-n8)
|
||||||
- Response: Created preset object
|
- Response: Created preset object
|
||||||
|
|
||||||
**GET /api/presets/{name}**
|
**GET /api/presets/{name}**
|
||||||
@@ -1506,7 +1506,7 @@ peak_mem = usqlite.mem_peak()
|
|||||||
|
|
||||||
1. User navigates to Settings page
|
1. User navigates to Settings page
|
||||||
2. User modifies settings in sections:
|
2. User modifies settings in sections:
|
||||||
- Basic Settings (pin, LED count, color order)
|
- Basic Settings (pin, LED count, colour order)
|
||||||
- Pattern Settings (pattern, delay)
|
- Pattern Settings (pattern, delay)
|
||||||
- Global Brightness
|
- Global Brightness
|
||||||
- Advanced Settings (N1-N8 parameters)
|
- Advanced Settings (N1-N8 parameters)
|
||||||
@@ -1519,7 +1519,7 @@ peak_mem = usqlite.mem_peak()
|
|||||||
### Flow 4: Multi-Device Control
|
### Flow 4: Multi-Device Control
|
||||||
|
|
||||||
1. User selects multiple devices or a group
|
1. User selects multiple devices or a group
|
||||||
2. User changes pattern/colors/global brightness
|
2. User changes pattern/colours/global brightness
|
||||||
3. User clicks "Apply Settings"
|
3. User clicks "Apply Settings"
|
||||||
4. System sends message targeting selected devices/groups
|
4. System sends message targeting selected devices/groups
|
||||||
5. All targeted devices update simultaneously
|
5. All targeted devices update simultaneously
|
||||||
@@ -1585,7 +1585,7 @@ peak_mem = usqlite.mem_peak()
|
|||||||
|
|
||||||
## Design Guidelines
|
## Design Guidelines
|
||||||
|
|
||||||
### Color Palette
|
### Colour Palette
|
||||||
|
|
||||||
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
|
- **Primary:** Purple gradient `#667eea` to `#764ba2`, Light gradient `#f5f7fa` to `#c3cfe2`
|
||||||
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
|
- **UI:** Primary `#667eea`, Hover `#5568d3`, Secondary `#e0e0e0`
|
||||||
@@ -1612,8 +1612,8 @@ peak_mem = usqlite.mem_peak()
|
|||||||
- Disabled: 50% opacity, no pointer events
|
- Disabled: 50% opacity, no pointer events
|
||||||
|
|
||||||
**Inputs:**
|
**Inputs:**
|
||||||
- Focus: Border color changes to primary purple
|
- Focus: Border colour changes to primary purple
|
||||||
- Hover: Slight border color change
|
- Hover: Slight border colour change
|
||||||
- Error: Red border
|
- Error: Red border
|
||||||
|
|
||||||
**Cards:**
|
**Cards:**
|
||||||
@@ -1738,7 +1738,7 @@ peak_mem = usqlite.mem_peak()
|
|||||||
- Validation
|
- Validation
|
||||||
|
|
||||||
**Preset Management:**
|
**Preset Management:**
|
||||||
- Preset creation with all fields (name, pattern, colors, delay, n1-n8)
|
- Preset creation with all fields (name, pattern, colours, delay, n1-n8)
|
||||||
- Preset loading and application
|
- Preset loading and application
|
||||||
- Preset editing and deletion
|
- Preset editing and deletion
|
||||||
- Name uniqueness validation
|
- Name uniqueness validation
|
||||||
@@ -1758,7 +1758,7 @@ peak_mem = usqlite.mem_peak()
|
|||||||
- Configuration parameters are properly formatted
|
- Configuration parameters are properly formatted
|
||||||
|
|
||||||
**Preset Application:**
|
**Preset Application:**
|
||||||
- Preset loads all parameters correctly (pattern, colors, delay, n1-n8)
|
- Preset loads all parameters correctly (pattern, colours, delay, n1-n8)
|
||||||
- Preset applies to single device
|
- Preset applies to single device
|
||||||
- Preset applies to device group
|
- Preset applies to device group
|
||||||
- Preset values match saved configuration
|
- Preset values match saved configuration
|
||||||
|
|||||||
112
docs/help.md
Normal file
112
docs/help.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# LED controller — user guide
|
||||||
|
|
||||||
|
This page describes the **main web UI** served from the Raspberry Pi app: profiles, tabs, presets, colour palettes, and sending commands to LED devices over the serial → ESP-NOW bridge.
|
||||||
|
|
||||||
|
For HTTP routes and the wire format the driver expects, see **[API.md](API.md)**. For running the app locally, see the project **README**.
|
||||||
|
|
||||||
|
Figures below are **schematic** (layout and ideas), not pixel-perfect screenshots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run mode and Edit mode
|
||||||
|
|
||||||
|
The header has a mode toggle (desktop and mobile menu). The **label on the button is the mode you switch to** when you press it.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The active tab is highlighted. Extra management buttons appear only in Edit mode.*
|
||||||
|
|
||||||
|
| Mode | Purpose |
|
||||||
|
|------|--------|
|
||||||
|
| **Run mode** | Day-to-day control: choose a tab, tap presets, apply profiles. Management buttons are hidden. |
|
||||||
|
| **Edit mode** | Full setup: tabs, presets, patterns, colour palette, **Send Presets**, profile create/clone/delete, preset reordering, and per-tile **Edit** on the strip. |
|
||||||
|
|
||||||
|
**Profiles** is available in both modes: in Run mode you can only **apply** a profile; in Edit mode you can also **create**, **clone**, and **delete** profiles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
- **Select a tab**: click its button in the top bar. The main area shows that tab’s preset strip and controls.
|
||||||
|
- **Edit mode — open tab settings**: **right-click** a tab button to change its name, **device IDs** (comma-separated), and which presets appear on the tab. Device identifiers are matched to each device’s **name** when the app builds `select` messages for the driver.
|
||||||
|
- **Tabs modal** (Edit mode): create new tabs from the header **Tabs** button. New tabs need a name and device ID list (defaults to `1` if you leave a simple placeholder).
|
||||||
|
- **Brightness slider** (per tab): adjusts **global** brightness sent to devices (`b` in the driver message), with a short debounce so small drags do not flood the link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Presets on the tab strip
|
||||||
|
|
||||||
|
- **Run and Edit mode**: click the **main part** of a preset tile to **select** that preset on all devices assigned to the current tab (same logical action as a `select` in the driver API).
|
||||||
|
- **Edit mode only**:
|
||||||
|
- **Edit** beside a tile opens the preset editor for that preset, scoped to the current tab (so you can **Remove from tab** without deleting the preset from the profile).
|
||||||
|
- **Drag and drop** tiles to reorder them; order is saved for that tab.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The slider controls global brightness for the tab’s devices. Click the coloured area of a tile to select that preset.*
|
||||||
|
|
||||||
|
The **Presets** header button (Edit mode) opens a **profile-wide** list: **Add** new presets, **Edit**, **Send** (push definition over the transport), and **Delete** (removes the preset from the profile entirely).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preset editor
|
||||||
|
|
||||||
|
- **Pattern**: chosen from the dropdown; optional **n1–n8** fields depend on the pattern (see **Pattern-specific parameters** in [API.md](API.md)).
|
||||||
|
- **Colours**: choosing a value in the colour picker **adds** a swatch when the picker closes. Swatches can be **reordered** by dragging. Changing a swatch with the picker **clears** palette linkage for that slot.
|
||||||
|
- **From Palette**: inserts a colour **linked** to the current profile’s palette. Linked slots show a **P** badge; if you change that palette entry later, presets using it update.
|
||||||
|
- **Brightness (0–255)** and **Delay (ms)**: stored on the preset and sent with the compact preset payload.
|
||||||
|
- **Try**: sends the current form values to devices on the **current tab**, then selects that preset — **without** `save` on the device (good for auditioning).
|
||||||
|
- **Default**: updates the tab’s **default preset** and sends a **default** hint for those devices; it does not force the same live selection behaviour as clicking a tile.
|
||||||
|
- **Save & Send**: writes the preset to the server, then pushes definitions with **save** so devices may persist them. It does **not** auto-select the preset on devices (use the strip or **Try** if you want that).
|
||||||
|
- **Remove from tab** (when you opened the editor from a tab): removes the preset from **this tab’s list only**; the preset remains in the profile for other tabs.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Try previews without persisting on the device; **Save & Send** stores the preset and pushes definitions with save.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
- **Apply**: sets the **current profile** in your session. Tabs and presets you see are scoped to that profile.
|
||||||
|
- **Edit mode — Create**: new profiles always get a populated **default** tab. Optionally tick **DJ tab** to also create a `dj` tab (device name `dj`) with starter DJ-oriented presets.
|
||||||
|
- **Clone** / **Delete**: available in Edit mode from the profile list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Send Presets (Edit mode)
|
||||||
|
|
||||||
|
**Send Presets** walks **every tab** in the **current profile**, collects each tab’s preset IDs, and calls **`POST /presets/send`** per tab (including each tab’s **default** preset when set). Use this to bulk-push definitions to hardware after editing, without clicking **Send** on every preset individually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
The **Patterns** dialog (Edit mode) is a **read-only reference**: pattern names and typical **delay** ranges from the pattern definitions. It does not change device behaviour by itself; patterns are chosen inside the preset editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Colour palette
|
||||||
|
|
||||||
|
**Colour Palette** (Edit mode) edits the **current profile’s** palette swatches. Those colours are reused by **From Palette** in the preset editor and stay in sync while the **P** link remains.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Add or change swatches here; linked preset colours update automatically.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile layout
|
||||||
|
|
||||||
|
On narrow screens, use **Menu** to reach the same actions as the desktop header (Profiles, Tabs, Presets, Help, mode toggle, etc.).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Preset tiles behave the same once a tab is selected.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Further reading
|
||||||
|
|
||||||
|
- **[API.md](API.md)** — REST routes, session scoping, WebSocket `/ws`, and LED driver JSON (`presets`, `select`, `save`, `default`, pattern keys).
|
||||||
|
- **README** — `pipenv run run`, port 80 setup, and high-level behaviour.
|
||||||
BIN
docs/help.pdf
Normal file
BIN
docs/help.pdf
Normal file
Binary file not shown.
14
docs/images/help/colour-palette.svg
Normal file
14
docs/images/help/colour-palette.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 140" width="480" height="140">
|
||||||
|
<title>Colour Palette modal (concept)</title>
|
||||||
|
<rect width="480" height="140" fill="#2a2a2a" stroke="#555" stroke-width="1" rx="6"/>
|
||||||
|
<text x="20" y="28" fill="#fff" font-family="sans-serif" font-size="15" font-weight="600">Colour Palette</text>
|
||||||
|
<text x="20" y="48" fill="#888" font-family="sans-serif" font-size="10">Profile: current profile name</text>
|
||||||
|
<rect x="20" y="58" width="44" height="44" rx="4" fill="#e53935" stroke="#333"/>
|
||||||
|
<rect x="72" y="58" width="44" height="44" rx="4" fill="#fdd835" stroke="#333"/>
|
||||||
|
<rect x="124" y="58" width="44" height="44" rx="4" fill="#43a047" stroke="#333"/>
|
||||||
|
<rect x="176" y="58" width="44" height="44" rx="4" fill="#1e88e5" stroke="#333"/>
|
||||||
|
<rect x="228" y="58" width="44" height="44" rx="4" fill="#8e24aa" stroke="#333"/>
|
||||||
|
<rect x="280" y="70" width="36" height="28" rx="3" fill="#1a1a1a" stroke="#666"/>
|
||||||
|
<text x="288" y="88" fill="#ccc" font-family="sans-serif" font-size="10">+</text>
|
||||||
|
<text x="20" y="122" fill="#aaa" font-family="sans-serif" font-size="10">Swatches belong to the profile; preset editor uses them via From Palette.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
24
docs/images/help/header-toolbar.svg
Normal file
24
docs/images/help/header-toolbar.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 108" width="820" height="108">
|
||||||
|
<title>Header: tab buttons and action bar</title>
|
||||||
|
<rect width="820" height="108" fill="#1a1a1a"/>
|
||||||
|
<rect x="0" y="106" width="820" height="2" fill="#4a4a4a"/>
|
||||||
|
<text x="16" y="28" fill="#888" font-family="sans-serif" font-size="11">Tabs</text>
|
||||||
|
<rect x="16" y="40" width="72" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="34" y="63" fill="#ccc" font-family="sans-serif" font-size="13">default</text>
|
||||||
|
<rect x="96" y="40" width="88" height="36" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="2"/>
|
||||||
|
<text x="108" y="63" fill="#fff" font-family="sans-serif" font-size="13">lounge</text>
|
||||||
|
<rect x="192" y="40" width="56" height="36" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="204" y="63" fill="#ccc" font-family="sans-serif" font-size="13">dj</text>
|
||||||
|
<text x="380" y="28" fill="#888" font-family="sans-serif" font-size="11">Actions (Edit mode)</text>
|
||||||
|
<rect x="380" y="40" width="72" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="396" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Profiles</text>
|
||||||
|
<rect x="458" y="40" width="52" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="470" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Tabs</text>
|
||||||
|
<rect x="516" y="40" width="64" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="524" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Presets</text>
|
||||||
|
<rect x="586" y="40" width="78" height="30" rx="3" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="598" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Patterns</text>
|
||||||
|
<rect x="670" y="40" width="134" height="30" rx="3" fill="#4a4a6a" stroke="#7a7aaf" stroke-width="1"/>
|
||||||
|
<text x="688" y="59" fill="#e0e0e0" font-family="sans-serif" font-size="11">Run mode</text>
|
||||||
|
<text x="16" y="98" fill="#aaa" font-family="sans-serif" font-size="10">Active tab highlighted. Mode button shows the mode you switch to next.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
26
docs/images/help/mobile-menu.svg
Normal file
26
docs/images/help/mobile-menu.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 340" width="300" height="340" role="img" aria-labelledby="t">
|
||||||
|
<title id="t">Narrow screen: Menu aggregates header actions</title>
|
||||||
|
<rect width="300" height="340" fill="#2e2e2e"/>
|
||||||
|
<rect x="0" y="0" width="300" height="52" fill="#1a1a1a"/>
|
||||||
|
<rect x="12" y="12" width="56" height="28" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="22" y="30" fill="#eee" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12">Menu <20></text>
|
||||||
|
<rect x="76" y="14" width="52" height="24" rx="4" fill="#333" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="86" y="30" fill="#ccc" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||||
|
<rect x="136" y="14" width="52" height="24" rx="4" fill="#2a4a7a" stroke="#5a8fd4" stroke-width="1"/>
|
||||||
|
<text x="142" y="30" fill="#fff" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">tab</text>
|
||||||
|
<rect x="12" y="60" width="276" height="168" rx="4" fill="#252525" stroke="#4a4a4a" stroke-width="1"/>
|
||||||
|
<text x="24" y="84" fill="#888" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Dropdown (same actions as desktop header)</text>
|
||||||
|
<g font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="12" fill="#e8e8e8">
|
||||||
|
<text x="24" y="108">Run mode</text>
|
||||||
|
<text x="24" y="132">Profiles</text>
|
||||||
|
<text x="24" y="156">Tabs</text>
|
||||||
|
<text x="24" y="180">Presets</text>
|
||||||
|
<text x="24" y="204">Help</text>
|
||||||
|
</g>
|
||||||
|
<rect x="12" y="240" width="276" height="80" rx="6" fill="#222" stroke="#444" stroke-width="1"/>
|
||||||
|
<text x="24" y="268" fill="#aaa" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="10">Content area presets as on desktop</text>
|
||||||
|
<rect x="24" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||||
|
<text x="36" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||||
|
<rect x="112" y="278" width="80" height="32" rx="4" fill="#333" stroke="#555"/>
|
||||||
|
<text x="124" y="298" fill="#ddd" font-family="DejaVu Sans, Liberation Sans, Arial, sans-serif" font-size="11">preset</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
31
docs/images/help/preset-editor.svg
Normal file
31
docs/images/help/preset-editor.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 400" width="520" height="400">
|
||||||
|
<title>Preset editor modal (simplified)</title>
|
||||||
|
<rect width="520" height="400" fill="#1e1e1e"/>
|
||||||
|
<rect x="40" y="28" width="440" height="344" rx="8" fill="#2a2a2a" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="60" y="58" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="17" font-weight="600">Preset</text>
|
||||||
|
<text x="60" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Name</text>
|
||||||
|
<rect x="60" y="92" width="200" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||||
|
<text x="72" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">evening glow</text>
|
||||||
|
<text x="280" y="86" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Pattern</text>
|
||||||
|
<rect x="280" y="92" width="160" height="28" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||||
|
<text x="292" y="111" fill="#ddd" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="12">pulse</text>
|
||||||
|
<text x="60" y="148" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Colours</text>
|
||||||
|
<rect x="60" y="156" width="48" height="48" rx="4" fill="#7e57c2" stroke="#333" stroke-width="1"/>
|
||||||
|
<circle cx="66" cy="162" r="8" fill="#3f51b5" stroke="#fff" stroke-width="1"/>
|
||||||
|
<text x="63" y="166" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="7" font-weight="700">P</text>
|
||||||
|
<rect x="116" y="156" width="48" height="48" rx="4" fill="#26a69a" stroke="#333" stroke-width="1"/>
|
||||||
|
<text x="176" y="184" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">P = palette-linked</text>
|
||||||
|
<text x="60" y="232" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Brightness, delay, n1-n8</text>
|
||||||
|
<rect x="60" y="238" width="120" height="24" rx="3" fill="#1a1a1a" stroke="#444" stroke-width="1"/>
|
||||||
|
<text x="68" y="254" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">0-255</text>
|
||||||
|
<text x="60" y="290" fill="#aaa" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="10">Actions</text>
|
||||||
|
<rect x="60" y="298" width="44" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="72" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Try</text>
|
||||||
|
<rect x="112" y="298" width="56" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="120" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Default</text>
|
||||||
|
<rect x="176" y="298" width="88" height="26" rx="3" fill="#3d5a80" stroke="#5a7ab8" stroke-width="1"/>
|
||||||
|
<text x="188" y="315" fill="#fff" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Save+Send</text>
|
||||||
|
<rect x="272" y="298" width="48" height="26" rx="3" fill="#444" stroke="#666" stroke-width="1"/>
|
||||||
|
<text x="284" y="315" fill="#eee" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="11">Close</text>
|
||||||
|
<text x="60" y="352" fill="#888" font-family="DejaVu Sans,Liberation Sans,Arial,sans-serif" font-size="9">Try: preview without device save. Save+Send: store and push with save.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
35
docs/images/help/tab-preset-strip.svg
Normal file
35
docs/images/help/tab-preset-strip.svg
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 220" width="800" height="220">
|
||||||
|
<title>Main area: brightness and preset tiles</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="rg1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#ffd54f"/><stop offset="100%" style="stop-color:#fff8e1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rg2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#e53935"/><stop offset="33%" style="stop-color:#fdd835"/>
|
||||||
|
<stop offset="66%" style="stop-color:#43a047"/><stop offset="100%" style="stop-color:#1e88e5"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="rg3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#00897b"/><stop offset="100%" style="stop-color:#4db6ac"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="800" height="220" fill="#2e2e2e"/>
|
||||||
|
<text x="20" y="32" fill="#fff" font-family="sans-serif" font-size="16" font-weight="600">lounge</text>
|
||||||
|
<text x="20" y="56" fill="#aaa" font-family="sans-serif" font-size="11">Brightness (global)</text>
|
||||||
|
<rect x="20" y="64" width="320" height="8" rx="4" fill="#444"/>
|
||||||
|
<rect x="20" y="64" width="200" height="8" rx="4" fill="#6a9ee2"/>
|
||||||
|
<circle cx="220" cy="68" r="10" fill="#ccc" stroke="#333" stroke-width="1"/>
|
||||||
|
<text x="360" y="74" fill="#888" font-family="sans-serif" font-size="11">drag to adjust</text>
|
||||||
|
<text x="20" y="108" fill="#aaa" font-family="sans-serif" font-size="11">Click tile body to select on tab devices</text>
|
||||||
|
<rect x="20" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||||
|
<rect x="28" y="128" width="184" height="36" rx="4" fill="url(#rg1)"/>
|
||||||
|
<text x="32" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">warm white</text>
|
||||||
|
<rect x="232" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#6a9ee2" stroke-width="2"/>
|
||||||
|
<rect x="240" y="128" width="184" height="36" rx="4" fill="url(#rg2)"/>
|
||||||
|
<text x="244" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">rainbow</text>
|
||||||
|
<rect x="444" y="118" width="200" height="72" rx="6" fill="#252525" stroke="#555" stroke-width="1"/>
|
||||||
|
<rect x="452" y="128" width="184" height="36" rx="4" fill="url(#rg3)"/>
|
||||||
|
<text x="456" y="180" fill="#eee" font-family="sans-serif" font-size="13" font-weight="600">chase</text>
|
||||||
|
<rect x="656" y="130" width="56" height="48" rx="4" fill="#3d3d3d" stroke="#555" stroke-width="1"/>
|
||||||
|
<text x="670" y="158" fill="#ddd" font-family="sans-serif" font-size="11">Edit</text>
|
||||||
|
<text x="656" y="198" fill="#888" font-family="sans-serif" font-size="10">Edit mode: drag tiles to reorder</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,13 +1,13 @@
|
|||||||
# Custom Color Picker Component
|
# Custom Colour Picker Component
|
||||||
|
|
||||||
A cross-platform, cross-browser color picker component that provides a consistent user experience across all operating systems and browsers.
|
A cross-platform, cross-browser colour picker component that provides a consistent user experience across all operating systems and browsers.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
✅ **Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
|
✅ **Consistent UI** - Same appearance and behavior on Windows, macOS, Linux, iOS, and Android
|
||||||
✅ **Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
|
✅ **Browser Support** - Works in Chrome, Firefox, Safari, Edge, Opera, and mobile browsers
|
||||||
✅ **Touch Support** - Full touch/gesture support for mobile devices
|
✅ **Touch Support** - Full touch/gesture support for mobile devices
|
||||||
✅ **HSB Color Model** - Uses Hue, Saturation, Brightness for intuitive color selection
|
✅ **HSB Colour Model** - Uses Hue, Saturation, Brightness for intuitive colour selection
|
||||||
✅ **Multiple Input Methods** - Hex input, RGB inputs, and visual picker
|
✅ **Multiple Input Methods** - Hex input, RGB inputs, and visual picker
|
||||||
✅ **Accessible** - Keyboard accessible and screen reader friendly
|
✅ **Accessible** - Keyboard accessible and screen reader friendly
|
||||||
✅ **Customizable** - Easy to style and integrate
|
✅ **Customizable** - Easy to style and integrate
|
||||||
@@ -33,7 +33,7 @@ A cross-platform, cross-browser color picker component that provides a consisten
|
|||||||
<div id="my-color-picker"></div>
|
<div id="my-color-picker"></div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Initialize the color picker
|
### 3. Initialize the colour picker
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const picker = new ColorPicker('#my-color-picker', {
|
const picker = new ColorPicker('#my-color-picker', {
|
||||||
@@ -57,8 +57,8 @@ new ColorPicker(container, options)
|
|||||||
- `options` (object) - Configuration options
|
- `options` (object) - Configuration options
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
- `initialColor` (string) - Initial color in hex format (default: '#FF0000')
|
- `initialColor` (string) - Initial colour in hex format (default: '#FF0000')
|
||||||
- `onColorChange` (function) - Callback when color changes (receives hex color string)
|
- `onColorChange` (function) - Callback when colour changes (receives hex colour string)
|
||||||
- `showHexInput` (boolean) - Show hex input field (default: true)
|
- `showHexInput` (boolean) - Show hex input field (default: true)
|
||||||
|
|
||||||
### Methods
|
### Methods
|
||||||
@@ -101,7 +101,7 @@ const picker = new ColorPicker('#picker1', {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multiple Color Pickers
|
### Multiple Colour Pickers
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const colors = ['#FF0000', '#00FF00', '#0000FF'];
|
const colors = ['#FF0000', '#00FF00', '#0000FF'];
|
||||||
@@ -116,7 +116,7 @@ const pickers = colors.map((color, index) => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dynamic Color Picker Creation
|
### Dynamic Colour Picker Creation
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
function addColorPicker(containerId, initialColor = '#000000') {
|
function addColorPicker(containerId, initialColor = '#000000') {
|
||||||
@@ -139,12 +139,12 @@ addColorPicker('color-2', '#00FF00');
|
|||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
The color picker uses CSS classes that can be customized:
|
The colour picker uses CSS classes that can be customized:
|
||||||
|
|
||||||
- `.color-picker-container` - Main container
|
- `.color-picker-container` - Main container
|
||||||
- `.color-picker-preview` - Color preview button
|
- `.color-picker-preview` - Colour preview button
|
||||||
- `.color-picker-panel` - Dropdown panel
|
- `.color-picker-panel` - Dropdown panel
|
||||||
- `.color-picker-main` - Main color area
|
- `.color-picker-main` - Main colour area
|
||||||
- `.color-picker-hue` - Hue slider
|
- `.color-picker-hue` - Hue slider
|
||||||
- `.color-picker-controls` - Controls section
|
- `.color-picker-controls` - Controls section
|
||||||
|
|
||||||
@@ -183,20 +183,20 @@ The color picker uses CSS classes that can be customized:
|
|||||||
- ✅ iOS 12+
|
- ✅ iOS 12+
|
||||||
- ✅ Android 7+
|
- ✅ Android 7+
|
||||||
|
|
||||||
## Color Format
|
## Colour Format
|
||||||
|
|
||||||
The color picker uses **hex color format** (`#RRGGBB`):
|
The colour picker uses **hex colour format** (`#RRGGBB`):
|
||||||
- Always returns uppercase hex strings (e.g., `#FF0000`)
|
- Always returns uppercase hex strings (e.g., `#FF0000`)
|
||||||
- Accepts both uppercase and lowercase input
|
- Accepts both uppercase and lowercase input
|
||||||
- Automatically validates hex format
|
- Automatically validates hex format
|
||||||
|
|
||||||
## Integration with LED Driver Mockups
|
## Integration with LED Driver Mockups
|
||||||
|
|
||||||
The color picker is integrated into:
|
The colour picker is integrated into:
|
||||||
- `dashboard.html` - Color selection for patterns
|
- `dashboard.html` - Colour selection for patterns
|
||||||
- `presets.html` - Color selection when creating/editing presets
|
- `presets.html` - Colour selection when creating/editing presets
|
||||||
|
|
||||||
### Example: Getting Colors from Multiple Pickers
|
### Example: Getting Colours from Multiple Pickers
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const colorPickers = [];
|
const colorPickers = [];
|
||||||
@@ -218,7 +218,7 @@ function sendColorsToDevice() {
|
|||||||
## Performance
|
## Performance
|
||||||
|
|
||||||
- Lightweight: ~14KB JavaScript, ~4KB CSS
|
- Lightweight: ~14KB JavaScript, ~4KB CSS
|
||||||
- Fast rendering: Uses Canvas API for color gradients
|
- Fast rendering: Uses Canvas API for colour gradients
|
||||||
- Smooth interactions: Optimized event handling
|
- Smooth interactions: Optimized event handling
|
||||||
- Memory efficient: No external dependencies
|
- Memory efficient: No external dependencies
|
||||||
|
|
||||||
@@ -235,5 +235,5 @@ Part of the LED Driver project. Use freely in your projects.
|
|||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
See `color-picker-demo.html` for a live demonstration of the color picker component.
|
See `color-picker-demo.html` for a live demonstration of the colour picker component.
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,23 @@
|
|||||||
{
|
{
|
||||||
"grps": [
|
"g":{
|
||||||
{
|
"df": {
|
||||||
"n": "group1",
|
|
||||||
"pt": "on",
|
"pt": "on",
|
||||||
"cl": [
|
"cl": ["#ff0000"],
|
||||||
"000000",
|
"br": 200,
|
||||||
"000000"
|
"n1": 10,
|
||||||
],
|
"n2": 10,
|
||||||
"br": 100,
|
"n3": 10,
|
||||||
"dl": 100,
|
"n4": 10,
|
||||||
"n1": 0,
|
"n5": 10,
|
||||||
"n2": 0,
|
"n6": 10,
|
||||||
"n3": 0,
|
|
||||||
"n4": 0,
|
|
||||||
"n5": 0,
|
|
||||||
"n6": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"n": "group2",
|
|
||||||
"pt": "on",
|
|
||||||
"cl": [
|
|
||||||
"000000",
|
|
||||||
"000000"
|
|
||||||
],
|
|
||||||
"br": 100,
|
|
||||||
"dl": 100
|
"dl": 100
|
||||||
|
},
|
||||||
|
"dj": {
|
||||||
|
"pt": "blink",
|
||||||
|
"cl": ["#00ff00"],
|
||||||
|
"dl": 500
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
|
"sv": true,
|
||||||
|
"st": 0
|
||||||
}
|
}
|
||||||
112
esp32/benchmark_peers.py
Normal file
112
esp32/benchmark_peers.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Benchmark: LRU eviction vs add-then-remove-after-use on ESP32.
|
||||||
|
# Run on device: mpremote run esp32/benchmark_peers.py
|
||||||
|
# (add/del_peer are timed; send() may fail if no peer is listening - timing still valid)
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
MAX_PEERS = 20
|
||||||
|
ITERATIONS = 50
|
||||||
|
PAYLOAD = b"x" * 32 # small payload
|
||||||
|
|
||||||
|
network.WLAN(network.STA_IF).active(True)
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
esp.add_peer(BROADCAST)
|
||||||
|
|
||||||
|
# Build 19 dummy MACs so we have 20 peers total (broadcast + 19).
|
||||||
|
def mac(i):
|
||||||
|
return bytes([0, 0, 0, 0, 0, i])
|
||||||
|
peers_list = [mac(i) for i in range(1, 20)]
|
||||||
|
for p in peers_list:
|
||||||
|
esp.add_peer(p)
|
||||||
|
|
||||||
|
# One "new" MAC we'll add/remove.
|
||||||
|
new_mac = bytes([0, 0, 0, 0, 0, 99])
|
||||||
|
|
||||||
|
def bench_lru():
|
||||||
|
"""LRU: ensure_peer (evict oldest + add new), send, update last_used."""
|
||||||
|
last_used = {BROADCAST: time.ticks_ms()}
|
||||||
|
for p in peers_list:
|
||||||
|
last_used[p] = time.ticks_ms()
|
||||||
|
# Pre-remove one so we have 19; ensure_peer(new) will add 20th.
|
||||||
|
esp.del_peer(peers_list[-1])
|
||||||
|
last_used.pop(peers_list[-1], None)
|
||||||
|
# Now 19 peers. Each iteration: ensure_peer(new) -> add_peer(new), send, update.
|
||||||
|
# Next iter: ensure_peer(new) -> already there, just send. So we need to force
|
||||||
|
# eviction each time: use a different "new" each time so we always evict+add.
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for i in range(ITERATIONS):
|
||||||
|
addr = bytes([0, 0, 0, 0, 0, 50 + (i % 30)]) # 30 different "new" MACs
|
||||||
|
peers = esp.get_peers()
|
||||||
|
peer_macs = [p[0] for p in peers]
|
||||||
|
if addr not in peer_macs:
|
||||||
|
if len(peer_macs) >= MAX_PEERS:
|
||||||
|
oldest_mac = None
|
||||||
|
oldest_ts = time.ticks_ms()
|
||||||
|
for m in peer_macs:
|
||||||
|
if m == BROADCAST:
|
||||||
|
continue
|
||||||
|
ts = last_used.get(m, 0)
|
||||||
|
if ts <= oldest_ts:
|
||||||
|
oldest_ts = ts
|
||||||
|
oldest_mac = m
|
||||||
|
if oldest_mac is not None:
|
||||||
|
esp.del_peer(oldest_mac)
|
||||||
|
last_used.pop(oldest_mac, None)
|
||||||
|
esp.add_peer(addr)
|
||||||
|
esp.send(addr, PAYLOAD)
|
||||||
|
last_used[addr] = time.ticks_ms()
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
def bench_add_then_remove():
|
||||||
|
"""Add peer, send, del_peer (remove after use). At 20 we must del one first."""
|
||||||
|
# Start full: 20 peers. To add new we del any one, add new, send, del new.
|
||||||
|
victim = peers_list[0]
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for i in range(ITERATIONS):
|
||||||
|
esp.del_peer(victim) # make room
|
||||||
|
esp.add_peer(new_mac)
|
||||||
|
esp.send(new_mac, PAYLOAD)
|
||||||
|
esp.del_peer(new_mac)
|
||||||
|
esp.add_peer(victim) # put victim back so we're at 20 again
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
def bench_send_existing():
|
||||||
|
"""Baseline: send to existing peer only (no add/del)."""
|
||||||
|
t0 = time.ticks_us()
|
||||||
|
for _ in range(ITERATIONS):
|
||||||
|
esp.send(peers_list[0], PAYLOAD)
|
||||||
|
t1 = time.ticks_us()
|
||||||
|
return time.ticks_diff(t1, t0)
|
||||||
|
|
||||||
|
print("ESP-NOW peer benchmark ({} iterations)".format(ITERATIONS))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Baseline: send to existing peer
|
||||||
|
try:
|
||||||
|
us = bench_send_existing()
|
||||||
|
print("Send to existing peer only: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("Send existing failed:", e)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# LRU: evict oldest then add new, send
|
||||||
|
try:
|
||||||
|
us = bench_lru()
|
||||||
|
print("LRU (evict oldest + add + send): {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("LRU failed:", e)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Add then remove after use
|
||||||
|
try:
|
||||||
|
us = bench_add_then_remove()
|
||||||
|
print("Add then remove after use: {:>8} us total {:>7.1f} us/iter".format(us, us / ITERATIONS))
|
||||||
|
except Exception as e:
|
||||||
|
print("Add-then-remove failed:", e)
|
||||||
|
print()
|
||||||
|
print("Done.")
|
||||||
72
esp32/main.py
Normal file
72
esp32/main.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Serial-to-ESP-NOW bridge: receives from Pi on UART, forwards to ESP-NOW peers.
|
||||||
|
# Wire format: first 6 bytes = destination MAC, rest = payload. Address is always 6 bytes.
|
||||||
|
from machine import Pin, UART
|
||||||
|
import espnow
|
||||||
|
import network
|
||||||
|
import time
|
||||||
|
|
||||||
|
UART_BAUD = 912000
|
||||||
|
BROADCAST = b"\xff\xff\xff\xff\xff\xff"
|
||||||
|
MAX_PEERS = 20
|
||||||
|
# Match led-driver / controller default settings wifi_channel (1–11)
|
||||||
|
WIFI_CHANNEL = 6
|
||||||
|
|
||||||
|
sta = network.WLAN(network.STA_IF)
|
||||||
|
sta.active(True)
|
||||||
|
sta.config(pm=network.WLAN.PM_NONE, channel=WIFI_CHANNEL)
|
||||||
|
print("WiFi STA channel:", sta.config("channel"), "(WIFI_CHANNEL=%s)" % WIFI_CHANNEL)
|
||||||
|
|
||||||
|
esp = espnow.ESPNow()
|
||||||
|
esp.active(True)
|
||||||
|
esp.add_peer(BROADCAST)
|
||||||
|
|
||||||
|
uart = UART(1, UART_BAUD, tx=Pin(21), rx=Pin(6))
|
||||||
|
|
||||||
|
# Track last send time per peer for LRU eviction (remove oldest when at limit).
|
||||||
|
last_used = {BROADCAST: time.ticks_ms()}
|
||||||
|
|
||||||
|
|
||||||
|
# ESP_ERR_ESPNOW_EXIST: peer already registered (ignore when adding).
|
||||||
|
ESP_ERR_ESPNOW_EXIST = -12395
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_peer(addr):
|
||||||
|
"""Ensure addr is in the peer list. When at 20 peers, remove the oldest-used (LRU)."""
|
||||||
|
peers = esp.get_peers()
|
||||||
|
peer_macs = [p[0] for p in peers]
|
||||||
|
if addr in peer_macs:
|
||||||
|
return
|
||||||
|
if len(peer_macs) >= MAX_PEERS:
|
||||||
|
# Remove the peer we used least recently (oldest).
|
||||||
|
oldest_mac = None
|
||||||
|
oldest_ts = time.ticks_ms()
|
||||||
|
for mac in peer_macs:
|
||||||
|
if mac == BROADCAST:
|
||||||
|
continue
|
||||||
|
ts = last_used.get(mac, 0)
|
||||||
|
if ts <= oldest_ts:
|
||||||
|
oldest_ts = ts
|
||||||
|
oldest_mac = mac
|
||||||
|
if oldest_mac is not None:
|
||||||
|
esp.del_peer(oldest_mac)
|
||||||
|
last_used.pop(oldest_mac, None)
|
||||||
|
try:
|
||||||
|
esp.add_peer(addr)
|
||||||
|
except OSError as e:
|
||||||
|
if e.args[0] != ESP_ERR_ESPNOW_EXIST:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
print("Starting ESP32 main.py")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if uart.any():
|
||||||
|
data = uart.read()
|
||||||
|
if not data or len(data) < 6:
|
||||||
|
continue
|
||||||
|
print(f"Received data: {data}")
|
||||||
|
addr = data[:6]
|
||||||
|
payload = data[6:]
|
||||||
|
ensure_peer(addr)
|
||||||
|
esp.send(addr, payload)
|
||||||
|
last_used[addr] = time.ticks_ms()
|
||||||
1
led-driver
Submodule
1
led-driver
Submodule
Submodule led-driver added at c42dff8975
1
led-tool
Submodule
1
led-tool
Submodule
Submodule led-tool added at 3844aa9d6a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,25 @@
|
|||||||
import jwt
|
try:
|
||||||
|
import jwt
|
||||||
|
HAS_JWT = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_JWT = False
|
||||||
|
try:
|
||||||
|
import ubinascii
|
||||||
|
except ImportError:
|
||||||
|
import binascii as ubinascii
|
||||||
|
try:
|
||||||
|
import uhashlib as hashlib
|
||||||
|
except ImportError:
|
||||||
|
import hashlib
|
||||||
|
try:
|
||||||
|
import uhmac as hmac
|
||||||
|
except ImportError:
|
||||||
|
try:
|
||||||
|
import hmac
|
||||||
|
except ImportError:
|
||||||
|
hmac = None
|
||||||
|
import json
|
||||||
|
|
||||||
from microdot.microdot import invoke_handler
|
from microdot.microdot import invoke_handler
|
||||||
from microdot.helpers import wraps
|
from microdot.helpers import wraps
|
||||||
|
|
||||||
@@ -125,16 +146,61 @@ class Session:
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
def encode(self, payload, secret_key=None):
|
def encode(self, payload, secret_key=None):
|
||||||
return jwt.encode(payload, secret_key or self.secret_key,
|
"""Encode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
algorithm='HS256')
|
if HAS_JWT:
|
||||||
|
return jwt.encode(payload, secret_key or self.secret_key,
|
||||||
|
algorithm='HS256')
|
||||||
|
else:
|
||||||
|
# Simple encoding for MicroPython: base64(json) + HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
payload_json = json.dumps(payload)
|
||||||
|
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
|
||||||
|
|
||||||
|
# Create HMAC signature
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
return f"{payload_b64}.{signature}"
|
||||||
|
|
||||||
def decode(self, session, secret_key=None):
|
def decode(self, session, secret_key=None):
|
||||||
try:
|
"""Decode session data using JWT if available, otherwise use simple HMAC."""
|
||||||
payload = jwt.decode(session, secret_key or self.secret_key,
|
if HAS_JWT:
|
||||||
algorithms=['HS256'])
|
try:
|
||||||
except jwt.exceptions.PyJWTError: # pragma: no cover
|
payload = jwt.decode(session, secret_key or self.secret_key,
|
||||||
return {}
|
algorithms=['HS256'])
|
||||||
return payload
|
except jwt.exceptions.PyJWTError: # pragma: no cover
|
||||||
|
return {}
|
||||||
|
return payload
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
# Simple decoding for MicroPython
|
||||||
|
if '.' not in session:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
payload_b64, signature = session.rsplit('.', 1)
|
||||||
|
payload_json = ubinascii.a2b_base64(payload_b64).decode()
|
||||||
|
|
||||||
|
# Verify HMAC signature
|
||||||
|
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
|
||||||
|
if hmac:
|
||||||
|
# Use hmac module if available
|
||||||
|
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
|
||||||
|
else:
|
||||||
|
# Fallback: simple SHA256(key + message)
|
||||||
|
h = hashlib.sha256(key + payload_json.encode())
|
||||||
|
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
|
||||||
|
|
||||||
|
if signature != expected_signature:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return json.loads(payload_json)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def with_session(f):
|
def with_session(f):
|
||||||
|
|||||||
4
pytest.ini
Normal file
4
pytest.ini
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_endpoints_pytest.py
|
||||||
|
|
||||||
152
run_web.py
152
run_web.py
@@ -1,152 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Local development web server - imports and runs main.py with port 5000
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
# Add src and lib to path
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
|
|
||||||
|
|
||||||
# Import the main module
|
|
||||||
from src import main as main_module
|
|
||||||
|
|
||||||
# Override the port in the main function
|
|
||||||
async def run_local():
|
|
||||||
"""Run main with port 5000 for local development."""
|
|
||||||
from settings import Settings
|
|
||||||
import gc
|
|
||||||
|
|
||||||
# Mock MicroPython modules for local development
|
|
||||||
class MockMachine:
|
|
||||||
class WDT:
|
|
||||||
def __init__(self, timeout):
|
|
||||||
pass
|
|
||||||
def feed(self):
|
|
||||||
pass
|
|
||||||
import sys as sys_module
|
|
||||||
sys_module.modules['machine'] = MockMachine()
|
|
||||||
|
|
||||||
class MockESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
self.active_value = False
|
|
||||||
self.peers = []
|
|
||||||
def active(self, value):
|
|
||||||
self.active_value = value
|
|
||||||
print(f"[MOCK] ESPNow active: {value}")
|
|
||||||
def add_peer(self, peer):
|
|
||||||
self.peers.append(peer)
|
|
||||||
print(f"[MOCK] Added peer: {peer.hex() if hasattr(peer, 'hex') else peer}")
|
|
||||||
async def asend(self, peer, data):
|
|
||||||
print(f"[MOCK] Would send to {peer.hex() if hasattr(peer, 'hex') else peer}: {data}")
|
|
||||||
|
|
||||||
class MockAIOESPNow:
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
def active(self, value):
|
|
||||||
return MockESPNow()
|
|
||||||
def add_peer(self, peer):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class MockNetwork:
|
|
||||||
class WLAN:
|
|
||||||
def __init__(self, interface):
|
|
||||||
self.interface = interface
|
|
||||||
def active(self, value):
|
|
||||||
print(f"[MOCK] WLAN({self.interface}) active: {value}")
|
|
||||||
STA_IF = 0
|
|
||||||
|
|
||||||
# Replace MicroPython modules with mocks
|
|
||||||
sys_module.modules['aioespnow'] = type('module', (), {'AIOESPNow': MockESPNow})()
|
|
||||||
sys_module.modules['network'] = MockNetwork()
|
|
||||||
|
|
||||||
# Mock gc if needed
|
|
||||||
if not hasattr(gc, 'collect'):
|
|
||||||
class MockGC:
|
|
||||||
def collect(self):
|
|
||||||
pass
|
|
||||||
gc = MockGC()
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
print("Starting LED Controller Web Server (Local Development)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Mock network
|
|
||||||
import network
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
|
||||||
|
|
||||||
# Mock ESPNow
|
|
||||||
import aioespnow
|
|
||||||
e = aioespnow.AIOESPNow()
|
|
||||||
e.active(True)
|
|
||||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
|
||||||
|
|
||||||
from microdot import Microdot, send_file
|
|
||||||
from microdot.websocket import with_websocket
|
|
||||||
|
|
||||||
import controllers.preset as preset
|
|
||||||
import controllers.profile as profile
|
|
||||||
import controllers.group as group
|
|
||||||
import controllers.sequence as sequence
|
|
||||||
import controllers.tab as tab
|
|
||||||
import controllers.palette as palette
|
|
||||||
import controllers.scene as scene
|
|
||||||
|
|
||||||
app = Microdot()
|
|
||||||
|
|
||||||
# Mount model controllers as subroutes
|
|
||||||
app.mount(preset.controller, '/presets')
|
|
||||||
app.mount(profile.controller, '/profiles')
|
|
||||||
app.mount(group.controller, '/groups')
|
|
||||||
app.mount(sequence.controller, '/sequences')
|
|
||||||
app.mount(tab.controller, '/tabs')
|
|
||||||
app.mount(palette.controller, '/palettes')
|
|
||||||
app.mount(scene.controller, '/scenes')
|
|
||||||
|
|
||||||
# Serve index.html at root
|
|
||||||
@app.route('/')
|
|
||||||
def index(request):
|
|
||||||
"""Serve the main web UI."""
|
|
||||||
return send_file('src/templates/index.html')
|
|
||||||
|
|
||||||
# Static file route
|
|
||||||
@app.route("/static/<path:path>")
|
|
||||||
def static_handler(request, path):
|
|
||||||
"""Serve static files."""
|
|
||||||
if '..' in path:
|
|
||||||
return 'Not found', 404
|
|
||||||
return send_file('src/static/' + path)
|
|
||||||
|
|
||||||
@app.route('/ws')
|
|
||||||
@with_websocket
|
|
||||||
async def ws(request, ws):
|
|
||||||
while True:
|
|
||||||
data = await ws.receive()
|
|
||||||
if data:
|
|
||||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
|
||||||
print(data)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Use port 5000 for local development
|
|
||||||
port = 5000
|
|
||||||
print(f"Starting server on http://0.0.0.0:{port}")
|
|
||||||
print(f"Open http://localhost:{port} in your browser")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await app.start_server(host="0.0.0.0", port=port, debug=True)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nShutting down server...")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Change to project root
|
|
||||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
# Override settings path for local development
|
|
||||||
import settings as settings_module
|
|
||||||
settings_module.Settings.SETTINGS_FILE = os.path.join(os.getcwd(), 'settings.json')
|
|
||||||
|
|
||||||
asyncio.run(run_local())
|
|
||||||
19
scripts/build_help_pdf.sh
Executable file
19
scripts/build_help_pdf.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Build docs/help.pdf from docs/help.md.
|
||||||
|
# Requires: pandoc, chromium (headless print-to-PDF).
|
||||||
|
set -eu
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
# HTML next to docs/help.md so relative image paths (e.g. images/help/*.svg) resolve.
|
||||||
|
HTML="$ROOT/docs/.help-print.html"
|
||||||
|
trap 'rm -f "$HTML"' EXIT
|
||||||
|
|
||||||
|
pandoc "$ROOT/docs/help.md" -s \
|
||||||
|
--css="$ROOT/scripts/help-pdf.css" \
|
||||||
|
--metadata title="LED controller — user guide" \
|
||||||
|
-o "$HTML"
|
||||||
|
|
||||||
|
chromium --headless --no-sandbox --disable-gpu \
|
||||||
|
--print-to-pdf="$ROOT/docs/help.pdf" \
|
||||||
|
"file://${HTML}"
|
||||||
|
|
||||||
|
echo "Wrote $ROOT/docs/help.pdf ($(wc -c < "$ROOT/docs/help.pdf") bytes)"
|
||||||
4
scripts/cp-esp32-main.sh
Normal file
4
scripts/cp-esp32-main.sh
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy esp32/main.py to the connected ESP32 as /main.py (single line, no wrap).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
pipenv run mpremote fs cp esp32/main.py :/main.py
|
||||||
96
scripts/help-pdf.css
Normal file
96
scripts/help-pdf.css
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/* Print stylesheet for docs/help.md → PDF (Chromium headless) */
|
||||||
|
@page {
|
||||||
|
margin: 18mm;
|
||||||
|
size: A4;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: "DejaVu Sans", "Liberation Sans", Helvetica, Arial, sans-serif;
|
||||||
|
color: #222;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 1.45rem;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-top: 1.25em;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 1em;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
background: #f3f3f3;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
font-family: "DejaVu Sans Mono", "Liberation Mono", Consolas, monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 0.65em 0.85em;
|
||||||
|
overflow-x: auto;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0.75em 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #bbb;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #1a5276;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #ccc;
|
||||||
|
margin: 1.25em 0;
|
||||||
|
}
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.35em;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images in docs/help.md */
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
p.help-figure-caption {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #555;
|
||||||
|
margin: 0.35em 0 1em 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
20
scripts/install-boot-service.sh
Executable file
20
scripts/install-boot-service.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install systemd service so LED controller starts at boot.
|
||||||
|
# Run once: sudo scripts/install-boot-service.sh
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO="$(pwd)"
|
||||||
|
SERVICE_NAME="led-controller.service"
|
||||||
|
UNIT_PATH="/etc/systemd/system/$SERVICE_NAME"
|
||||||
|
if [ ! -f "scripts/led-controller.service" ]; then
|
||||||
|
echo "Run this script from the repo root."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x scripts/start.sh
|
||||||
|
sudo cp "scripts/led-controller.service" "$UNIT_PATH"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE_NAME"
|
||||||
|
echo "Installed and enabled $SERVICE_NAME"
|
||||||
|
echo "Start now: sudo systemctl start $SERVICE_NAME"
|
||||||
|
echo "Status: sudo systemctl status $SERVICE_NAME"
|
||||||
|
echo "Logs: journalctl -u $SERVICE_NAME -f"
|
||||||
17
scripts/led-controller.service
Normal file
17
scripts/led-controller.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=LED Controller web server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=pi
|
||||||
|
WorkingDirectory=/home/pi/led-controller
|
||||||
|
Environment=PORT=80
|
||||||
|
Environment=PATH=/home/pi/.local/bin:/usr/local/bin:/usr/bin:/bin
|
||||||
|
ExecStart=/bin/bash /home/pi/led-controller/scripts/start.sh
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
35
scripts/setup-port80.sh
Executable file
35
scripts/setup-port80.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Allow the app to bind to port 80 without root.
|
||||||
|
# Run once: sudo scripts/setup-port80.sh (from repo root)
|
||||||
|
# Or: scripts/setup-port80.sh (will prompt for sudo only for setcap)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
REPO_ROOT="$(pwd)"
|
||||||
|
# If run under sudo, use the invoking user's pipenv so the venv is found
|
||||||
|
if [ -n "$SUDO_USER" ]; then
|
||||||
|
VENV="$(sudo -u "$SUDO_USER" bash -c "cd '$REPO_ROOT' && pipenv --venv" 2>/dev/null)" || true
|
||||||
|
else
|
||||||
|
VENV="$(pipenv --venv 2>/dev/null)" || true
|
||||||
|
fi
|
||||||
|
if [ -z "$VENV" ]; then
|
||||||
|
echo "Run 'pipenv install' first, then run this script again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PYTHON="${VENV}/bin/python3"
|
||||||
|
if [ ! -f "$PYTHON" ]; then
|
||||||
|
PYTHON="${VENV}/bin/python"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$PYTHON" ]; then
|
||||||
|
echo "Python not found in venv: $VENV"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Use the real binary (setcap can fail on symlinks or some filesystems)
|
||||||
|
REAL_PYTHON="$(readlink -f "$PYTHON" 2>/dev/null)" || REAL_PYTHON="$PYTHON"
|
||||||
|
if sudo setcap 'cap_net_bind_service=+ep' "$REAL_PYTHON" 2>/dev/null; then
|
||||||
|
echo "OK: port 80 enabled for $REAL_PYTHON"
|
||||||
|
echo "Start the app with: pipenv run run"
|
||||||
|
else
|
||||||
|
echo "setcap failed on $REAL_PYTHON"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
5
scripts/start.sh
Executable file
5
scripts/start.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the LED controller web server (port 80 by default).
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
export PORT="${PORT:-80}"
|
||||||
|
pipenv run run
|
||||||
33
scripts/test-port80.sh
Executable file
33
scripts/test-port80.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test the app on port 80. Run after: sudo scripts/setup-port80.sh
|
||||||
|
# Usage: ./scripts/test-port80.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
APP_URL="${APP_URL:-http://127.0.0.1:80}"
|
||||||
|
|
||||||
|
echo "Starting app on port 80 in background..."
|
||||||
|
pipenv run run &
|
||||||
|
PID=$!
|
||||||
|
trap "kill $PID 2>/dev/null; exit" EXIT
|
||||||
|
|
||||||
|
echo "Waiting for server to start..."
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "$APP_URL/" 2>/dev/null | grep -q 200; then
|
||||||
|
echo "Server is up."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Requesting $APP_URL/ ..."
|
||||||
|
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$APP_URL/")
|
||||||
|
if [ "$CODE" = "200" ]; then
|
||||||
|
echo "OK: GET / returned HTTP $CODE"
|
||||||
|
curl -s "$APP_URL/" | head -5
|
||||||
|
echo "..."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "FAIL: GET / returned HTTP $CODE (expected 200)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"session_secret_key": "06a2a6ac631cc8db0658373b37f7fe922a8a3ed3a27229e1adb5448cb368f2b7"}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,6 @@
|
|||||||
import settings
|
# Boot script (ESP only; no-op on Pi)
|
||||||
import util.wifi as wifi
|
import settings # noqa: F401
|
||||||
from settings import Settings
|
from settings import Settings
|
||||||
|
|
||||||
s = Settings()
|
s = Settings()
|
||||||
|
# AP setup was here when running on ESP; Pi uses system networking.
|
||||||
name = s.get('name', 'led-controller')
|
|
||||||
wifi.ap(name, '')
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
68
src/controllers/device.py
Normal file
68
src/controllers/device.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from microdot import Microdot
|
||||||
|
from models.device import Device
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
devices = Device()
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("")
|
||||||
|
async def list_devices(request):
|
||||||
|
"""List all devices."""
|
||||||
|
devices_data = {}
|
||||||
|
for dev_id in devices.list():
|
||||||
|
d = devices.read(dev_id)
|
||||||
|
if d:
|
||||||
|
devices_data[dev_id] = d
|
||||||
|
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
@controller.get("/<id>")
|
||||||
|
async def get_device(request, id):
|
||||||
|
"""Get a device by ID."""
|
||||||
|
dev = devices.read(id)
|
||||||
|
if dev:
|
||||||
|
return json.dumps(dev), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post("")
|
||||||
|
async def create_device(request):
|
||||||
|
"""Create a new device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
address = data.get("address")
|
||||||
|
default_pattern = data.get("default_pattern")
|
||||||
|
tabs = data.get("tabs")
|
||||||
|
if isinstance(tabs, list):
|
||||||
|
tabs = [str(t) for t in tabs]
|
||||||
|
else:
|
||||||
|
tabs = []
|
||||||
|
dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs)
|
||||||
|
dev = devices.read(dev_id)
|
||||||
|
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put("/<id>")
|
||||||
|
async def update_device(request, id):
|
||||||
|
"""Update a device."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
if "tabs" in data and isinstance(data["tabs"], list):
|
||||||
|
data["tabs"] = [str(t) for t in data["tabs"]]
|
||||||
|
if devices.update(id, data):
|
||||||
|
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@controller.delete("/<id>")
|
||||||
|
async def delete_device(request, id):
|
||||||
|
"""Delete a device."""
|
||||||
|
if devices.delete(id):
|
||||||
|
return json.dumps({"message": "Device deleted successfully"}), 200
|
||||||
|
return json.dumps({"error": "Device not found"}), 404
|
||||||
@@ -8,14 +8,18 @@ palettes = Palette()
|
|||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def list_palettes(request):
|
async def list_palettes(request):
|
||||||
"""List all palettes."""
|
"""List all palettes."""
|
||||||
return json.dumps(palettes), 200, {'Content-Type': 'application/json'}
|
data = {}
|
||||||
|
for pid in palettes.list():
|
||||||
|
colors = palettes.read(pid)
|
||||||
|
data[pid] = colors
|
||||||
|
return json.dumps(data), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
async def get_palette(request, id):
|
async def get_palette(request, id):
|
||||||
"""Get a specific palette by ID."""
|
"""Get a specific palette by ID."""
|
||||||
palette = palettes.read(id)
|
if str(id) in palettes:
|
||||||
if palette:
|
palette = palettes.read(id)
|
||||||
return json.dumps(palette), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
@@ -23,12 +27,11 @@ async def create_palette(request):
|
|||||||
"""Create a new palette."""
|
"""Create a new palette."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
name = data.get("name", "")
|
|
||||||
colors = data.get("colors", None)
|
colors = data.get("colors", None)
|
||||||
palette_id = palettes.create(name, colors)
|
# Palette no longer needs a name; only colors are stored.
|
||||||
if data:
|
palette_id = palettes.create("", colors)
|
||||||
palettes.update(palette_id, data)
|
created_colors = palettes.read(palette_id) or []
|
||||||
return json.dumps(palettes.read(palette_id)), 201, {'Content-Type': 'application/json'}
|
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@@ -36,9 +39,13 @@ async def create_palette(request):
|
|||||||
async def update_palette(request, id):
|
async def update_palette(request, id):
|
||||||
"""Update an existing palette."""
|
"""Update an existing palette."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
data = request.json or {}
|
||||||
|
# Ignore any name field; only colors are relevant.
|
||||||
|
if "name" in data:
|
||||||
|
data.pop("name", None)
|
||||||
if palettes.update(id, data):
|
if palettes.update(id, data):
|
||||||
return json.dumps(palettes.read(id)), 200, {'Content-Type': 'application/json'}
|
colors = palettes.read(id) or []
|
||||||
|
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Palette not found"}), 404
|
return json.dumps({"error": "Palette not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from models.pattern import Pattern
|
from models.pattern import Pattern
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
patterns = Pattern()
|
patterns = Pattern()
|
||||||
|
|
||||||
|
def load_pattern_definitions():
|
||||||
|
"""Load pattern definitions from pattern.json file."""
|
||||||
|
try:
|
||||||
|
# Try different paths for local development vs MicroPython
|
||||||
|
paths = ['db/pattern.json', 'pattern.json', '/db/pattern.json']
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading pattern.json: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@controller.get('/definitions')
|
||||||
|
async def get_pattern_definitions(request):
|
||||||
|
"""Get pattern definitions from pattern.json."""
|
||||||
|
definitions = load_pattern_definitions()
|
||||||
|
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def list_patterns(request):
|
async def list_patterns(request):
|
||||||
@@ -25,11 +47,23 @@ async def get_pattern(request, id):
|
|||||||
async def create_pattern(request):
|
async def create_pattern(request):
|
||||||
"""Create a new pattern."""
|
"""Create a new pattern."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
payload = request.json or {}
|
||||||
name = data.get("name", "")
|
name = payload.get("name", "")
|
||||||
pattern_id = patterns.create(name, data.get("data", {}))
|
pattern_data = payload.get("data", {})
|
||||||
if data:
|
|
||||||
patterns.update(pattern_id, data)
|
# IMPORTANT:
|
||||||
|
# `patterns.create()` stores `pattern_data` as the underlying dict value.
|
||||||
|
# If we then call `patterns.update(pattern_id, payload)` with the full
|
||||||
|
# request object, it may assign `payload["data"]` back onto that same
|
||||||
|
# dict object, creating a circular reference (json.dumps fails).
|
||||||
|
pattern_id = patterns.create(name, pattern_data)
|
||||||
|
|
||||||
|
# Only merge "extra" metadata fields (anything except name/data).
|
||||||
|
extra = dict(payload)
|
||||||
|
extra.pop("name", None)
|
||||||
|
extra.pop("data", None)
|
||||||
|
if extra:
|
||||||
|
patterns.update(pattern_id, extra)
|
||||||
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
|
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
@@ -1,49 +1,223 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
|
from microdot.session import with_session
|
||||||
from models.preset import Preset
|
from models.preset import Preset
|
||||||
|
from models.profile import Profile
|
||||||
|
from models.transport import get_current_sender
|
||||||
|
from util.espnow_message import build_message, build_preset_dict
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
presets = Preset()
|
presets = Preset()
|
||||||
|
profiles = Profile()
|
||||||
|
|
||||||
|
def get_current_profile_id(session=None):
|
||||||
|
"""Get the current active profile ID from session or fallback to first."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
session_profile = None
|
||||||
|
if session is not None:
|
||||||
|
session_profile = session.get('current_profile')
|
||||||
|
if session_profile and session_profile in profile_list:
|
||||||
|
return session_profile
|
||||||
|
if profile_list:
|
||||||
|
return profile_list[0]
|
||||||
|
return None
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def list_presets(request):
|
@with_session
|
||||||
"""List all presets."""
|
async def list_presets(request, session):
|
||||||
return json.dumps(presets), 200, {'Content-Type': 'application/json'}
|
"""List presets for the current profile."""
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({}), 200, {'Content-Type': 'application/json'}
|
||||||
|
scoped = {
|
||||||
|
pid: pdata for pid, pdata in presets.items()
|
||||||
|
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
|
||||||
|
}
|
||||||
|
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<preset_id>')
|
||||||
async def get_preset(request, id):
|
@with_session
|
||||||
"""Get a specific preset by ID."""
|
async def get_preset(request, session, preset_id):
|
||||||
preset = presets.read(id)
|
"""Get a specific preset by ID (current profile only)."""
|
||||||
if preset:
|
preset = presets.read(preset_id)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if preset and str(preset.get("profile_id")) == str(current_profile_id):
|
||||||
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
async def create_preset(request):
|
@with_session
|
||||||
"""Create a new preset."""
|
async def create_preset(request, session):
|
||||||
|
"""Create a new preset for the current profile."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
try:
|
||||||
preset_id = presets.create()
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not current_profile_id:
|
||||||
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
preset_id = presets.create(current_profile_id)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
if presets.update(preset_id, data):
|
if presets.update(preset_id, data):
|
||||||
return json.dumps(presets.read(preset_id)), 201, {'Content-Type': 'application/json'}
|
preset_data = presets.read(preset_id)
|
||||||
|
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Failed to create preset"}), 400
|
return json.dumps({"error": "Failed to create preset"}), 400
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.put('/<id>')
|
@controller.put('/<preset_id>')
|
||||||
async def update_preset(request, id):
|
@with_session
|
||||||
"""Update an existing preset."""
|
async def update_preset(request, session, preset_id):
|
||||||
|
"""Update an existing preset (current profile only)."""
|
||||||
try:
|
try:
|
||||||
data = request.json
|
preset = presets.read(preset_id)
|
||||||
if presets.update(id, data):
|
current_profile_id = get_current_profile_id(session)
|
||||||
return json.dumps(presets.read(id)), 200, {'Content-Type': 'application/json'}
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
data = {}
|
||||||
|
data = dict(data)
|
||||||
|
data["profile_id"] = str(current_profile_id)
|
||||||
|
if presets.update(preset_id, data):
|
||||||
|
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete('/<preset_id>')
|
||||||
async def delete_preset(request, id):
|
@with_session
|
||||||
"""Delete a preset."""
|
async def delete_preset(request, *args, **kwargs):
|
||||||
if presets.delete(id):
|
"""Delete a preset (current profile only)."""
|
||||||
|
# Be tolerant of wrapper/arg-order variations.
|
||||||
|
session = None
|
||||||
|
preset_id = None
|
||||||
|
if len(args) > 0:
|
||||||
|
session = args[0]
|
||||||
|
if len(args) > 1:
|
||||||
|
preset_id = args[1]
|
||||||
|
if 'session' in kwargs and kwargs.get('session') is not None:
|
||||||
|
session = kwargs.get('session')
|
||||||
|
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
|
||||||
|
preset_id = kwargs.get('preset_id')
|
||||||
|
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
|
||||||
|
preset_id = kwargs.get('id')
|
||||||
|
if preset_id is None:
|
||||||
|
return json.dumps({"error": "Preset ID is required"}), 400
|
||||||
|
preset = presets.read(preset_id)
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
|
||||||
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
if presets.delete(preset_id):
|
||||||
return json.dumps({"message": "Preset deleted successfully"}), 200
|
return json.dumps({"message": "Preset deleted successfully"}), 200
|
||||||
return json.dumps({"error": "Preset not found"}), 404
|
return json.dumps({"error": "Preset not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@controller.post('/send')
|
||||||
|
@with_session
|
||||||
|
async def send_presets(request, session):
|
||||||
|
"""
|
||||||
|
Send one or more presets to the LED driver (via serial transport).
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
{"preset_ids": ["1", "2", ...]} or {"ids": ["1", "2", ...]}
|
||||||
|
|
||||||
|
The controller looks up each preset, converts to API format, chunks into
|
||||||
|
<= 240-byte messages, and sends them over the configured transport.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
preset_ids = data.get('preset_ids') or data.get('ids')
|
||||||
|
if not isinstance(preset_ids, list) or not preset_ids:
|
||||||
|
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
|
||||||
|
save_flag = data.get('save', True)
|
||||||
|
save_flag = bool(save_flag)
|
||||||
|
default_id = data.get('default')
|
||||||
|
# Optional 12-char hex MAC to send to one device; omit for default (e.g. broadcast).
|
||||||
|
destination_mac = data.get('destination_mac') or data.get('to')
|
||||||
|
|
||||||
|
# Build API-compliant preset map keyed by preset ID, include name
|
||||||
|
current_profile_id = get_current_profile_id(session)
|
||||||
|
presets_by_name = {}
|
||||||
|
for pid in preset_ids:
|
||||||
|
preset_data = presets.read(str(pid))
|
||||||
|
if not preset_data:
|
||||||
|
continue
|
||||||
|
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||||
|
continue
|
||||||
|
preset_key = str(pid)
|
||||||
|
preset_payload = build_preset_dict(preset_data)
|
||||||
|
preset_payload["name"] = preset_data.get("name", "")
|
||||||
|
presets_by_name[preset_key] = preset_payload
|
||||||
|
|
||||||
|
if not presets_by_name:
|
||||||
|
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
if default_id is not None and str(default_id) not in presets_by_name:
|
||||||
|
default_id = None
|
||||||
|
|
||||||
|
sender = get_current_sender()
|
||||||
|
if not sender:
|
||||||
|
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
async def send_chunk(chunk_presets, is_last):
|
||||||
|
# Save/default should only be sent with the final presets chunk.
|
||||||
|
msg = build_message(
|
||||||
|
presets=chunk_presets,
|
||||||
|
save=save_flag and is_last,
|
||||||
|
default=default_id if is_last else None,
|
||||||
|
)
|
||||||
|
await sender.send(msg, addr=destination_mac)
|
||||||
|
|
||||||
|
MAX_BYTES = 240
|
||||||
|
send_delay_s = 0.1
|
||||||
|
entries = list(presets_by_name.items())
|
||||||
|
total_presets = len(entries)
|
||||||
|
messages_sent = 0
|
||||||
|
|
||||||
|
batch = {}
|
||||||
|
last_msg = None
|
||||||
|
for name, preset_obj in entries:
|
||||||
|
test_batch = dict(batch)
|
||||||
|
test_batch[name] = preset_obj
|
||||||
|
test_msg = build_message(presets=test_batch, save=save_flag, default=default_id)
|
||||||
|
size = len(test_msg)
|
||||||
|
|
||||||
|
if size <= MAX_BYTES or not batch:
|
||||||
|
batch = test_batch
|
||||||
|
last_msg = test_msg
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await send_chunk(batch, False)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
await asyncio.sleep(send_delay_s)
|
||||||
|
messages_sent += 1
|
||||||
|
batch = {name: preset_obj}
|
||||||
|
last_msg = build_message(presets=batch, save=save_flag, default=default_id)
|
||||||
|
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
await send_chunk(batch, True)
|
||||||
|
except Exception:
|
||||||
|
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||||
|
await asyncio.sleep(send_delay_s)
|
||||||
|
messages_sent += 1
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "Presets sent",
|
||||||
|
"presets_sent": total_presets,
|
||||||
|
"messages_sent": messages_sent
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,41 @@
|
|||||||
from microdot import Microdot
|
from microdot import Microdot
|
||||||
from microdot.session import with_session
|
from microdot.session import with_session
|
||||||
from models.profile import Profile
|
from models.profile import Profile
|
||||||
|
from models.tab import Tab
|
||||||
|
from models.preset import Preset
|
||||||
import json
|
import json
|
||||||
|
|
||||||
controller = Microdot()
|
controller = Microdot()
|
||||||
profiles = Profile()
|
profiles = Profile()
|
||||||
|
tabs = Tab()
|
||||||
|
presets = Preset()
|
||||||
|
|
||||||
@controller.get('')
|
@controller.get('')
|
||||||
async def list_profiles(request):
|
@with_session
|
||||||
"""List all profiles."""
|
async def list_profiles(request, session):
|
||||||
return json.dumps(profiles), 200, {'Content-Type': 'application/json'}
|
"""List all profiles with current profile info."""
|
||||||
|
profile_list = profiles.list()
|
||||||
|
current_id = session.get('current_profile')
|
||||||
|
if current_id and current_id not in profile_list:
|
||||||
|
current_id = None
|
||||||
|
|
||||||
|
# If no current profile in session, use first one
|
||||||
|
if not current_id and profile_list:
|
||||||
|
current_id = profile_list[0]
|
||||||
|
session['current_profile'] = str(current_id)
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
# Build profiles object
|
||||||
|
profiles_data = {}
|
||||||
|
for profile_id in profile_list:
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
if profile_data:
|
||||||
|
profiles_data[profile_id] = profile_data
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"profiles": profiles_data,
|
||||||
|
"current_profile_id": current_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.get('/current')
|
@controller.get('/current')
|
||||||
@with_session
|
@with_session
|
||||||
@@ -17,6 +43,8 @@ async def get_current_profile(request, session):
|
|||||||
"""Get the current profile ID from session (or fallback)."""
|
"""Get the current profile ID from session (or fallback)."""
|
||||||
profile_list = profiles.list()
|
profile_list = profiles.list()
|
||||||
current_id = session.get('current_profile')
|
current_id = session.get('current_profile')
|
||||||
|
if current_id and current_id not in profile_list:
|
||||||
|
current_id = None
|
||||||
if not current_id and profile_list:
|
if not current_id and profile_list:
|
||||||
current_id = profile_list[0]
|
current_id = profile_list[0]
|
||||||
session['current_profile'] = str(current_id)
|
session['current_profile'] = str(current_id)
|
||||||
@@ -27,8 +55,13 @@ async def get_current_profile(request, session):
|
|||||||
return json.dumps({"error": "No profile available"}), 404
|
return json.dumps({"error": "No profile available"}), 404
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
async def get_profile(request, id):
|
@with_session
|
||||||
|
async def get_profile(request, id, session):
|
||||||
"""Get a specific profile by ID."""
|
"""Get a specific profile by ID."""
|
||||||
|
# Handle 'current' as a special case
|
||||||
|
if id == 'current':
|
||||||
|
return await get_current_profile(request, session)
|
||||||
|
|
||||||
profile = profiles.read(id)
|
profile = profiles.read(id)
|
||||||
if profile:
|
if profile:
|
||||||
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
|
||||||
@@ -48,12 +81,231 @@ async def apply_profile(request, session, id):
|
|||||||
async def create_profile(request):
|
async def create_profile(request):
|
||||||
"""Create a new profile."""
|
"""Create a new profile."""
|
||||||
try:
|
try:
|
||||||
data = request.json or {}
|
data = dict(request.json or {})
|
||||||
name = data.get("name", "")
|
name = data.get("name", "")
|
||||||
|
seed_raw = data.get("seed_dj_tab", False)
|
||||||
|
if isinstance(seed_raw, str):
|
||||||
|
seed_dj_tab = seed_raw.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
else:
|
||||||
|
seed_dj_tab = bool(seed_raw)
|
||||||
|
# Request-only flag: do not persist on profile records.
|
||||||
|
data.pop("seed_dj_tab", None)
|
||||||
profile_id = profiles.create(name)
|
profile_id = profiles.create(name)
|
||||||
|
# Avoid persisting request-only fields.
|
||||||
|
data.pop("name", None)
|
||||||
if data:
|
if data:
|
||||||
profiles.update(profile_id, data)
|
profiles.update(profile_id, data)
|
||||||
return json.dumps(profiles.read(profile_id)), 201, {'Content-Type': 'application/json'}
|
|
||||||
|
# New profiles always start with a default tab pre-populated with starter presets.
|
||||||
|
default_preset_ids = []
|
||||||
|
default_preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "on",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#FFFFFF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "off",
|
||||||
|
"pattern": "off",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 0,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 100,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#FF0000", "#00FF00", "#0000FF"],
|
||||||
|
"brightness": 255,
|
||||||
|
"delay": 500,
|
||||||
|
"auto": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in default_preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
default_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
default_tab_id = tabs.create(name="default", names=["1"], presets=[default_preset_ids])
|
||||||
|
tabs.update(default_tab_id, {
|
||||||
|
"presets_flat": default_preset_ids,
|
||||||
|
"default_preset": default_preset_ids[0] if default_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile = profiles.read(profile_id) or {}
|
||||||
|
profile_tabs = profile.get("tabs", []) if isinstance(profile.get("tabs", []), list) else []
|
||||||
|
profile_tabs.append(str(default_tab_id))
|
||||||
|
|
||||||
|
if seed_dj_tab:
|
||||||
|
# Seed a DJ-focused tab with three starter presets.
|
||||||
|
seeded_preset_ids = []
|
||||||
|
preset_defs = [
|
||||||
|
{
|
||||||
|
"name": "DJ Rainbow",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": [],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 60,
|
||||||
|
"n1": 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Single Color",
|
||||||
|
"pattern": "on",
|
||||||
|
"colors": ["#ff00ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DJ Transition",
|
||||||
|
"pattern": "transition",
|
||||||
|
"colors": ["#ff0000", "#00ff00", "#0000ff"],
|
||||||
|
"brightness": 220,
|
||||||
|
"delay": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for preset_data in preset_defs:
|
||||||
|
pid = presets.create(profile_id)
|
||||||
|
presets.update(pid, preset_data)
|
||||||
|
seeded_preset_ids.append(str(pid))
|
||||||
|
|
||||||
|
dj_tab_id = tabs.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
|
||||||
|
tabs.update(dj_tab_id, {
|
||||||
|
"presets_flat": seeded_preset_ids,
|
||||||
|
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile_tabs.append(str(dj_tab_id))
|
||||||
|
|
||||||
|
profiles.update(profile_id, {"tabs": profile_tabs})
|
||||||
|
|
||||||
|
profile_data = profiles.read(profile_id)
|
||||||
|
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
@controller.post('/<id>/clone')
|
||||||
|
async def clone_profile(request, id):
|
||||||
|
"""Clone an existing profile along with its tabs and palette."""
|
||||||
|
try:
|
||||||
|
source = profiles.read(id)
|
||||||
|
if not source:
|
||||||
|
return json.dumps({"error": "Profile not found"}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
source_name = source.get("name") or f"Profile {id}"
|
||||||
|
new_name = data.get("name") or source_name
|
||||||
|
profile_type = source.get("type", "tabs")
|
||||||
|
|
||||||
|
def allocate_id(model, cache):
|
||||||
|
if "next" not in cache:
|
||||||
|
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
|
||||||
|
cache["next"] = max_id + 1
|
||||||
|
next_id = str(cache["next"])
|
||||||
|
cache["next"] += 1
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def map_preset_container(value, id_map, preset_cache, new_profile_id, new_presets):
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets) for v in value]
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
preset_id = str(value)
|
||||||
|
if preset_id in id_map:
|
||||||
|
return id_map[preset_id]
|
||||||
|
preset_data = presets.read(preset_id)
|
||||||
|
if not preset_data:
|
||||||
|
return None
|
||||||
|
new_preset_id = allocate_id(presets, preset_cache)
|
||||||
|
clone_data = dict(preset_data)
|
||||||
|
clone_data["profile_id"] = str(new_profile_id)
|
||||||
|
new_presets[new_preset_id] = clone_data
|
||||||
|
id_map[preset_id] = new_preset_id
|
||||||
|
return new_preset_id
|
||||||
|
|
||||||
|
# Prepare new IDs without writing until everything is ready.
|
||||||
|
profile_cache = {}
|
||||||
|
palette_cache = {}
|
||||||
|
tab_cache = {}
|
||||||
|
preset_cache = {}
|
||||||
|
|
||||||
|
new_profile_id = allocate_id(profiles, profile_cache)
|
||||||
|
new_palette_id = allocate_id(profiles._palette_model, palette_cache)
|
||||||
|
|
||||||
|
# Clone palette colors into the new profile's palette
|
||||||
|
src_palette_id = source.get("palette_id")
|
||||||
|
palette_colors = []
|
||||||
|
if src_palette_id:
|
||||||
|
try:
|
||||||
|
palette_colors = profiles._palette_model.read(src_palette_id)
|
||||||
|
except Exception:
|
||||||
|
palette_colors = []
|
||||||
|
|
||||||
|
# Clone tabs and presets used by those tabs
|
||||||
|
source_tabs = source.get("tabs")
|
||||||
|
if not isinstance(source_tabs, list) or len(source_tabs) == 0:
|
||||||
|
source_tabs = source.get("tab_order", [])
|
||||||
|
source_tabs = source_tabs or []
|
||||||
|
cloned_tab_ids = []
|
||||||
|
preset_id_map = {}
|
||||||
|
new_tabs = {}
|
||||||
|
new_presets = {}
|
||||||
|
for tab_id in source_tabs:
|
||||||
|
tab = tabs.read(tab_id)
|
||||||
|
if not tab:
|
||||||
|
continue
|
||||||
|
tab_name = tab.get("name") or f"Tab {tab_id}"
|
||||||
|
clone_name = tab_name
|
||||||
|
mapped_presets = map_preset_container(tab.get("presets"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||||
|
clone_id = allocate_id(tabs, tab_cache)
|
||||||
|
clone_data = {
|
||||||
|
"name": clone_name,
|
||||||
|
"names": tab.get("names") or [],
|
||||||
|
"presets": mapped_presets if mapped_presets is not None else []
|
||||||
|
}
|
||||||
|
extra = {k: v for k, v in tab.items() if k not in ("name", "names", "presets")}
|
||||||
|
if "presets_flat" in extra:
|
||||||
|
extra["presets_flat"] = map_preset_container(extra.get("presets_flat"), preset_id_map, preset_cache, new_profile_id, new_presets)
|
||||||
|
if extra:
|
||||||
|
clone_data.update(extra)
|
||||||
|
new_tabs[clone_id] = clone_data
|
||||||
|
cloned_tab_ids.append(clone_id)
|
||||||
|
|
||||||
|
new_profile_data = {
|
||||||
|
"name": new_name,
|
||||||
|
"type": profile_type,
|
||||||
|
"tabs": cloned_tab_ids,
|
||||||
|
"scenes": list(source.get("scenes", [])) if isinstance(source.get("scenes", []), list) else [],
|
||||||
|
"palette_id": str(new_palette_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commit all changes and save once per model.
|
||||||
|
profiles._palette_model[str(new_palette_id)] = list(palette_colors) if palette_colors else []
|
||||||
|
for pid, pdata in new_presets.items():
|
||||||
|
presets[pid] = pdata
|
||||||
|
for tid, tdata in new_tabs.items():
|
||||||
|
tabs[tid] = tdata
|
||||||
|
profiles[str(new_profile_id)] = new_profile_data
|
||||||
|
|
||||||
|
profiles._palette_model.save()
|
||||||
|
presets.save()
|
||||||
|
tabs.save()
|
||||||
|
profiles.save()
|
||||||
|
|
||||||
|
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|
||||||
|
|||||||
87
src/controllers/settings.py
Normal file
87
src/controllers/settings.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from microdot import Microdot, send_file
|
||||||
|
from settings import Settings
|
||||||
|
import json
|
||||||
|
|
||||||
|
controller = Microdot()
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
async def get_settings(request):
|
||||||
|
"""Get all settings."""
|
||||||
|
# Settings is already a dict subclass; avoid dict() wrapper which can
|
||||||
|
# trigger MicroPython's "dict update sequence has wrong length" quirk.
|
||||||
|
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.get('/wifi/ap')
|
||||||
|
async def get_ap_config(request):
|
||||||
|
"""Get saved AP configuration (Pi: no in-device AP)."""
|
||||||
|
config = {
|
||||||
|
'saved_ssid': settings.get('wifi_ap_ssid'),
|
||||||
|
'saved_password': settings.get('wifi_ap_password'),
|
||||||
|
'saved_channel': settings.get('wifi_ap_channel'),
|
||||||
|
'active': False,
|
||||||
|
}
|
||||||
|
return json.dumps(config), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
@controller.post('/wifi/ap')
|
||||||
|
async def configure_ap(request):
|
||||||
|
"""Save AP configuration to settings (Pi: no in-device AP)."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
ssid = data.get('ssid')
|
||||||
|
password = data.get('password', '')
|
||||||
|
channel = data.get('channel')
|
||||||
|
|
||||||
|
if not ssid:
|
||||||
|
return json.dumps({"error": "SSID is required"}), 400
|
||||||
|
|
||||||
|
# Validate channel (1-11 for 2.4GHz)
|
||||||
|
if channel is not None:
|
||||||
|
channel = int(channel)
|
||||||
|
if channel < 1 or channel > 11:
|
||||||
|
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
|
||||||
|
|
||||||
|
settings['wifi_ap_ssid'] = ssid
|
||||||
|
settings['wifi_ap_password'] = password
|
||||||
|
if channel is not None:
|
||||||
|
settings['wifi_ap_channel'] = channel
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"message": "AP settings saved",
|
||||||
|
"ssid": ssid,
|
||||||
|
"channel": channel
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500
|
||||||
|
|
||||||
|
def _validate_wifi_channel(value):
|
||||||
|
"""Return int 1–11 or raise ValueError."""
|
||||||
|
ch = int(value)
|
||||||
|
if ch < 1 or ch > 11:
|
||||||
|
raise ValueError("wifi_channel must be between 1 and 11")
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
@controller.put('/settings')
|
||||||
|
async def update_settings(request):
|
||||||
|
"""Update general settings."""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
for key, value in data.items():
|
||||||
|
if key == 'wifi_channel' and value is not None:
|
||||||
|
settings[key] = _validate_wifi_channel(value)
|
||||||
|
else:
|
||||||
|
settings[key] = value
|
||||||
|
settings.save()
|
||||||
|
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
|
except ValueError as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@controller.get('/page')
|
||||||
|
async def settings_page(request):
|
||||||
|
"""Serve the settings page."""
|
||||||
|
return send_file('templates/settings.html')
|
||||||
|
|
||||||
@@ -33,13 +33,13 @@ def get_profile_tab_order(profile_id):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def get_current_tab_id(request, session=None):
|
def get_current_tab_id(request, session=None):
|
||||||
"""Get the current tab ID from session."""
|
"""Get the current tab ID from cookie."""
|
||||||
if session:
|
# Read from cookie first
|
||||||
current_tab = session.get('current_tab')
|
current_tab = request.cookies.get('current_tab')
|
||||||
if current_tab:
|
if current_tab:
|
||||||
return current_tab
|
return current_tab
|
||||||
|
|
||||||
# Fallback to first tab in current profile if no session
|
# Fallback to first tab in current profile
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
if profile_id:
|
if profile_id:
|
||||||
profile = profiles.read(profile_id)
|
profile = profiles.read(profile_id)
|
||||||
@@ -50,16 +50,8 @@ def get_current_tab_id(request, session=None):
|
|||||||
return tabs_list[0]
|
return tabs_list[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@controller.get('')
|
def _render_tabs_list_fragment(request, session):
|
||||||
async def list_tabs(request):
|
"""Helper function to render tabs list HTML fragment."""
|
||||||
"""List all tabs."""
|
|
||||||
return json.dumps(tabs), 200, {'Content-Type': 'application/json'}
|
|
||||||
|
|
||||||
# HTML Fragment endpoints for htmx - must be before /<id> route
|
|
||||||
@controller.get('/list-fragment')
|
|
||||||
@with_session
|
|
||||||
async def tabs_list_fragment(request, session):
|
|
||||||
"""Return HTML fragment for the tabs list."""
|
|
||||||
profile_id = get_current_profile_id(session)
|
profile_id = get_current_profile_id(session)
|
||||||
# #region agent log
|
# #region agent log
|
||||||
try:
|
try:
|
||||||
@@ -69,7 +61,7 @@ async def tabs_list_fragment(request, session):
|
|||||||
"sessionId": "debug-session",
|
"sessionId": "debug-session",
|
||||||
"runId": "tabs-pre-fix",
|
"runId": "tabs-pre-fix",
|
||||||
"hypothesisId": "H1",
|
"hypothesisId": "H1",
|
||||||
"location": "src/controllers/tab.py:tabs_list_fragment",
|
"location": "src/controllers/tab.py:_render_tabs_list_fragment",
|
||||||
"message": "tabs list fragment",
|
"message": "tabs list fragment",
|
||||||
"data": {
|
"data": {
|
||||||
"profile_id": profile_id,
|
"profile_id": profile_id,
|
||||||
@@ -106,49 +98,18 @@ async def tabs_list_fragment(request, session):
|
|||||||
html += '</div>'
|
html += '</div>'
|
||||||
return html, 200, {'Content-Type': 'text/html'}
|
return html, 200, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
@controller.get('/create-form-fragment')
|
def _render_tab_content_fragment(request, session, id):
|
||||||
async def create_tab_form_fragment(request):
|
"""Helper function to render tab content HTML fragment."""
|
||||||
"""Return the create tab form HTML fragment."""
|
|
||||||
html = '''
|
|
||||||
<h2>Add New Tab</h2>
|
|
||||||
<form hx-post="/tabs"
|
|
||||||
hx-target="#tabs-list"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-headers='{"Accept": "text/html"}'
|
|
||||||
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
|
|
||||||
<label>Tab Name:</label>
|
|
||||||
<input type="text" name="name" placeholder="Enter tab name" required>
|
|
||||||
<label>Device IDs (comma-separated):</label>
|
|
||||||
<input type="text" name="ids" placeholder="1,2,3" value="1">
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
'''
|
|
||||||
return html, 200, {'Content-Type': 'text/html'}
|
|
||||||
|
|
||||||
@controller.get('/current')
|
|
||||||
@with_session
|
|
||||||
async def get_current_tab(request, session):
|
|
||||||
"""Get the current tab from session."""
|
|
||||||
current_tab_id = get_current_tab_id(request, session)
|
|
||||||
if not current_tab_id:
|
|
||||||
accept_header = request.headers.get('Accept', '')
|
|
||||||
wants_html = 'text/html' in accept_header
|
|
||||||
if wants_html:
|
|
||||||
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
|
|
||||||
return json.dumps({"error": "No current tab set"}), 404
|
|
||||||
|
|
||||||
return await tab_content_fragment.__wrapped__(request, session, current_tab_id)
|
|
||||||
|
|
||||||
@controller.get('/<id>/content-fragment')
|
|
||||||
@with_session
|
|
||||||
async def tab_content_fragment(request, session, id):
|
|
||||||
"""Return HTML fragment for tab content."""
|
|
||||||
# Handle 'current' as a special case
|
# Handle 'current' as a special case
|
||||||
if id == 'current':
|
if id == 'current':
|
||||||
return await get_current_tab(request, session)
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if not current_tab_id:
|
||||||
|
accept_header = request.headers.get('Accept', '')
|
||||||
|
wants_html = 'text/html' in accept_header
|
||||||
|
if wants_html:
|
||||||
|
return '<div class="error">No current tab set</div>', 404, {'Content-Type': 'text/html'}
|
||||||
|
return json.dumps({"error": "No current tab set"}), 404
|
||||||
|
id = current_tab_id
|
||||||
|
|
||||||
tab = tabs.read(id)
|
tab = tabs.read(id)
|
||||||
if not tab:
|
if not tab:
|
||||||
@@ -167,9 +128,7 @@ async def tab_content_fragment(request, session, id):
|
|||||||
html = (
|
html = (
|
||||||
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
'<div class="presets-section" data-tab-id="' + str(id) + '">'
|
||||||
'<h3>Presets</h3>'
|
'<h3>Presets</h3>'
|
||||||
'<div class="profiles-actions" style="margin-bottom: 1rem;">'
|
'<div class="profiles-actions" style="margin-bottom: 1rem;"></div>'
|
||||||
'<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>'
|
|
||||||
'</div>'
|
|
||||||
'<div id="presets-list-tab" class="presets-list">'
|
'<div id="presets-list-tab" class="presets-list">'
|
||||||
'<!-- Presets will be loaded here -->'
|
'<!-- Presets will be loaded here -->'
|
||||||
'</div>'
|
'</div>'
|
||||||
@@ -177,6 +136,62 @@ async def tab_content_fragment(request, session, id):
|
|||||||
)
|
)
|
||||||
return html, 200, {'Content-Type': 'text/html'}
|
return html, 200, {'Content-Type': 'text/html'}
|
||||||
|
|
||||||
|
@controller.get('')
|
||||||
|
@with_session
|
||||||
|
async def list_tabs(request, session):
|
||||||
|
"""List all tabs with current tab info."""
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
|
||||||
|
# Get tab order for current profile
|
||||||
|
tab_order = get_profile_tab_order(profile_id) if profile_id else []
|
||||||
|
|
||||||
|
# Build tabs list with metadata
|
||||||
|
tabs_data = {}
|
||||||
|
for tab_id in tabs.list():
|
||||||
|
tab_data = tabs.read(tab_id)
|
||||||
|
if tab_data:
|
||||||
|
tabs_data[tab_id] = tab_data
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"tabs": tabs_data,
|
||||||
|
"tab_order": tab_order,
|
||||||
|
"current_tab_id": current_tab_id,
|
||||||
|
"profile_id": profile_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
# Get current tab - returns JSON with tab data and content info
|
||||||
|
@controller.get('/current')
|
||||||
|
@with_session
|
||||||
|
async def get_current_tab(request, session):
|
||||||
|
"""Get the current tab from session."""
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if not current_tab_id:
|
||||||
|
return json.dumps({"error": "No current tab set", "tab": None, "tab_id": None}), 404
|
||||||
|
|
||||||
|
tab = tabs.read(current_tab_id)
|
||||||
|
if tab:
|
||||||
|
return json.dumps({
|
||||||
|
"tab": tab,
|
||||||
|
"tab_id": current_tab_id
|
||||||
|
}), 200, {'Content-Type': 'application/json'}
|
||||||
|
return json.dumps({"error": "Tab not found", "tab": None, "tab_id": None}), 404
|
||||||
|
|
||||||
|
@controller.post('/<id>/set-current')
|
||||||
|
async def set_current_tab(request, id):
|
||||||
|
"""Set a tab as the current tab in cookie."""
|
||||||
|
tab = tabs.read(id)
|
||||||
|
if not tab:
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
|
||||||
|
# Set cookie with current tab
|
||||||
|
response_data = json.dumps({"message": "Current tab set", "tab_id": id})
|
||||||
|
response = response_data, 200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': f'current_tab={id}; Path=/; Max-Age=31536000' # 1 year expiry
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
@controller.get('/<id>')
|
@controller.get('/<id>')
|
||||||
async def get_tab(request, id):
|
async def get_tab(request, id):
|
||||||
"""Get a specific tab by ID."""
|
"""Get a specific tab by ID."""
|
||||||
@@ -198,84 +213,60 @@ async def update_tab(request, id):
|
|||||||
|
|
||||||
@controller.delete('/<id>')
|
@controller.delete('/<id>')
|
||||||
@with_session
|
@with_session
|
||||||
async def delete_tab(request, id, session):
|
async def delete_tab(request, session, id):
|
||||||
"""Delete a tab."""
|
"""Delete a tab."""
|
||||||
# Check if this is an htmx request (wants HTML fragment)
|
try:
|
||||||
accept_header = request.headers.get('Accept', '')
|
# Handle 'current' tab ID
|
||||||
wants_html = 'text/html' in accept_header
|
if id == 'current':
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
# Handle 'current' tab ID
|
if current_tab_id:
|
||||||
if id == 'current':
|
id = current_tab_id
|
||||||
current_tab_id = get_current_tab_id(request, session)
|
else:
|
||||||
if current_tab_id:
|
return json.dumps({"error": "No current tab to delete"}), 404
|
||||||
id = current_tab_id
|
|
||||||
else:
|
|
||||||
if wants_html:
|
|
||||||
return '<div class="error">No current tab to delete</div>', 404, {'Content-Type': 'text/html'}
|
|
||||||
return json.dumps({"error": "No current tab to delete"}), 404
|
|
||||||
|
|
||||||
if tabs.delete(id):
|
|
||||||
# Remove from profile's tabs
|
|
||||||
profile_id = get_current_profile_id(session)
|
|
||||||
if profile_id:
|
|
||||||
profile = profiles.read(profile_id)
|
|
||||||
if profile:
|
|
||||||
# Support both "tabs" (new) and "tab_order" (old) format
|
|
||||||
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
|
||||||
if id in tabs_list:
|
|
||||||
tabs_list.remove(id)
|
|
||||||
profile['tabs'] = tabs_list
|
|
||||||
# Remove old tab_order if it exists
|
|
||||||
if 'tab_order' in profile:
|
|
||||||
del profile['tab_order']
|
|
||||||
profiles.update(profile_id, profile)
|
|
||||||
|
|
||||||
# Clear session if the deleted tab was the current tab
|
if tabs.delete(id):
|
||||||
current_tab_id = get_current_tab_id(request, session)
|
# Remove from profile's tabs
|
||||||
if current_tab_id == id:
|
profile_id = get_current_profile_id(session)
|
||||||
if 'current_tab' in session:
|
if profile_id:
|
||||||
session.pop('current_tab', None)
|
profile = profiles.read(profile_id)
|
||||||
session.save()
|
if profile:
|
||||||
|
# Support both "tabs" (new) and "tab_order" (old) format
|
||||||
if wants_html:
|
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||||
return await tabs_list_fragment.__wrapped__(request, session)
|
if id in tabs_list:
|
||||||
else:
|
tabs_list.remove(id)
|
||||||
|
profile['tabs'] = tabs_list
|
||||||
|
# Remove old tab_order if it exists
|
||||||
|
if 'tab_order' in profile:
|
||||||
|
del profile['tab_order']
|
||||||
|
profiles.update(profile_id, profile)
|
||||||
|
|
||||||
|
# Clear cookie if the deleted tab was the current tab
|
||||||
|
current_tab_id = get_current_tab_id(request, session)
|
||||||
|
if current_tab_id == id:
|
||||||
|
response_data = json.dumps({"message": "Tab deleted successfully"})
|
||||||
|
response = response_data, 200, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Set-Cookie': 'current_tab=; Path=/; Max-Age=0' # Clear cookie
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
|
||||||
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
|
return json.dumps({"message": "Tab deleted successfully"}), 200, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
if wants_html:
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
return '<div class="error">Tab not found</div>', 404, {'Content-Type': 'text/html'}
|
except Exception as e:
|
||||||
return json.dumps({"error": "Tab not found"}), 404
|
import sys
|
||||||
|
try:
|
||||||
|
sys.print_exception(e)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return json.dumps({"error": str(e)}), 500, {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@controller.post('')
|
@controller.post('')
|
||||||
@with_session
|
@with_session
|
||||||
async def create_tab(request, session):
|
async def create_tab(request, session):
|
||||||
"""Create a new tab."""
|
"""Create a new tab."""
|
||||||
# Check if this is an htmx request (wants HTML fragment)
|
|
||||||
accept_header = request.headers.get('Accept', '')
|
|
||||||
wants_html = 'text/html' in accept_header
|
|
||||||
# #region agent log
|
|
||||||
try:
|
try:
|
||||||
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
|
# Handle form data or JSON
|
||||||
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
|
|
||||||
_log.write(json.dumps({
|
|
||||||
"sessionId": "debug-session",
|
|
||||||
"runId": "tabs-pre-fix",
|
|
||||||
"hypothesisId": "H3",
|
|
||||||
"location": "src/controllers/tab.py:create_tab_htmx",
|
|
||||||
"message": "create tab with session",
|
|
||||||
"data": {
|
|
||||||
"wants_html": wants_html,
|
|
||||||
"has_form": bool(request.form),
|
|
||||||
"accept": accept_header
|
|
||||||
},
|
|
||||||
"timestamp": int(time.time() * 1000)
|
|
||||||
}) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# #endregion
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Handle form data (htmx) or JSON
|
|
||||||
if request.form:
|
if request.form:
|
||||||
name = request.form.get('name', '').strip()
|
name = request.form.get('name', '').strip()
|
||||||
ids_str = request.form.get('ids', '1').strip()
|
ids_str = request.form.get('ids', '1').strip()
|
||||||
@@ -288,8 +279,6 @@ async def create_tab(request, session):
|
|||||||
preset_ids = data.get("presets", None)
|
preset_ids = data.get("presets", None)
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
if wants_html:
|
|
||||||
return '<div class="error">Tab name cannot be empty</div>', 400, {'Content-Type': 'text/html'}
|
|
||||||
return json.dumps({"error": "Tab name cannot be empty"}), 400
|
return json.dumps({"error": "Tab name cannot be empty"}), 400
|
||||||
|
|
||||||
tab_id = tabs.create(name, names, preset_ids)
|
tab_id = tabs.create(name, names, preset_ids)
|
||||||
@@ -308,36 +297,50 @@ async def create_tab(request, session):
|
|||||||
if 'tab_order' in profile:
|
if 'tab_order' in profile:
|
||||||
del profile['tab_order']
|
del profile['tab_order']
|
||||||
profiles.update(profile_id, profile)
|
profiles.update(profile_id, profile)
|
||||||
# #region agent log
|
|
||||||
try:
|
|
||||||
os.makedirs('/home/pi/led-controller/.cursor', exist_ok=True)
|
|
||||||
with open('/home/pi/led-controller/.cursor/debug.log', 'a') as _log:
|
|
||||||
_log.write(json.dumps({
|
|
||||||
"sessionId": "debug-session",
|
|
||||||
"runId": "tabs-pre-fix",
|
|
||||||
"hypothesisId": "H4",
|
|
||||||
"location": "src/controllers/tab.py:create_tab_htmx",
|
|
||||||
"message": "tab created and profile updated",
|
|
||||||
"data": {
|
|
||||||
"tab_id": tab_id,
|
|
||||||
"profile_id": profile_id,
|
|
||||||
"profile_tabs": tabs_list if profile_id and profile else None
|
|
||||||
},
|
|
||||||
"timestamp": int(time.time() * 1000)
|
|
||||||
}) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# #endregion
|
|
||||||
|
|
||||||
if wants_html:
|
# Return JSON response with tab ID
|
||||||
# Return HTML fragment for tabs list
|
tab_data = tabs.read(tab_id)
|
||||||
return await tabs_list_fragment.__wrapped__(request, session)
|
return json.dumps({tab_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||||
else:
|
|
||||||
# Return JSON response
|
|
||||||
return json.dumps(tabs.read(tab_id)), 201, {'Content-Type': 'application/json'}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import sys
|
import sys
|
||||||
sys.print_exception(e)
|
sys.print_exception(e)
|
||||||
if wants_html:
|
return json.dumps({"error": str(e)}), 400
|
||||||
return f'<div class="error">Error: {str(e)}</div>', 400, {'Content-Type': 'text/html'}
|
|
||||||
|
@controller.post('/<id>/clone')
|
||||||
|
@with_session
|
||||||
|
async def clone_tab(request, session, id):
|
||||||
|
"""Clone an existing tab and add it to the current profile."""
|
||||||
|
try:
|
||||||
|
source = tabs.read(id)
|
||||||
|
if not source:
|
||||||
|
return json.dumps({"error": "Tab not found"}), 404
|
||||||
|
|
||||||
|
data = request.json or {}
|
||||||
|
source_name = source.get("name") or f"Tab {id}"
|
||||||
|
new_name = data.get("name") or f"{source_name} Copy"
|
||||||
|
clone_id = tabs.create(new_name, source.get("names"), source.get("presets"))
|
||||||
|
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||||
|
if extra:
|
||||||
|
tabs.update(clone_id, extra)
|
||||||
|
|
||||||
|
profile_id = get_current_profile_id(session)
|
||||||
|
if profile_id:
|
||||||
|
profile = profiles.read(profile_id)
|
||||||
|
if profile:
|
||||||
|
tabs_list = profile.get('tabs', profile.get('tab_order', []))
|
||||||
|
if clone_id not in tabs_list:
|
||||||
|
tabs_list.append(clone_id)
|
||||||
|
profile['tabs'] = tabs_list
|
||||||
|
if 'tab_order' in profile:
|
||||||
|
del profile['tab_order']
|
||||||
|
profiles.update(profile_id, profile)
|
||||||
|
|
||||||
|
tab_data = tabs.read(clone_id)
|
||||||
|
return json.dumps({clone_id: tab_data}), 201, {'Content-Type': 'application/json'}
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
sys.print_exception(e)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
return json.dumps({"error": str(e)}), 400
|
return json.dumps({"error": str(e)}), 400
|
||||||
|
|||||||
70
src/main.py
70
src/main.py
@@ -1,13 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from settings import Settings
|
import json
|
||||||
import gc
|
import os
|
||||||
import machine
|
|
||||||
from microdot import Microdot, send_file
|
from microdot import Microdot, send_file
|
||||||
from microdot.websocket import with_websocket
|
from microdot.websocket import with_websocket
|
||||||
from microdot.session import Session
|
from microdot.session import Session
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
import aioespnow
|
|
||||||
import network
|
|
||||||
import controllers.preset as preset
|
import controllers.preset as preset
|
||||||
import controllers.profile as profile
|
import controllers.profile as profile
|
||||||
import controllers.group as group
|
import controllers.group as group
|
||||||
@@ -16,18 +14,18 @@ import controllers.tab as tab
|
|||||||
import controllers.palette as palette
|
import controllers.palette as palette
|
||||||
import controllers.scene as scene
|
import controllers.scene as scene
|
||||||
import controllers.pattern as pattern
|
import controllers.pattern as pattern
|
||||||
|
import controllers.settings as settings_controller
|
||||||
|
from models.transport import get_sender, set_sender
|
||||||
|
|
||||||
|
|
||||||
async def main(port=80):
|
async def main(port=80):
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
print(settings)
|
||||||
print("Starting")
|
print("Starting")
|
||||||
|
|
||||||
network.WLAN(network.STA_IF).active(True)
|
# Initialize transport (serial to ESP32 bridge)
|
||||||
|
sender = get_sender(settings)
|
||||||
|
set_sender(sender)
|
||||||
e = aioespnow.AIOESPNow()
|
|
||||||
e.active(True)
|
|
||||||
e.add_peer(b"\xbb\xbb\xbb\xbb\xbb\xbb")
|
|
||||||
|
|
||||||
app = Microdot()
|
app = Microdot()
|
||||||
|
|
||||||
@@ -56,13 +54,25 @@ async def main(port=80):
|
|||||||
app.mount(palette.controller, '/palettes')
|
app.mount(palette.controller, '/palettes')
|
||||||
app.mount(scene.controller, '/scenes')
|
app.mount(scene.controller, '/scenes')
|
||||||
app.mount(pattern.controller, '/patterns')
|
app.mount(pattern.controller, '/patterns')
|
||||||
|
app.mount(settings_controller.controller, '/settings')
|
||||||
|
|
||||||
# Serve index.html at root
|
# Serve index.html at root (cwd is src/ when run via pipenv run run)
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index(request):
|
def index(request):
|
||||||
"""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)
|
||||||
|
@app.route('/favicon.ico')
|
||||||
|
def favicon(request):
|
||||||
|
return '', 204
|
||||||
|
|
||||||
# Static file route
|
# Static file route
|
||||||
@app.route("/static/<path:path>")
|
@app.route("/static/<path:path>")
|
||||||
def static_handler(request, path):
|
def static_handler(request, path):
|
||||||
@@ -77,9 +87,29 @@ async def main(port=80):
|
|||||||
async def ws(request, ws):
|
async def ws(request, ws):
|
||||||
while True:
|
while True:
|
||||||
data = await ws.receive()
|
data = await ws.receive()
|
||||||
|
print(data)
|
||||||
if data:
|
if data:
|
||||||
await e.asend(b"\xbb\xbb\xbb\xbb\xbb\xbb", data)
|
try:
|
||||||
print(data)
|
parsed = json.loads(data)
|
||||||
|
print("WS received JSON:", parsed)
|
||||||
|
# Optional "to": 12-char hex MAC; rest is payload (sent with that address).
|
||||||
|
addr = parsed.pop("to", None)
|
||||||
|
payload = json.dumps(parsed) if parsed else data
|
||||||
|
await sender.send(payload, addr=addr)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Not JSON: send raw with default address
|
||||||
|
try:
|
||||||
|
await sender.send(data)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"error": "Send failed"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
await ws.send(json.dumps({"error": "Send failed"}))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -87,15 +117,11 @@ async def main(port=80):
|
|||||||
|
|
||||||
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port))
|
||||||
|
|
||||||
wdt = machine.WDT(timeout=10000)
|
|
||||||
wdt.feed()
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
gc.collect()
|
await asyncio.sleep(30)
|
||||||
for i in range(60):
|
|
||||||
wdt.feed()
|
|
||||||
await asyncio.sleep_ms(500)
|
|
||||||
# cleanup before ending the application
|
# cleanup before ending the application
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
import os
|
||||||
|
port = int(os.environ.get("PORT", 80))
|
||||||
|
asyncio.run(main(port=port))
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
54
src/models/device.py
Normal file
54
src/models/device.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from models.model import Model
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_address(addr):
|
||||||
|
"""Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons)."""
|
||||||
|
if addr is None:
|
||||||
|
return None
|
||||||
|
s = str(addr).strip().lower().replace(":", "").replace("-", "")
|
||||||
|
if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Model):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def create(self, name="", address=None, default_pattern=None, tabs=None):
|
||||||
|
next_id = self.get_next_id()
|
||||||
|
addr = _normalize_address(address)
|
||||||
|
self[next_id] = {
|
||||||
|
"name": name,
|
||||||
|
"address": addr,
|
||||||
|
"default_pattern": default_pattern if default_pattern else None,
|
||||||
|
"tabs": list(tabs) if tabs else [],
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
return next_id
|
||||||
|
|
||||||
|
def read(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
return self.get(id_str, None)
|
||||||
|
|
||||||
|
def update(self, id, data):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
if "address" in data and data["address"] is not None:
|
||||||
|
data = dict(data)
|
||||||
|
data["address"] = _normalize_address(data["address"])
|
||||||
|
self[id_str].update(data)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
id_str = str(id)
|
||||||
|
if id_str not in self:
|
||||||
|
return False
|
||||||
|
self.pop(id_str)
|
||||||
|
self.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def list(self):
|
||||||
|
return list(self.keys())
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# DB directory: project root / db (writable without root)
|
||||||
|
def _db_dir():
|
||||||
|
try:
|
||||||
|
# src/models/model.py -> project root
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
return os.path.join(base, "db")
|
||||||
|
except Exception:
|
||||||
|
return "db"
|
||||||
|
|
||||||
class Model(dict):
|
class Model(dict):
|
||||||
def __new__(cls, *args, **kwargs):
|
def __new__(cls, *args, **kwargs):
|
||||||
@@ -13,13 +23,13 @@ class Model(dict):
|
|||||||
if hasattr(self, '_initialized'):
|
if hasattr(self, '_initialized'):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create /db directory if it doesn't exist (MicroPython compatible)
|
db_dir = _db_dir()
|
||||||
try:
|
try:
|
||||||
os.mkdir("/db")
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Directory already exists, which is fine
|
pass
|
||||||
self.class_name = self.__class__.__name__
|
self.class_name = self.__class__.__name__
|
||||||
self.file = f"/db/{self.class_name.lower()}.json"
|
self.file = os.path.join(db_dir, f"{self.class_name.lower()}.json")
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
@@ -37,27 +47,67 @@ class Model(dict):
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
# Ensure directory exists
|
db_dir = os.path.dirname(self.file)
|
||||||
try:
|
try:
|
||||||
os.mkdir("/db")
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # Directory already exists
|
pass
|
||||||
j = json.dumps(self)
|
j = json.dumps(self)
|
||||||
with open(self.file, 'w') as file:
|
with open(self.file, 'w') as file:
|
||||||
file.write(j)
|
file.write(j)
|
||||||
|
file.flush() # Ensure data is written to buffer
|
||||||
|
# Try to sync filesystem if available (MicroPython)
|
||||||
|
try:
|
||||||
|
os.sync()
|
||||||
|
except (AttributeError, OSError):
|
||||||
|
pass # os.sync() not available on all platforms
|
||||||
print(f"{self.class_name} saved successfully to {self.file}")
|
print(f"{self.class_name} saved successfully to {self.file}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
print(f"Error saving {self.class_name} to {self.file}: {e}")
|
||||||
import sys
|
traceback.print_exception(type(e), e, e.__traceback__)
|
||||||
sys.print_exception(e)
|
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
try:
|
try:
|
||||||
with open(self.file, 'r') as file:
|
# Check if file exists first
|
||||||
loaded_settings = json.load(file)
|
try:
|
||||||
self.update(loaded_settings)
|
with open(self.file, 'r') as file:
|
||||||
|
content = file.read().strip()
|
||||||
|
except OSError:
|
||||||
|
# File doesn't exist
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
# Empty file
|
||||||
|
loaded_settings = {}
|
||||||
|
else:
|
||||||
|
# Parse JSON content
|
||||||
|
loaded_settings = json.loads(content)
|
||||||
|
|
||||||
|
# Verify it's a dictionary
|
||||||
|
if not isinstance(loaded_settings, dict):
|
||||||
|
raise ValueError(f"File does not contain a dictionary, got {type(loaded_settings)}")
|
||||||
|
|
||||||
|
# Clear and update with loaded data
|
||||||
|
# Clear first
|
||||||
|
self.clear()
|
||||||
|
# Manually copy items to avoid any update() method issues
|
||||||
|
for key, value in loaded_settings.items():
|
||||||
|
self[key] = value
|
||||||
print(f"{self.class_name} loaded successfully.")
|
print(f"{self.class_name} loaded successfully.")
|
||||||
except Exception as e:
|
except OSError as e:
|
||||||
print(f"Error loading {self.class_name}")
|
# File doesn't exist yet - this is normal on first run
|
||||||
|
# Create an empty file with defaults
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
print(f"{self.class_name} initialized (new file created).")
|
||||||
|
except ValueError:
|
||||||
|
# JSON parsing error - file exists but is corrupted
|
||||||
|
# Note: MicroPython uses ValueError for JSON errors, not JSONDecodeError
|
||||||
|
print(f"Error loading {self.class_name}: Invalid JSON format. Resetting to defaults.")
|
||||||
|
self.set_defaults()
|
||||||
|
self.save()
|
||||||
|
except Exception:
|
||||||
|
# Other unexpected errors - avoid trying to format exception to prevent further errors
|
||||||
|
print(f"Error loading {self.class_name}. Resetting to defaults.")
|
||||||
self.set_defaults()
|
self.set_defaults()
|
||||||
self.save()
|
self.save()
|
||||||
|
|||||||
@@ -6,22 +6,30 @@ class Palette(Model):
|
|||||||
|
|
||||||
def create(self, name="", colors=None):
|
def create(self, name="", colors=None):
|
||||||
next_id = self.get_next_id()
|
next_id = self.get_next_id()
|
||||||
self[next_id] = {
|
# Store palette as a simple list of colors; name is ignored.
|
||||||
"name": name,
|
self[next_id] = list(colors) if colors else []
|
||||||
"colors": colors if colors else []
|
|
||||||
}
|
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|
||||||
def read(self, id):
|
def read(self, id):
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
return self.get(id_str, None)
|
value = self.get(id_str, None)
|
||||||
|
# Backwards compatibility: if stored as {"colors": [...]}, unwrap.
|
||||||
|
if isinstance(value, dict) and "colors" in value:
|
||||||
|
return value.get("colors") or []
|
||||||
|
# Otherwise, expect a list of colors.
|
||||||
|
return value or []
|
||||||
|
|
||||||
def update(self, id, data):
|
def update(self, id, data):
|
||||||
id_str = str(id)
|
id_str = str(id)
|
||||||
if id_str not in self:
|
if id_str not in self:
|
||||||
return False
|
return False
|
||||||
self[id_str].update(data)
|
# Accept either {"colors": [...]} or a raw list.
|
||||||
|
if isinstance(data, dict):
|
||||||
|
colors = data.get("colors", [])
|
||||||
|
else:
|
||||||
|
colors = data
|
||||||
|
self[id_str] = list(colors) if colors else []
|
||||||
self.save()
|
self.save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
from models.model import Model
|
from models.model import Model
|
||||||
|
from models.profile import Profile
|
||||||
|
|
||||||
class Preset(Model):
|
class Preset(Model):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
# Backfill profile ownership for existing presets.
|
||||||
|
try:
|
||||||
|
profiles = Profile()
|
||||||
|
profile_list = profiles.list()
|
||||||
|
default_profile_id = profile_list[0] if profile_list else None
|
||||||
|
changed = False
|
||||||
|
for preset_id, preset_data in list(self.items()):
|
||||||
|
if isinstance(preset_data, dict) and "profile_id" not in preset_data:
|
||||||
|
if default_profile_id is not None:
|
||||||
|
preset_data["profile_id"] = str(default_profile_id)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def create(self):
|
def create(self, profile_id=None):
|
||||||
next_id = self.get_next_id()
|
next_id = self.get_next_id()
|
||||||
self[next_id] = {
|
self[next_id] = {
|
||||||
"name": "",
|
"name": "",
|
||||||
@@ -20,6 +36,7 @@ class Preset(Model):
|
|||||||
"n6": 0,
|
"n6": 0,
|
||||||
"n7": 0,
|
"n7": 0,
|
||||||
"n8": 0,
|
"n8": 0,
|
||||||
|
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||||
}
|
}
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
from models.model import Model
|
from models.model import Model
|
||||||
|
from models.pallet import Palette
|
||||||
|
|
||||||
|
|
||||||
class Profile(Model):
|
class Profile(Model):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""Profile model.
|
||||||
|
|
||||||
|
Each profile owns a single, unique palette stored in the Palette model.
|
||||||
|
The profile stores a `palette_id` that points to its palette; any legacy
|
||||||
|
inline `palette` arrays are migrated to a dedicated Palette entry.
|
||||||
|
"""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._palette_model = Palette()
|
||||||
|
|
||||||
|
# Migrate legacy inline palettes to separate Palette entries.
|
||||||
|
changed = False
|
||||||
|
for pid, pdata in list(self.items()):
|
||||||
|
if isinstance(pdata, dict):
|
||||||
|
if "palette" in pdata and "palette_id" not in pdata:
|
||||||
|
colors = pdata.get("palette") or []
|
||||||
|
palette_id = self._palette_model.create(colors=colors)
|
||||||
|
pdata.pop("palette", None)
|
||||||
|
pdata["palette_id"] = str(palette_id)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.save()
|
||||||
|
|
||||||
def create(self, name="", profile_type="tabs"):
|
def create(self, name="", profile_type="tabs"):
|
||||||
"""
|
"""Create a new profile and its own empty palette.
|
||||||
Create a new profile.
|
|
||||||
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
profile_type: "tabs" or "scenes" (ignoring scenes for now)
|
||||||
"""
|
"""
|
||||||
next_id = self.get_next_id()
|
next_id = self.get_next_id()
|
||||||
|
# Create a unique palette for this profile.
|
||||||
|
palette_id = self._palette_model.create(colors=[])
|
||||||
self[next_id] = {
|
self[next_id] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"type": profile_type, # "tabs" or "scenes"
|
"type": profile_type, # "tabs" or "scenes"
|
||||||
"tabs": [], # Array of tab IDs
|
"tabs": [], # Array of tab IDs
|
||||||
"scenes": [], # Array of scene IDs (for future use)
|
"scenes": [], # Array of scene IDs (for future use)
|
||||||
"palette": []
|
"palette_id": str(palette_id),
|
||||||
}
|
}
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|||||||
12
src/models/serial.py
Normal file
12
src/models/serial.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class Serial:
|
||||||
|
def __init__(self, port, baudrate):
|
||||||
|
self.port = port
|
||||||
|
self.baudrate = baudrate
|
||||||
|
self.uart = UART(1, baudrate, tx=Pin(21), rx=Pin(6))
|
||||||
|
|
||||||
|
def send(self, data):
|
||||||
|
self.uart.write(data)
|
||||||
|
|
||||||
|
def receive(self):
|
||||||
|
return self.uart.read()
|
||||||
|
|
||||||
@@ -9,7 +9,8 @@ class Tab(Model):
|
|||||||
self[next_id] = {
|
self[next_id] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"names": names if names else [],
|
"names": names if names else [],
|
||||||
"presets": presets if presets else []
|
"presets": presets if presets else [],
|
||||||
|
"default_preset": None
|
||||||
}
|
}
|
||||||
self.save()
|
self.save()
|
||||||
return next_id
|
return next_id
|
||||||
|
|||||||
66
src/models/transport.py
Normal file
66
src/models/transport.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
# Default: broadcast (6 bytes). Pi always sends 6-byte address + payload to ESP32.
|
||||||
|
BROADCAST_MAC = bytes.fromhex("ffffffffffff")
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_payload(data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
return data.encode()
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return json.dumps(data).encode()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mac(addr):
|
||||||
|
"""Convert 12-char hex string or 6-byte bytes to 6-byte MAC."""
|
||||||
|
if addr is None or addr == b"":
|
||||||
|
return BROADCAST_MAC
|
||||||
|
if isinstance(addr, bytes) and len(addr) == 6:
|
||||||
|
return addr
|
||||||
|
if isinstance(addr, str) and len(addr) == 12:
|
||||||
|
return bytes.fromhex(addr)
|
||||||
|
return BROADCAST_MAC
|
||||||
|
|
||||||
|
|
||||||
|
async def _to_thread(func, *args):
|
||||||
|
to_thread = getattr(asyncio, "to_thread", None)
|
||||||
|
if to_thread:
|
||||||
|
return await to_thread(func, *args)
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, func, *args)
|
||||||
|
|
||||||
|
|
||||||
|
class SerialSender:
|
||||||
|
def __init__(self, port, baudrate, default_addr=None):
|
||||||
|
import serial
|
||||||
|
|
||||||
|
self._serial = serial.Serial(port, baudrate=baudrate, timeout=1)
|
||||||
|
self._default_addr = _parse_mac(default_addr)
|
||||||
|
|
||||||
|
async def send(self, data, addr=None):
|
||||||
|
mac = _parse_mac(addr) if addr is not None else self._default_addr
|
||||||
|
payload = _encode_payload(data)
|
||||||
|
await _to_thread(self._serial.write, mac + payload)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
_current_sender = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_sender(sender):
|
||||||
|
global _current_sender
|
||||||
|
_current_sender = sender
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_sender():
|
||||||
|
return _current_sender
|
||||||
|
|
||||||
|
|
||||||
|
def get_sender(settings):
|
||||||
|
port = settings.get("serial_port", "/dev/ttyS0")
|
||||||
|
baudrate = settings.get("serial_baudrate", 912000)
|
||||||
|
default_addr = settings.get("serial_destination_mac", "ffffffffffff")
|
||||||
|
return SerialSender(port, baudrate, default_addr=default_addr)
|
||||||
0
src/profile.py
Normal file
0
src/profile.py
Normal file
@@ -2,11 +2,23 @@ import json
|
|||||||
import os
|
import os
|
||||||
import binascii
|
import binascii
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_path():
|
||||||
|
"""Path to settings.json in project root (writable without root)."""
|
||||||
|
try:
|
||||||
|
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
return os.path.join(base, "settings.json")
|
||||||
|
except Exception:
|
||||||
|
return "settings.json"
|
||||||
|
|
||||||
|
|
||||||
class Settings(dict):
|
class Settings(dict):
|
||||||
SETTINGS_FILE = "/settings.json"
|
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
if Settings.SETTINGS_FILE is None:
|
||||||
|
Settings.SETTINGS_FILE = _settings_path()
|
||||||
self.load() # Load settings from file during initialization
|
self.load() # Load settings from file during initialization
|
||||||
|
|
||||||
def generate_secret_key(self):
|
def generate_secret_key(self):
|
||||||
@@ -33,6 +45,9 @@ class Settings(dict):
|
|||||||
self['session_secret_key'] = self.generate_secret_key()
|
self['session_secret_key'] = self.generate_secret_key()
|
||||||
# Save immediately when generating a new key
|
# Save immediately when generating a new key
|
||||||
self.save()
|
self.save()
|
||||||
|
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||||
|
if 'wifi_channel' not in self:
|
||||||
|
self['wifi_channel'] = 6
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -122,22 +122,6 @@ class LightingController {
|
|||||||
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
|
document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
|
||||||
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
|
document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
|
||||||
|
|
||||||
// Close modals on outside click
|
|
||||||
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
|
|
||||||
});
|
|
||||||
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
|
|
||||||
});
|
|
||||||
document.getElementById('profiles-modal').addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
|
|
||||||
});
|
|
||||||
document.getElementById('presets-modal').addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
|
|
||||||
});
|
|
||||||
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
|
|
||||||
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderTabs() {
|
renderTabs() {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const closeButton = document.getElementById('color-palette-close-btn');
|
const closeButton = document.getElementById('color-palette-close-btn');
|
||||||
const paletteContainer = document.getElementById('palette-container');
|
const paletteContainer = document.getElementById('palette-container');
|
||||||
const paletteNewColor = document.getElementById('palette-new-color');
|
const paletteNewColor = document.getElementById('palette-new-color');
|
||||||
const paletteAddButton = document.getElementById('palette-add-color-btn');
|
|
||||||
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
const profileNameDisplay = document.getElementById('palette-current-profile-name');
|
||||||
|
|
||||||
if (!paletteButton || !paletteModal || !paletteContainer) {
|
if (!paletteButton || !paletteModal || !paletteContainer) {
|
||||||
@@ -12,6 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentProfileId = null;
|
let currentProfileId = null;
|
||||||
|
let currentPaletteId = null;
|
||||||
let currentPalette = [];
|
let currentPalette = [];
|
||||||
let currentProfileName = null;
|
let currentProfileName = null;
|
||||||
|
|
||||||
@@ -84,7 +84,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPalette = profile.palette || profile.color_palette || [];
|
// Prefer palette_id-based storage; fall back to legacy inline palette.
|
||||||
|
currentPaletteId = profile.palette_id || profile.paletteId || null;
|
||||||
|
if (currentPaletteId) {
|
||||||
|
try {
|
||||||
|
const palResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (palResponse.ok) {
|
||||||
|
const palData = await palResponse.json();
|
||||||
|
currentPalette = (palData.colors) || [];
|
||||||
|
} else {
|
||||||
|
currentPalette = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load palette by id:', e);
|
||||||
|
currentPalette = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: palette stored directly on profile
|
||||||
|
currentPalette = profile.palette || profile.color_palette || [];
|
||||||
|
}
|
||||||
renderPalette();
|
renderPalette();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load palette:', error);
|
console.error('Failed to load palette:', error);
|
||||||
@@ -99,17 +119,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/profiles/current', {
|
// Ensure we have a palette ID for this profile.
|
||||||
method: 'PUT',
|
if (!currentPaletteId) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const createResponse = await fetch('/palettes', {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
palette: newPalette,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
color_palette: newPalette,
|
body: JSON.stringify({ colors: newPalette }),
|
||||||
}),
|
});
|
||||||
});
|
if (!createResponse.ok) {
|
||||||
if (!response.ok) {
|
throw new Error('Failed to create palette');
|
||||||
throw new Error('Failed to save palette');
|
}
|
||||||
|
const pal = await createResponse.json();
|
||||||
|
currentPaletteId = pal.id || Object.keys(pal)[0];
|
||||||
|
|
||||||
|
// Link the new palette to the current profile.
|
||||||
|
const linkResponse = await fetch('/profiles/current', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
palette_id: currentPaletteId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!linkResponse.ok) {
|
||||||
|
throw new Error('Failed to link palette to profile');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing palette colors
|
||||||
|
const updateResponse = await fetch(`/palettes/${currentPaletteId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ colors: newPalette }),
|
||||||
|
});
|
||||||
|
if (!updateResponse.ok) {
|
||||||
|
throw new Error('Failed to save palette');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPalette = newPalette;
|
currentPalette = newPalette;
|
||||||
renderPalette();
|
renderPalette();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (closeButton) {
|
if (closeButton) {
|
||||||
closeButton.addEventListener('click', closeModal);
|
closeButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
if (paletteAddButton && paletteNewColor) {
|
if (paletteNewColor) {
|
||||||
paletteAddButton.addEventListener('click', async () => {
|
const addSelectedColor = async () => {
|
||||||
const color = paletteNewColor.value;
|
const color = paletteNewColor.value;
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return;
|
return;
|
||||||
@@ -142,11 +187,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await savePalette([...currentPalette, color]);
|
await savePalette([...currentPalette, color]);
|
||||||
});
|
};
|
||||||
|
// Add when the picker closes (user confirms selection).
|
||||||
|
paletteNewColor.addEventListener('change', addSelectedColor);
|
||||||
}
|
}
|
||||||
paletteModal.addEventListener('click', (event) => {
|
|
||||||
if (event.target === paletteModal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
251
src/static/devices.js
Normal file
251
src/static/devices.js
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// Device management: list, create, edit, delete (name and 6-byte address)
|
||||||
|
|
||||||
|
const HEX_BOX_COUNT = 12;
|
||||||
|
|
||||||
|
function makeHexAddressBoxes(container) {
|
||||||
|
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
for (let i = 0; i < HEX_BOX_COUNT; i++) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'hex-addr-box';
|
||||||
|
input.maxLength = 1;
|
||||||
|
input.autocomplete = 'off';
|
||||||
|
input.setAttribute('data-index', i);
|
||||||
|
input.setAttribute('inputmode', 'numeric');
|
||||||
|
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
|
||||||
|
e.target.value = v;
|
||||||
|
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
|
||||||
|
e.target.nextElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
|
||||||
|
e.target.previousElementSibling.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener('paste', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
|
||||||
|
boxes[j].value = pasted[j];
|
||||||
|
}
|
||||||
|
if (pasted.length > 0) {
|
||||||
|
const nextIdx = Math.min(pasted.length, boxes.length - 1);
|
||||||
|
boxes[nextIdx].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddressFromBoxes(container) {
|
||||||
|
if (!container) return '';
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
return Array.from(boxes).map((b) => b.value).join('').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAddressToBoxes(container, addrStr) {
|
||||||
|
if (!container) return;
|
||||||
|
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
|
||||||
|
const boxes = container.querySelectorAll('.hex-addr-box');
|
||||||
|
boxes.forEach((b, i) => {
|
||||||
|
b.value = s[i] || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevicesModal() {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||||
|
if (!response.ok) throw new Error('Failed to load devices');
|
||||||
|
const devices = await response.json();
|
||||||
|
renderDevicesList(devices || {});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadDevicesModal:', e);
|
||||||
|
container.innerHTML = '<span class="muted-text">Failed to load devices.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevicesList(devices) {
|
||||||
|
const container = document.getElementById('devices-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
const ids = Object.keys(devices).filter((k) => devices[k] && typeof devices[k] === 'object');
|
||||||
|
if (ids.length === 0) {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'muted-text';
|
||||||
|
p.textContent = 'No devices. Create one above.';
|
||||||
|
container.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ids.forEach((devId) => {
|
||||||
|
const dev = devices[devId];
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.gap = '0.5rem';
|
||||||
|
row.style.flexWrap = 'wrap';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = (dev && dev.name) || devId;
|
||||||
|
label.style.flex = '1';
|
||||||
|
label.style.minWidth = '100px';
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'muted-text';
|
||||||
|
meta.style.fontSize = '0.85em';
|
||||||
|
const addr = (dev && dev.address) ? dev.address : '—';
|
||||||
|
meta.textContent = `Address: ${addr}`;
|
||||||
|
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
editBtn.textContent = 'Edit';
|
||||||
|
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||||
|
deleteBtn.textContent = 'Delete';
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) await loadDevicesModal();
|
||||||
|
else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Delete failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Delete failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(meta);
|
||||||
|
row.appendChild(editBtn);
|
||||||
|
row.appendChild(deleteBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDeviceModal(devId, dev) {
|
||||||
|
const modal = document.getElementById('edit-device-modal');
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
if (!modal || !idInput) return;
|
||||||
|
idInput.value = devId;
|
||||||
|
if (nameInput) nameInput.value = (dev && dev.name) || '';
|
||||||
|
setAddressToBoxes(addressBoxes, (dev && dev.address) || '');
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevice(name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to create device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('createDevice:', e);
|
||||||
|
alert('Failed to create device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateDevice(devId, name, address) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/devices/${devId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, address: address || null }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok) {
|
||||||
|
await loadDevicesModal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
alert(data.error || 'Failed to update device');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('updateDevice:', e);
|
||||||
|
alert('Failed to update device');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
|
||||||
|
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||||
|
|
||||||
|
const devicesBtn = document.getElementById('devices-btn');
|
||||||
|
const devicesModal = document.getElementById('devices-modal');
|
||||||
|
const devicesCloseBtn = document.getElementById('devices-close-btn');
|
||||||
|
const newName = document.getElementById('new-device-name');
|
||||||
|
const createBtn = document.getElementById('create-device-btn');
|
||||||
|
const editForm = document.getElementById('edit-device-form');
|
||||||
|
const editCloseBtn = document.getElementById('edit-device-close-btn');
|
||||||
|
const editDeviceModal = document.getElementById('edit-device-modal');
|
||||||
|
|
||||||
|
if (devicesBtn && devicesModal) {
|
||||||
|
devicesBtn.addEventListener('click', () => {
|
||||||
|
devicesModal.classList.add('active');
|
||||||
|
loadDevicesModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (devicesCloseBtn) {
|
||||||
|
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
const newAddressBoxes = document.getElementById('new-device-address-boxes');
|
||||||
|
const doCreate = async () => {
|
||||||
|
const name = (newName && newName.value.trim()) || '';
|
||||||
|
if (!name) {
|
||||||
|
alert('Device name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : '';
|
||||||
|
const ok = await createDevice(name, address);
|
||||||
|
if (ok && newName) {
|
||||||
|
newName.value = '';
|
||||||
|
setAddressToBoxes(newAddressBoxes, '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (createBtn) createBtn.addEventListener('click', doCreate);
|
||||||
|
if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); });
|
||||||
|
|
||||||
|
if (editForm) {
|
||||||
|
editForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idInput = document.getElementById('edit-device-id');
|
||||||
|
const nameInput = document.getElementById('edit-device-name');
|
||||||
|
const addressBoxes = document.getElementById('edit-device-address-boxes');
|
||||||
|
const devId = idInput && idInput.value;
|
||||||
|
if (!devId) return;
|
||||||
|
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : '';
|
||||||
|
const ok = await updateDevice(
|
||||||
|
devId,
|
||||||
|
nameInput ? nameInput.value.trim() : '',
|
||||||
|
address
|
||||||
|
);
|
||||||
|
if (ok) editDeviceModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (editCloseBtn) {
|
||||||
|
editCloseBtn.addEventListener('click', () => editDeviceModal && editDeviceModal.classList.remove('active'));
|
||||||
|
}
|
||||||
|
});
|
||||||
197
src/static/help.js
Normal file
197
src/static/help.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Help modal
|
||||||
|
const helpBtn = document.getElementById('help-btn');
|
||||||
|
const helpModal = document.getElementById('help-modal');
|
||||||
|
const helpCloseBtn = document.getElementById('help-close-btn');
|
||||||
|
const mainMenuBtn = document.getElementById('main-menu-btn');
|
||||||
|
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
|
||||||
|
|
||||||
|
if (helpBtn && helpModal) {
|
||||||
|
helpBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (helpCloseBtn && helpModal) {
|
||||||
|
helpCloseBtn.addEventListener('click', () => {
|
||||||
|
helpModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile main menu: forward clicks to existing header buttons
|
||||||
|
if (mainMenuBtn && mainMenuDropdown) {
|
||||||
|
mainMenuBtn.addEventListener('click', () => {
|
||||||
|
mainMenuDropdown.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
mainMenuDropdown.addEventListener('click', (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target && target.matches('button[data-target]')) {
|
||||||
|
const id = target.getAttribute('data-target');
|
||||||
|
const realBtn = document.getElementById(id);
|
||||||
|
if (realBtn) {
|
||||||
|
realBtn.click();
|
||||||
|
}
|
||||||
|
mainMenuDropdown.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings modal wiring (reusing existing settings endpoints).
|
||||||
|
const settingsButton = document.getElementById('settings-btn');
|
||||||
|
const settingsModal = document.getElementById('settings-modal');
|
||||||
|
const settingsCloseButton = document.getElementById('settings-close-btn');
|
||||||
|
|
||||||
|
const showSettingsMessage = (text, type = 'success') => {
|
||||||
|
const messageEl = document.getElementById('settings-message');
|
||||||
|
if (!messageEl) return;
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type} show`;
|
||||||
|
setTimeout(() => {
|
||||||
|
messageEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDeviceSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings');
|
||||||
|
const data = await response.json();
|
||||||
|
const nameInput = document.getElementById('device-name-input');
|
||||||
|
if (nameInput && data && typeof data === 'object') {
|
||||||
|
nameInput.value = data.device_name || 'led-controller';
|
||||||
|
}
|
||||||
|
const chInput = document.getElementById('wifi-channel-input');
|
||||||
|
if (chInput && data && typeof data === 'object') {
|
||||||
|
const ch = data.wifi_channel;
|
||||||
|
chInput.value =
|
||||||
|
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading device settings:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAPStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap');
|
||||||
|
const config = await response.json();
|
||||||
|
const statusEl = document.getElementById('ap-status');
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (config.active) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h4>AP Status: <span class="status-connected">Active</span></h4>
|
||||||
|
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||||
|
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||||
|
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h4>AP Status: <span class="status-disconnected">Inactive</span></h4>
|
||||||
|
<p>Access Point is not currently active</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||||
|
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading AP status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsButton && settingsModal) {
|
||||||
|
settingsButton.addEventListener('click', () => {
|
||||||
|
settingsModal.classList.add('active');
|
||||||
|
// Load current WiFi status/config when opening
|
||||||
|
loadDeviceSettings();
|
||||||
|
loadAPStatus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settingsCloseButton && settingsModal) {
|
||||||
|
settingsCloseButton.addEventListener('click', () => {
|
||||||
|
settingsModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceForm = document.getElementById('device-form');
|
||||||
|
if (deviceForm) {
|
||||||
|
deviceForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nameInput = document.getElementById('device-name-input');
|
||||||
|
const deviceName = nameInput ? nameInput.value.trim() : '';
|
||||||
|
if (!deviceName) {
|
||||||
|
showSettingsMessage('Device name is required', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chRaw = document.getElementById('wifi-channel-input')
|
||||||
|
? document.getElementById('wifi-channel-input').value
|
||||||
|
: '6';
|
||||||
|
const wifiChannel = parseInt(chRaw, 10);
|
||||||
|
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
||||||
|
showSettingsMessage('WiFi channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
device_name: deviceName,
|
||||||
|
wifi_channel: wifiChannel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
showSettingsMessage(
|
||||||
|
'Device settings saved. They will apply on next restart where relevant.',
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
showSettingsMessage(`Error: ${result.error || 'Failed to save device name'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const apForm = document.getElementById('ap-form');
|
||||||
|
if (apForm) {
|
||||||
|
apForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = {
|
||||||
|
ssid: document.getElementById('ap-ssid').value,
|
||||||
|
password: document.getElementById('ap-password').value,
|
||||||
|
channel: document.getElementById('ap-channel').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||||
|
showSettingsMessage('AP password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.channel) {
|
||||||
|
formData.channel = parseInt(formData.channel, 10);
|
||||||
|
if (formData.channel < 1 || formData.channel > 11) {
|
||||||
|
showSettingsMessage('Channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
showSettingsMessage('Access Point configured successfully!', 'success');
|
||||||
|
setTimeout(loadAPStatus, 1000);
|
||||||
|
} else {
|
||||||
|
showSettingsMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showSettingsMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -78,9 +78,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
patternsCloseButton.addEventListener('click', closeModal);
|
patternsCloseButton.addEventListener('click', closeModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
patternsModal.addEventListener('click', (event) => {
|
|
||||||
if (event.target === patternsModal) {
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,29 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
const profilesCloseButton = document.getElementById("profiles-close-btn");
|
||||||
const profilesList = document.getElementById("profiles-list");
|
const profilesList = document.getElementById("profiles-list");
|
||||||
const newProfileInput = document.getElementById("new-profile-name");
|
const newProfileInput = document.getElementById("new-profile-name");
|
||||||
|
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
|
||||||
const createProfileButton = document.getElementById("create-profile-btn");
|
const createProfileButton = document.getElementById("create-profile-btn");
|
||||||
|
|
||||||
if (!profilesButton || !profilesModal || !profilesList) {
|
if (!profilesButton || !profilesModal || !profilesList) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isEditModeActive = () => {
|
||||||
|
const toggle = document.querySelector('.ui-mode-toggle');
|
||||||
|
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfileEditorControlsVisibility = () => {
|
||||||
|
const editMode = isEditModeActive();
|
||||||
|
const actions = profilesModal.querySelector('.profiles-actions');
|
||||||
|
if (actions) {
|
||||||
|
actions.style.display = editMode ? '' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openModal = () => {
|
const openModal = () => {
|
||||||
profilesModal.classList.add("active");
|
profilesModal.classList.add("active");
|
||||||
|
updateProfileEditorControlsVisibility();
|
||||||
loadProfiles();
|
loadProfiles();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +34,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
profilesModal.classList.remove("active");
|
profilesModal.classList.remove("active");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshTabsForActiveProfile = async () => {
|
||||||
|
// Clear stale current tab so tab controller falls back to first tab of applied profile.
|
||||||
|
document.cookie = "current_tab=; path=/; max-age=0";
|
||||||
|
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabs === "function") {
|
||||||
|
await window.tabsManager.loadTabs();
|
||||||
|
}
|
||||||
|
if (window.tabsManager && typeof window.tabsManager.loadTabsModal === "function") {
|
||||||
|
await window.tabsManager.loadTabsModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderProfiles = (profiles, currentProfileId) => {
|
const renderProfiles = (profiles, currentProfileId) => {
|
||||||
profilesList.innerHTML = "";
|
profilesList.innerHTML = "";
|
||||||
let entries = [];
|
let entries = [];
|
||||||
@@ -26,7 +53,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (Array.isArray(profiles)) {
|
if (Array.isArray(profiles)) {
|
||||||
entries = profiles.map((profileId) => [profileId, {}]);
|
entries = profiles.map((profileId) => [profileId, {}]);
|
||||||
} else if (profiles && typeof profiles === "object") {
|
} else if (profiles && typeof profiles === "object") {
|
||||||
entries = Object.entries(profiles);
|
// Make sure we're iterating over profile entries, not metadata
|
||||||
|
entries = Object.entries(profiles).filter(([key]) => {
|
||||||
|
// Skip metadata keys like 'current_profile_id' if they exist
|
||||||
|
return key !== 'current_profile_id' && key !== 'profiles';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -37,6 +68,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
entries.forEach(([profileId, profile]) => {
|
entries.forEach(([profileId, profile]) => {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "profiles-row";
|
row.className = "profiles-row";
|
||||||
@@ -62,13 +94,63 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
throw new Error("Failed to apply profile");
|
throw new Error("Failed to apply profile");
|
||||||
}
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
document.body.dispatchEvent(new Event("tabs-updated"));
|
await refreshTabsForActiveProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Apply profile failed:", error);
|
console.error("Apply profile failed:", error);
|
||||||
alert("Failed to apply profile.");
|
alert("Failed to apply profile.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cloneButton = document.createElement("button");
|
||||||
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
|
cloneButton.textContent = "Clone";
|
||||||
|
cloneButton.addEventListener("click", async () => {
|
||||||
|
const baseName = (profile && profile.name) || profileId;
|
||||||
|
const suggested = `${baseName}`;
|
||||||
|
const name = prompt("New profile name:", suggested);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Profile name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/profiles/${profileId}/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to clone profile");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newProfileId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newProfileId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newProfileId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadProfiles();
|
||||||
|
await refreshTabsForActiveProfile();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clone profile failed:", error);
|
||||||
|
alert("Failed to clone profile.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const deleteButton = document.createElement("button");
|
const deleteButton = document.createElement("button");
|
||||||
deleteButton.className = "btn btn-danger btn-small";
|
deleteButton.className = "btn btn-danger btn-small";
|
||||||
deleteButton.textContent = "Delete";
|
deleteButton.textContent = "Delete";
|
||||||
@@ -94,7 +176,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
row.appendChild(applyButton);
|
row.appendChild(applyButton);
|
||||||
row.appendChild(deleteButton);
|
if (editMode) {
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
}
|
||||||
profilesList.appendChild(row);
|
profilesList.appendChild(row);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -113,19 +198,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to load profiles");
|
throw new Error("Failed to load profiles");
|
||||||
}
|
}
|
||||||
const profiles = await response.json();
|
const data = await response.json();
|
||||||
let currentProfileId = null;
|
// Handle both old format (just profiles object) and new format (with current_profile_id)
|
||||||
try {
|
const profiles = data.profiles || data;
|
||||||
const currentResponse = await fetch("/profiles/current", {
|
const currentProfileId = data.current_profile_id || null;
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (currentResponse.ok) {
|
|
||||||
const currentData = await currentResponse.json();
|
|
||||||
currentProfileId = currentData.id || null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to load current profile:", error);
|
|
||||||
}
|
|
||||||
renderProfiles(profiles, currentProfileId);
|
renderProfiles(profiles, currentProfileId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Load profiles failed:", error);
|
console.error("Load profiles failed:", error);
|
||||||
@@ -138,6 +214,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createProfile = async () => {
|
const createProfile = async () => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!newProfileInput) {
|
if (!newProfileInput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,13 +229,40 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const response = await fetch("/profiles", {
|
const response = await fetch("/profiles", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
seed_dj_tab: !!(newProfileSeedDjInput && newProfileSeedDjInput.checked),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to create profile");
|
throw new Error("Failed to create profile");
|
||||||
}
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newProfileId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newProfileId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newProfileId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newProfileId) {
|
||||||
|
await fetch(`/profiles/${newProfileId}/apply`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
newProfileInput.value = "";
|
newProfileInput.value = "";
|
||||||
|
if (newProfileSeedDjInput) {
|
||||||
|
newProfileSeedDjInput.checked = false;
|
||||||
|
}
|
||||||
await loadProfiles();
|
await loadProfiles();
|
||||||
|
await refreshTabsForActiveProfile();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create profile failed:", error);
|
console.error("Create profile failed:", error);
|
||||||
alert("Failed to create profile.");
|
alert("Failed to create profile.");
|
||||||
@@ -178,9 +284,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
profilesModal.addEventListener("click", (event) => {
|
// Keep modal controls in sync with run/edit mode.
|
||||||
if (event.target === profilesModal) {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
closeModal();
|
btn.addEventListener('click', () => {
|
||||||
}
|
if (profilesModal.classList.contains('active')) {
|
||||||
|
updateProfileEditorControlsVisibility();
|
||||||
|
loadProfiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,25 +20,70 @@ body {
|
|||||||
|
|
||||||
header {
|
header {
|
||||||
background-color: #1a1a1a;
|
background-color: #1a1a1a;
|
||||||
padding: 1rem 2rem;
|
padding: 0.75rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 2px solid #4a4a4a;
|
border-bottom: 2px solid #4a4a4a;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.35rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-mobile {
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
display: none;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown button {
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-menu-dropdown button:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header/menu actions that should only appear in Edit mode */
|
||||||
|
body.preset-ui-run .edit-mode-only {
|
||||||
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.45rem 0.9rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -87,15 +132,22 @@ header h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tabs-container {
|
.tabs-container {
|
||||||
background-color: #1a1a1a;
|
background-color: transparent;
|
||||||
border-bottom: 2px solid #4a4a4a;
|
padding: 0.5rem 0;
|
||||||
padding: 0.5rem 1rem;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs-list {
|
.tabs-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tab-button {
|
||||||
@@ -121,10 +173,27 @@ header h1 {
|
|||||||
|
|
||||||
.tab-content {
|
.tab-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.5rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presets-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-brightness-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
flex-direction: column;
|
||||||
padding: 1rem;
|
align-items: stretch;
|
||||||
gap: 1rem;
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-brightness-group label {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-panel {
|
.left-panel {
|
||||||
@@ -356,6 +425,149 @@ header h1 {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make the presets area fill available vertical space; no border around presets */
|
||||||
|
.presets-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab preset selecting area: 3 columns, vertical scroll only */
|
||||||
|
#presets-list-tab {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 5rem;
|
||||||
|
column-gap: 0.3rem;
|
||||||
|
row-gap: 0.3rem;
|
||||||
|
align-content: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings modal layout */
|
||||||
|
.settings-section {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info h3,
|
||||||
|
.status-info h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info p {
|
||||||
|
color: #aaa;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background-color: #1b5e20;
|
||||||
|
color: #4caf50;
|
||||||
|
border: 1px solid #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background-color: #5e1b1b;
|
||||||
|
color: #f44336;
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.patterns-list {
|
.patterns-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -363,21 +575,92 @@ header h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.presets-list {
|
.presets-list {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
flex-wrap: wrap;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pattern-button {
|
.pattern-button {
|
||||||
padding: 0.75rem;
|
height: 5rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: 3px solid #000;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: none;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset tile: main button + optional edit/remove (Edit mode) */
|
||||||
|
.preset-tile-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-row--run .preset-tile-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit only beside the preset tile in edit mode. */
|
||||||
|
.preset-tile-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0 0.15rem 0.25rem;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-editor-modal-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-tile-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit {
|
||||||
|
background-color: #4a3f8f;
|
||||||
|
border: 1px solid #7b6fd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-mode-toggle--edit:hover {
|
||||||
|
background-color: #5a4f9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset select buttons inside the tab grid */
|
||||||
|
#presets-list-tab .pattern-button {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.pattern-button .pattern-button-label {
|
||||||
|
text-shadow: 0 0 2px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pattern-button:hover {
|
.pattern-button:hover {
|
||||||
@@ -387,10 +670,28 @@ header h1 {
|
|||||||
.pattern-button.active {
|
.pattern-button.active {
|
||||||
background-color: #6a5acd;
|
background-color: #6a5acd;
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
.pattern-button.active[style*="background-image"] {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-button.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -3px;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 3px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #ffffff;
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pattern-button.default-preset {
|
.pattern-button.default-preset {
|
||||||
border: 2px solid #6a5acd;
|
/* No border; active state shows selection */
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-palette {
|
.color-palette {
|
||||||
@@ -489,7 +790,7 @@ header h1 {
|
|||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
min-width: 400px;
|
min-width: 320px;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,3 +847,265 @@ header h1 {
|
|||||||
background: #5a5a5a;
|
background: #5a5a5a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile-friendly layout */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
header {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
} header h1 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
|
||||||
|
.header-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-menu-mobile {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-container {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
flex: 1;
|
||||||
|
border-right: none;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
padding-left: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the "Presets for ..." heading to save space on mobile */
|
||||||
|
.presets-section h3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 95vw;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles moved from inline <style> in templates/index.html */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.modal-content label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-content input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.profiles-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.profiles-actions input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.profiles-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.profiles-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
/* Hide any text content in palette rows - only show color swatches */
|
||||||
|
#palette-container .profiles-row {
|
||||||
|
font-size: 0; /* Hide any text nodes */
|
||||||
|
}
|
||||||
|
#palette-container .profiles-row > * {
|
||||||
|
font-size: 1rem; /* Restore font size for buttons */
|
||||||
|
}
|
||||||
|
#palette-container .profiles-row > span:not(.btn),
|
||||||
|
#palette-container .profiles-row > label,
|
||||||
|
#palette-container .profiles-row::before,
|
||||||
|
#palette-container .profiles-row::after {
|
||||||
|
display: none !important;
|
||||||
|
content: none !important;
|
||||||
|
}
|
||||||
|
/* Preset colors container */
|
||||||
|
#preset-colors-container {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
#preset-colors-container .muted-text {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.muted-text {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #d32f2f;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #3a1a1a;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
/* Drag and drop styles for presets */
|
||||||
|
.draggable-preset {
|
||||||
|
cursor: move;
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.draggable-preset.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.draggable-preset:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
/* Drag and drop styles for color swatches */
|
||||||
|
.draggable-color-swatch {
|
||||||
|
transition: opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
.draggable-color-swatch.dragging-color {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
.draggable-color-swatch.drag-over-color {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.color-swatches-container {
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
#presets-list-tab {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Help modal readability */
|
||||||
|
#help-modal .modal-content {
|
||||||
|
max-width: 720px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content h2 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content h3 {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content p {
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content ul {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-left: 1.25rem;
|
||||||
|
padding-left: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
#help-modal .modal-content li {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
#help-modal .muted-text {
|
||||||
|
text-align: left;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab content placeholder (no tab selected) */
|
||||||
|
.tab-content-placeholder {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset editor: color actions row */
|
||||||
|
#preset-editor-modal .preset-colors-container + .profiles-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset editor: brightness/delay field wrappers */
|
||||||
|
.preset-editor-field {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings modal */
|
||||||
|
#settings-modal .modal-content {
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}#settings-modal .modal-content > p.muted-text {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}#settings-modal .settings-section.ap-settings-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|||||||
745
src/static/tabs.js
Normal file
745
src/static/tabs.js
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
// Tab management JavaScript
|
||||||
|
let currentTabId = null;
|
||||||
|
|
||||||
|
const isEditModeActive = () => {
|
||||||
|
const toggle = document.querySelector('.ui-mode-toggle');
|
||||||
|
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get current tab from cookie
|
||||||
|
function getCurrentTabFromCookie() {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let cookie of cookies) {
|
||||||
|
const [name, value] = cookie.trim().split('=');
|
||||||
|
if (name === 'current_tab') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tabs list
|
||||||
|
async function loadTabs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/tabs');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Get current tab from cookie first, then from server response
|
||||||
|
const cookieTabId = getCurrentTabFromCookie();
|
||||||
|
const serverCurrent = data.current_tab_id;
|
||||||
|
const tabs = data.tabs || {};
|
||||||
|
const tabIds = Object.keys(tabs);
|
||||||
|
|
||||||
|
let candidateId = cookieTabId || serverCurrent || null;
|
||||||
|
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab.
|
||||||
|
if (candidateId && !tabIds.includes(String(candidateId))) {
|
||||||
|
candidateId = tabIds.length > 0 ? tabIds[0] : null;
|
||||||
|
// Clear stale cookie
|
||||||
|
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabId = candidateId;
|
||||||
|
renderTabsList(data.tabs, data.tab_order, currentTabId);
|
||||||
|
|
||||||
|
// Load current tab content if available
|
||||||
|
if (currentTabId) {
|
||||||
|
await loadTabContent(currentTabId);
|
||||||
|
} else if (data.tab_order && data.tab_order.length > 0) {
|
||||||
|
// Set first tab as current if none is set
|
||||||
|
const firstTabId = data.tab_order[0];
|
||||||
|
await setCurrentTab(firstTabId);
|
||||||
|
await loadTabContent(firstTabId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tabs:', error);
|
||||||
|
const container = document.getElementById('tabs-list');
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = '<div class="error">Failed to load tabs</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tabs list in the main UI
|
||||||
|
function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||||
|
const container = document.getElementById('tabs-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!tabOrder || tabOrder.length === 0) {
|
||||||
|
container.innerHTML = '<div class="muted-text">No tabs available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
|
let html = '<div class="tabs-list">';
|
||||||
|
for (const tabId of tabOrder) {
|
||||||
|
const tab = tabs[tabId];
|
||||||
|
if (tab) {
|
||||||
|
const activeClass = tabId === currentTabId ? 'active' : '';
|
||||||
|
const tabName = tab.name || `Tab ${tabId}`;
|
||||||
|
html += `
|
||||||
|
<button class="tab-button ${activeClass}"
|
||||||
|
data-tab-id="${tabId}"
|
||||||
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
||||||
|
onclick="selectTab('${tabId}')">
|
||||||
|
${tabName}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tabs list in modal (like profiles)
|
||||||
|
function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||||
|
const container = document.getElementById('tabs-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
let entries = [];
|
||||||
|
|
||||||
|
if (Array.isArray(tabOrder)) {
|
||||||
|
entries = tabOrder.map((tabId) => [tabId, tabs[tabId] || {}]);
|
||||||
|
} else if (tabs && typeof tabs === "object") {
|
||||||
|
entries = Object.entries(tabs).filter(([key]) => {
|
||||||
|
return key !== 'current_tab_id' && key !== 'tabs' && key !== 'tab_order';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted-text";
|
||||||
|
empty.textContent = "No tabs found.";
|
||||||
|
container.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMode = isEditModeActive();
|
||||||
|
entries.forEach(([tabId, tab]) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "profiles-row";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = (tab && tab.name) || tabId;
|
||||||
|
if (String(tabId) === String(currentTabId)) {
|
||||||
|
label.textContent = `✓ ${label.textContent}`;
|
||||||
|
label.style.fontWeight = "bold";
|
||||||
|
label.style.color = "#FFD700";
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyButton = document.createElement("button");
|
||||||
|
applyButton.className = "btn btn-secondary btn-small";
|
||||||
|
applyButton.textContent = "Select";
|
||||||
|
applyButton.addEventListener("click", async () => {
|
||||||
|
await selectTab(tabId);
|
||||||
|
document.getElementById('tabs-modal').classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
const editButton = document.createElement("button");
|
||||||
|
editButton.className = "btn btn-secondary btn-small";
|
||||||
|
editButton.textContent = "Edit";
|
||||||
|
editButton.addEventListener("click", () => {
|
||||||
|
openEditTabModal(tabId, tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cloneButton = document.createElement("button");
|
||||||
|
cloneButton.className = "btn btn-secondary btn-small";
|
||||||
|
cloneButton.textContent = "Clone";
|
||||||
|
cloneButton.addEventListener("click", async () => {
|
||||||
|
const baseName = (tab && tab.name) || tabId;
|
||||||
|
const suggested = `${baseName} Copy`;
|
||||||
|
const name = prompt("New tab name:", suggested);
|
||||||
|
if (name === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = String(name).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
alert("Tab name cannot be empty.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}/clone`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: trimmed }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
|
||||||
|
throw new Error(errorData.error || "Failed to clone tab");
|
||||||
|
}
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
let newTabId = null;
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
if (data.id) {
|
||||||
|
newTabId = String(data.id);
|
||||||
|
} else {
|
||||||
|
const ids = Object.keys(data);
|
||||||
|
if (ids.length > 0) {
|
||||||
|
newTabId = String(ids[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadTabsModal();
|
||||||
|
if (newTabId) {
|
||||||
|
await selectTab(newTabId);
|
||||||
|
} else {
|
||||||
|
await loadTabs();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Clone tab failed:", error);
|
||||||
|
alert("Failed to clone tab: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteButton = document.createElement("button");
|
||||||
|
deleteButton.className = "btn btn-danger btn-small";
|
||||||
|
deleteButton.textContent = "Delete";
|
||||||
|
deleteButton.addEventListener("click", async () => {
|
||||||
|
const confirmed = confirm(`Delete tab "${label.textContent}"?`);
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: "Failed to delete tab" }));
|
||||||
|
throw new Error(errorData.error || "Failed to delete tab");
|
||||||
|
}
|
||||||
|
// Clear cookie if deleted tab was current
|
||||||
|
if (tabId === currentTabId) {
|
||||||
|
document.cookie = 'current_tab=; path=/; max-age=0';
|
||||||
|
currentTabId = null;
|
||||||
|
}
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs(); // Reload main tabs list
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete tab failed:", error);
|
||||||
|
alert("Failed to delete tab: " + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(applyButton);
|
||||||
|
if (editMode) {
|
||||||
|
row.appendChild(editButton);
|
||||||
|
row.appendChild(cloneButton);
|
||||||
|
row.appendChild(deleteButton);
|
||||||
|
}
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tabs in modal
|
||||||
|
async function loadTabsModal() {
|
||||||
|
const container = document.getElementById('tabs-list-modal');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = "";
|
||||||
|
const loading = document.createElement("p");
|
||||||
|
loading.className = "muted-text";
|
||||||
|
loading.textContent = "Loading tabs...";
|
||||||
|
container.appendChild(loading);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/tabs", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to load tabs");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const tabs = data.tabs || data;
|
||||||
|
const currentTabId = getCurrentTabFromCookie() || data.current_tab_id || null;
|
||||||
|
renderTabsListModal(tabs, data.tab_order || [], currentTabId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Load tabs failed:", error);
|
||||||
|
container.innerHTML = "";
|
||||||
|
const errorMessage = document.createElement("p");
|
||||||
|
errorMessage.className = "muted-text";
|
||||||
|
errorMessage.textContent = "Failed to load tabs.";
|
||||||
|
container.appendChild(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select a tab
|
||||||
|
async function selectTab(tabId) {
|
||||||
|
// Update active state
|
||||||
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
const btn = document.querySelector(`[data-tab-id="${tabId}"]`);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set as current tab
|
||||||
|
await setCurrentTab(tabId);
|
||||||
|
// Load tab content
|
||||||
|
loadTabContent(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current tab in cookie
|
||||||
|
async function setCurrentTab(tabId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}/set-current`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
currentTabId = tabId;
|
||||||
|
// Also set cookie on client side
|
||||||
|
document.cookie = `current_tab=${tabId}; path=/; max-age=31536000`;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to set current tab:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting current tab:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tab content
|
||||||
|
async function loadTabContent(tabId) {
|
||||||
|
const container = document.getElementById('tab-content');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
|
const tab = await response.json();
|
||||||
|
|
||||||
|
if (tab.error) {
|
||||||
|
container.innerHTML = `<div class="error">${tab.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render tab content (presets section)
|
||||||
|
const tabName = tab.name || `Tab ${tabId}`;
|
||||||
|
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
|
||||||
|
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||||
|
<div class="tab-brightness-group">
|
||||||
|
<label for="tab-brightness-slider">Brightness</label>
|
||||||
|
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="presets-list-tab" class="presets-list">
|
||||||
|
<!-- Presets will be loaded here by presets.js -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wire up per-tab brightness slider to send global brightness via ESPNow.
|
||||||
|
const brightnessSlider = container.querySelector('#tab-brightness-slider');
|
||||||
|
let brightnessSendTimeout = null;
|
||||||
|
if (brightnessSlider) {
|
||||||
|
brightnessSlider.addEventListener('input', (e) => {
|
||||||
|
const val = parseInt(e.target.value, 10) || 0;
|
||||||
|
if (brightnessSendTimeout) {
|
||||||
|
clearTimeout(brightnessSendTimeout);
|
||||||
|
}
|
||||||
|
brightnessSendTimeout = setTimeout(() => {
|
||||||
|
if (typeof window.sendEspnowRaw === 'function') {
|
||||||
|
try {
|
||||||
|
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send brightness via ESPNow:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger presets loading if the function exists
|
||||||
|
if (typeof renderTabPresets === 'function') {
|
||||||
|
renderTabPresets(tabId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tab content:', error);
|
||||||
|
container.innerHTML = '<div class="error">Failed to load tab content</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all presets used by all tabs in the current profile via /presets/send.
|
||||||
|
async function sendProfilePresets() {
|
||||||
|
try {
|
||||||
|
// Load current profile to get its tabs
|
||||||
|
const profileRes = await fetch('/profiles/current', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!profileRes.ok) {
|
||||||
|
alert('Failed to load current profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profileData = await profileRes.json();
|
||||||
|
const profile = profileData.profile || {};
|
||||||
|
let tabList = null;
|
||||||
|
if (Array.isArray(profile.tabs)) {
|
||||||
|
tabList = profile.tabs;
|
||||||
|
} else if (profile.tabs) {
|
||||||
|
tabList = [profile.tabs];
|
||||||
|
}
|
||||||
|
if (!tabList || tabList.length === 0) {
|
||||||
|
if (Array.isArray(profile.tab_order)) {
|
||||||
|
tabList = profile.tab_order;
|
||||||
|
} else if (profile.tab_order) {
|
||||||
|
tabList = [profile.tab_order];
|
||||||
|
} else {
|
||||||
|
tabList = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tabList || tabList.length === 0) {
|
||||||
|
console.warn('sendProfilePresets: no tabs found', {
|
||||||
|
profileData,
|
||||||
|
profile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabList.length) {
|
||||||
|
alert('Current profile has no tabs to send presets for.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSent = 0;
|
||||||
|
let totalMessages = 0;
|
||||||
|
let tabsWithPresets = 0;
|
||||||
|
|
||||||
|
for (const tabId of tabList) {
|
||||||
|
try {
|
||||||
|
const tabResp = await fetch(`/tabs/${tabId}`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!tabResp.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tabData = await tabResp.json();
|
||||||
|
let presetIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
presetIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
presetIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
presetIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
presetIds = (presetIds || []).filter(Boolean);
|
||||||
|
if (!presetIds.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tabsWithPresets += 1;
|
||||||
|
const payload = { preset_ids: presetIds };
|
||||||
|
if (tabData.default_preset) {
|
||||||
|
payload.default = tabData.default_preset;
|
||||||
|
}
|
||||||
|
const response = await fetch('/presets/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const msg = (data && data.error) || `Failed to send presets for tab ${tabId}.`;
|
||||||
|
console.warn(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
||||||
|
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send profile presets for tab:', tabId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tabsWithPresets) {
|
||||||
|
alert('No presets to send for the current profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messagesLabel = totalMessages ? totalMessages : '?';
|
||||||
|
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send profile presets:', error);
|
||||||
|
alert('Failed to send profile presets.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
|
||||||
|
async function populateEditTabPresetsList(tabId) {
|
||||||
|
const listEl = document.getElementById('edit-tab-presets-list');
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
||||||
|
try {
|
||||||
|
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!tabRes.ok) {
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tabData = await tabRes.json();
|
||||||
|
let inTabIds = [];
|
||||||
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
|
inTabIds = tabData.presets_flat;
|
||||||
|
} else if (Array.isArray(tabData.presets)) {
|
||||||
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
|
inTabIds = tabData.presets;
|
||||||
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||||
|
inTabIds = tabData.presets.flat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
|
||||||
|
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||||
|
const allIds = Object.keys(allPresets);
|
||||||
|
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (availableToAdd.length === 0) {
|
||||||
|
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const presetId of availableToAdd) {
|
||||||
|
const preset = allPresets[presetId] || {};
|
||||||
|
const name = preset.name || presetId;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'profiles-row';
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.style.justifyContent = 'space-between';
|
||||||
|
row.style.gap = '0.5rem';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = name;
|
||||||
|
const selectBtn = document.createElement('button');
|
||||||
|
selectBtn.type = 'button';
|
||||||
|
selectBtn.className = 'btn btn-primary btn-small';
|
||||||
|
selectBtn.textContent = 'Select';
|
||||||
|
selectBtn.addEventListener('click', async () => {
|
||||||
|
if (typeof window.addPresetToTab === 'function') {
|
||||||
|
await window.addPresetToTab(presetId, tabId);
|
||||||
|
await populateEditTabPresetsList(tabId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(selectBtn);
|
||||||
|
listEl.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('populateEditTabPresetsList:', e);
|
||||||
|
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open edit tab modal
|
||||||
|
function openEditTabModal(tabId, tab) {
|
||||||
|
const modal = document.getElementById('edit-tab-modal');
|
||||||
|
const idInput = document.getElementById('edit-tab-id');
|
||||||
|
const nameInput = document.getElementById('edit-tab-name');
|
||||||
|
const idsInput = document.getElementById('edit-tab-ids');
|
||||||
|
|
||||||
|
if (idInput) idInput.value = tabId;
|
||||||
|
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
|
||||||
|
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
|
||||||
|
|
||||||
|
if (modal) modal.classList.add('active');
|
||||||
|
populateEditTabPresetsList(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update an existing tab
|
||||||
|
async function updateTab(tabId, name, ids) {
|
||||||
|
try {
|
||||||
|
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||||
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
names: names
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload tabs list
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs();
|
||||||
|
// Close modal
|
||||||
|
document.getElementById('edit-tab-modal').classList.remove('active');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.error || 'Failed to update tab'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update tab:', error);
|
||||||
|
alert('Failed to update tab');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new tab
|
||||||
|
async function createTab(name, ids) {
|
||||||
|
try {
|
||||||
|
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||||
|
const response = await fetch('/tabs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: name,
|
||||||
|
names: names
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload tabs list
|
||||||
|
await loadTabsModal();
|
||||||
|
await loadTabs();
|
||||||
|
// Select the new tab
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const newTabId = Object.keys(data)[0];
|
||||||
|
await selectTab(newTabId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${data.error || 'Failed to create tab'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create tab:', error);
|
||||||
|
alert('Failed to create tab');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadTabs();
|
||||||
|
|
||||||
|
// Set up tabs modal
|
||||||
|
const tabsButton = document.getElementById('tabs-btn');
|
||||||
|
const tabsModal = document.getElementById('tabs-modal');
|
||||||
|
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
||||||
|
const newTabNameInput = document.getElementById('new-tab-name');
|
||||||
|
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||||
|
const createTabButton = document.getElementById('create-tab-btn');
|
||||||
|
|
||||||
|
if (tabsButton && tabsModal) {
|
||||||
|
tabsButton.addEventListener('click', () => {
|
||||||
|
tabsModal.classList.add('active');
|
||||||
|
loadTabsModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabsCloseButton) {
|
||||||
|
tabsCloseButton.addEventListener('click', () => {
|
||||||
|
tabsModal.classList.remove('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right-click on a tab button in the main header bar to edit that tab
|
||||||
|
document.addEventListener('contextmenu', async (event) => {
|
||||||
|
if (!isEditModeActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = event.target.closest('.tab-button');
|
||||||
|
if (!btn || !btn.dataset.tabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
const tabId = btn.dataset.tabId;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const tab = await response.json();
|
||||||
|
openEditTabModal(tabId, tab);
|
||||||
|
} else {
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load tab:', error);
|
||||||
|
alert('Failed to load tab for editing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up create tab
|
||||||
|
const createTabHandler = async () => {
|
||||||
|
if (!newTabNameInput) return;
|
||||||
|
const name = newTabNameInput.value.trim();
|
||||||
|
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
await createTab(name, ids);
|
||||||
|
if (newTabNameInput) newTabNameInput.value = '';
|
||||||
|
if (newTabIdsInput) newTabIdsInput.value = '1';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createTabButton) {
|
||||||
|
createTabButton.addEventListener('click', createTabHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newTabNameInput) {
|
||||||
|
newTabNameInput.addEventListener('keypress', (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
createTabHandler();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up edit tab form
|
||||||
|
const editTabForm = document.getElementById('edit-tab-form');
|
||||||
|
if (editTabForm) {
|
||||||
|
editTabForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const idInput = document.getElementById('edit-tab-id');
|
||||||
|
const nameInput = document.getElementById('edit-tab-name');
|
||||||
|
const idsInput = document.getElementById('edit-tab-ids');
|
||||||
|
|
||||||
|
const tabId = idInput ? idInput.value : null;
|
||||||
|
const name = nameInput ? nameInput.value.trim() : '';
|
||||||
|
const ids = idsInput ? idsInput.value.trim() : '1';
|
||||||
|
|
||||||
|
if (tabId && name) {
|
||||||
|
await updateTab(tabId, name, ids);
|
||||||
|
editTabForm.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile-wide "Send Presets" button in header
|
||||||
|
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
||||||
|
if (sendProfilePresetsBtn) {
|
||||||
|
sendProfilePresetsBtn.addEventListener('click', async () => {
|
||||||
|
await sendProfilePresets();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
||||||
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
await loadTabs();
|
||||||
|
if (tabsModal && tabsModal.classList.contains('active')) {
|
||||||
|
await loadTabsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
window.tabsManager = {
|
||||||
|
loadTabs,
|
||||||
|
loadTabsModal,
|
||||||
|
selectTab,
|
||||||
|
createTab,
|
||||||
|
updateTab,
|
||||||
|
openEditTabModal,
|
||||||
|
getCurrentTabId: () => currentTabId
|
||||||
|
};
|
||||||
@@ -5,88 +5,82 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LED Controller - Tab Mode</title>
|
<title>LED Controller - Tab Mode</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header>
|
<header>
|
||||||
<h1>LED Controller - Tab Mode</h1>
|
<div class="tabs-container">
|
||||||
|
<div id="tabs-list">
|
||||||
|
Loading tabs...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-primary"
|
|
||||||
hx-get="/tabs/create-form-fragment"
|
|
||||||
hx-target="#add-tab-modal .modal-content"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
onclick="document.getElementById('add-tab-modal').classList.add('active')">
|
|
||||||
+ Add Tab
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="edit-tab-btn">Edit Tab</button>
|
|
||||||
<button class="btn btn-danger"
|
|
||||||
hx-delete="/tabs/current"
|
|
||||||
hx-target="#tabs-list"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-headers='{"Accept": "text/html"}'
|
|
||||||
hx-confirm="Are you sure you want to delete this tab?">
|
|
||||||
Delete Tab
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
|
|
||||||
<button class="btn btn-secondary" id="presets-btn">Presets</button>
|
|
||||||
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
|
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="color-palette-btn">Colour Palette</button>
|
||||||
|
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||||
|
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||||
|
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-menu-mobile">
|
||||||
|
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||||
|
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||||
|
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||||
|
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="color-palette-btn">Colour Palette</button>
|
||||||
|
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||||
|
<button type="button" data-target="help-btn">Help</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<div class="tabs-container">
|
<div id="tab-content" class="tab-content">
|
||||||
<div id="tabs-list"
|
<div class="tab-content-placeholder">
|
||||||
hx-get="/tabs/list-fragment"
|
|
||||||
hx-trigger="load, tabs-updated from:body"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
Loading tabs...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tab-content"
|
|
||||||
class="tab-content"
|
|
||||||
hx-get="/tabs/current"
|
|
||||||
hx-trigger="load, tabs-updated from:body"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-headers='{"Accept": "text/html"}'>
|
|
||||||
<div style="padding: 2rem; text-align: center; color: #aaa;">
|
|
||||||
Select a tab to get started
|
Select a tab to get started
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Tab Modal -->
|
<!-- Tabs Modal -->
|
||||||
<div id="add-tab-modal" class="modal">
|
<div id="tabs-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Add New Tab</h2>
|
<h2>Tabs</h2>
|
||||||
<form hx-post="/tabs"
|
<div class="profiles-actions">
|
||||||
hx-target="#tabs-list"
|
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||||
hx-swap="innerHTML"
|
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
|
||||||
hx-headers='{"Accept": "text/html"}'
|
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||||
hx-on::after-request="if(event.detail.successful) { document.getElementById('add-tab-modal').classList.remove('active'); document.body.dispatchEvent(new Event('tabs-updated')); }">
|
</div>
|
||||||
<label>Tab Name:</label>
|
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||||
<input type="text" name="name" placeholder="Enter tab name" required>
|
<div class="modal-actions">
|
||||||
<label>Device IDs (comma-separated):</label>
|
<button class="btn btn-secondary" id="tabs-close-btn">Close</button>
|
||||||
<input type="text" name="ids" placeholder="1,2,3" value="1">
|
</div>
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="submit" class="btn btn-primary">Add</button>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="document.getElementById('add-tab-modal').classList.remove('active')">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edit Tab Modal (placeholder for now) -->
|
<!-- Edit Tab Modal -->
|
||||||
<div id="edit-tab-modal" class="modal">
|
<div id="edit-tab-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Edit Tab</h2>
|
<h2>Edit Tab</h2>
|
||||||
<p>Edit functionality coming soon...</p>
|
<form id="edit-tab-form">
|
||||||
<div class="modal-actions">
|
<input type="hidden" id="edit-tab-id">
|
||||||
<button class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
<div class="modal-actions" style="margin-bottom: 1rem;">
|
||||||
</div>
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
|
||||||
|
</div>
|
||||||
|
<label>Tab Name:</label>
|
||||||
|
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||||
|
<label>Device IDs (comma-separated):</label>
|
||||||
|
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
||||||
|
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
||||||
|
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +92,12 @@
|
|||||||
<input type="text" id="new-profile-name" placeholder="Profile name">
|
<input type="text" id="new-profile-name" placeholder="Profile name">
|
||||||
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
<button class="btn btn-primary" id="create-profile-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
|
||||||
|
<input type="checkbox" id="new-profile-seed-dj">
|
||||||
|
DJ tab
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div id="profiles-list" class="profiles-list"></div>
|
<div id="profiles-list" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
<button class="btn btn-secondary" id="profiles-close-btn">Close</button>
|
||||||
@@ -129,16 +129,21 @@
|
|||||||
<option value="">Pattern</option>
|
<option value="">Pattern</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<label>Colors</label>
|
<label>Colours</label>
|
||||||
<div id="preset-colors-container" class="preset-colors-container"></div>
|
<div id="preset-colors-container" class="preset-colors-container"></div>
|
||||||
<div class="profiles-actions" style="margin-top: 0.5rem;">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="preset-new-color" value="#ffffff">
|
<input type="color" id="preset-new-color" value="#ffffff" title="Choose colour (auto-adds)">
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
|
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">From Palette</button>
|
||||||
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
<div class="preset-editor-field">
|
||||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
<label for="preset-brightness-input">Brightness (0–255)</label>
|
||||||
|
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-field">
|
||||||
|
<label for="preset-delay-input">Delay (ms)</label>
|
||||||
|
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
@@ -174,9 +179,11 @@
|
|||||||
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
|
<input type="number" id="preset-n8-input" min="0" max="255" value="0" class="n-input">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions preset-editor-modal-actions">
|
||||||
<button class="btn btn-primary" id="preset-save-btn">Save</button>
|
<button class="btn btn-secondary" id="preset-send-btn">Try</button>
|
||||||
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
|
<button class="btn btn-secondary" id="preset-default-btn">Default</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="preset-remove-from-tab-btn" hidden>Remove from tab</button>
|
||||||
|
<button class="btn btn-primary" id="preset-save-btn">Save & Send</button>
|
||||||
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,15 +200,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Color Palette Modal -->
|
<!-- Colour Palette Modal -->
|
||||||
<div id="color-palette-modal" class="modal">
|
<div id="color-palette-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Color Palette</h2>
|
<h2>Colour Palette</h2>
|
||||||
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
<p class="muted-text">Profile: <span id="palette-current-profile-name">None</span></p>
|
||||||
<div id="palette-container" class="profiles-list"></div>
|
<div id="palette-container" class="profiles-list"></div>
|
||||||
<div class="profiles-actions">
|
<div class="profiles-actions">
|
||||||
<input type="color" id="palette-new-color" value="#ffffff">
|
<input type="color" id="palette-new-color" value="#ffffff">
|
||||||
<button class="btn btn-primary" id="palette-add-color-btn">Add Color</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
<button class="btn btn-secondary" id="color-palette-close-btn">Close</button>
|
||||||
@@ -209,145 +215,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<!-- Help Modal -->
|
||||||
.modal {
|
<div id="help-modal" class="modal">
|
||||||
display: none;
|
<div class="modal-content">
|
||||||
position: fixed;
|
<h2>Help</h2>
|
||||||
z-index: 1000;
|
<p class="muted-text">How to use the LED controller UI.</p>
|
||||||
left: 0;
|
|
||||||
top: 0;
|
<h3>Run mode</h3>
|
||||||
width: 100%;
|
<ul>
|
||||||
height: 100%;
|
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
|
||||||
background-color: rgba(0,0,0,0.7);
|
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
|
||||||
}
|
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
|
||||||
.modal.active {
|
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
|
||||||
display: flex;
|
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
|
||||||
align-items: center;
|
</ul>
|
||||||
justify-content: center;
|
|
||||||
}
|
<h3>Edit mode</h3>
|
||||||
.modal-content {
|
<ul>
|
||||||
background-color: #2e2e2e;
|
<li><strong>Tabs</strong>: create, edit, and manage tabs and device assignments.</li>
|
||||||
padding: 2rem;
|
<li><strong>Presets</strong>: create/manage reusable presets and edit preset details.</li>
|
||||||
border-radius: 8px;
|
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
|
||||||
min-width: 400px;
|
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
|
||||||
max-width: 600px;
|
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
|
||||||
}
|
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
|
||||||
.modal-content label {
|
</ul>
|
||||||
display: block;
|
|
||||||
margin-top: 1rem;
|
<div class="modal-actions">
|
||||||
margin-bottom: 0.5rem;
|
<button class="btn btn-secondary" id="help-close-btn">Close</button>
|
||||||
}
|
</div>
|
||||||
.modal-content input[type="text"] {
|
</div>
|
||||||
width: 100%;
|
</div>
|
||||||
padding: 0.5rem;
|
|
||||||
background-color: #3a3a3a;
|
<!-- Settings Modal -->
|
||||||
border: 1px solid #4a4a4a;
|
<div id="settings-modal" class="modal">
|
||||||
border-radius: 4px;
|
<div class="modal-content">
|
||||||
color: white;
|
<h2>Device Settings</h2>
|
||||||
}
|
<p class="muted-text">Configure WiFi Access Point and device settings.</p>
|
||||||
.profiles-actions {
|
|
||||||
display: flex;
|
<div id="settings-message" class="message"></div>
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
<!-- Device Name -->
|
||||||
}
|
<div class="settings-section">
|
||||||
.profiles-actions input[type="text"] {
|
<h3>Device</h3>
|
||||||
flex: 1;
|
<form id="device-form">
|
||||||
}
|
<div class="form-group">
|
||||||
.profiles-list {
|
<label for="device-name-input">Device Name</label>
|
||||||
display: flex;
|
<input type="text" id="device-name-input" name="device_name" placeholder="e.g. led-controller" required>
|
||||||
flex-direction: column;
|
<small>This name may be used for mDNS (e.g. <code>name.local</code>) and UI display.</small>
|
||||||
gap: 0.5rem;
|
</div>
|
||||||
margin-top: 1rem;
|
<div class="form-group">
|
||||||
max-height: 50vh;
|
<label for="wifi-channel-input">WiFi channel (ESP-NOW)</label>
|
||||||
overflow-y: auto;
|
<input type="number" id="wifi-channel-input" name="wifi_channel" min="1" max="11" required>
|
||||||
}
|
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value everywhere.</small>
|
||||||
.profiles-row {
|
</div>
|
||||||
display: flex;
|
<div class="btn-group">
|
||||||
align-items: center;
|
<button type="submit" class="btn btn-primary btn-full">Save device settings</button>
|
||||||
justify-content: space-between;
|
</div>
|
||||||
gap: 0.5rem;
|
</form>
|
||||||
padding: 0.5rem;
|
</div>
|
||||||
background-color: #3a3a3a;
|
|
||||||
border-radius: 4px;
|
<!-- WiFi Access Point Settings -->
|
||||||
}
|
<div class="settings-section ap-settings-section">
|
||||||
/* Hide any text content in palette rows - only show color swatches */
|
<h3>WiFi Access Point</h3>
|
||||||
#palette-container .profiles-row {
|
|
||||||
font-size: 0; /* Hide any text nodes */
|
<div id="ap-status" class="status-info">
|
||||||
}
|
<h4>AP Status</h4>
|
||||||
#palette-container .profiles-row > * {
|
<p>Loading...</p>
|
||||||
font-size: 1rem; /* Restore font size for buttons */
|
</div>
|
||||||
}
|
|
||||||
#palette-container .profiles-row > span:not(.btn),
|
<form id="ap-form">
|
||||||
#palette-container .profiles-row > label,
|
<div class="form-group">
|
||||||
#palette-container .profiles-row::before,
|
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||||
#palette-container .profiles-row::after {
|
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||||
display: none !important;
|
<small>The name of the WiFi access point this device creates</small>
|
||||||
content: none !important;
|
</div>
|
||||||
}
|
|
||||||
/* Preset colors container */
|
<div class="form-group">
|
||||||
#preset-colors-container {
|
<label for="ap-password">AP Password</label>
|
||||||
min-height: 80px;
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
padding: 0.5rem;
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
background-color: #2a2a2a;
|
</div>
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
<div class="form-group">
|
||||||
}
|
<label for="ap-channel">Channel (1-11)</label>
|
||||||
#preset-colors-container .muted-text {
|
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||||
color: #888;
|
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||||
font-size: 0.9rem;
|
</div>
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
<div class="btn-group">
|
||||||
}
|
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||||
.muted-text {
|
</div>
|
||||||
text-align: center;
|
</form>
|
||||||
color: #888;
|
</div>
|
||||||
}
|
|
||||||
.modal-actions {
|
<div class="modal-actions">
|
||||||
display: flex;
|
<button class="btn btn-secondary" id="settings-close-btn">Close</button>
|
||||||
gap: 0.5rem;
|
</div>
|
||||||
margin-top: 1.5rem;
|
</div>
|
||||||
justify-content: flex-end;
|
</div>
|
||||||
}
|
|
||||||
.error {
|
<!-- Styles moved to /static/style.css -->
|
||||||
color: #d32f2f;
|
<script src="/static/tabs.js"></script>
|
||||||
padding: 0.5rem;
|
<script src="/static/help.js"></script>
|
||||||
background-color: #3a1a1a;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
/* Drag and drop styles for presets */
|
|
||||||
.draggable-preset {
|
|
||||||
cursor: move;
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
.draggable-preset.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
|
||||||
.draggable-preset:hover {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
/* Drag and drop styles for color swatches */
|
|
||||||
.draggable-color-swatch {
|
|
||||||
transition: opacity 0.2s, transform 0.2s;
|
|
||||||
}
|
|
||||||
.draggable-color-swatch.dragging-color {
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: scale(0.9);
|
|
||||||
}
|
|
||||||
.draggable-color-swatch.drag-over-color {
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.color-swatches-container {
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
/* Ensure presets list uses grid layout */
|
|
||||||
#presets-list-tab {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/tab_palette.js"></script>
|
<script src="/static/tab_palette.js"></script>
|
||||||
|
|||||||
365
src/templates/settings.html
Normal file
365
src/templates/settings.html
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LED Controller - Settings</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
.settings-container {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header p {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 2px solid #4a4a4a;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info p {
|
||||||
|
color: #aaa;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-connected {
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disconnected {
|
||||||
|
color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-full {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #aaa;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background-color: #2e2e2e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.success {
|
||||||
|
background-color: #1b5e20;
|
||||||
|
color: #4caf50;
|
||||||
|
border: 1px solid #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.error {
|
||||||
|
background-color: #5e1b1b;
|
||||||
|
color: #f44336;
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="settings-container">
|
||||||
|
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||||
|
|
||||||
|
<div class="settings-header">
|
||||||
|
<h1>Device Settings</h1>
|
||||||
|
<p>Configure WiFi Access Point and ESP-NOW options</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="message"></div>
|
||||||
|
|
||||||
|
<!-- ESP-NOW (LED driver / bridge channel) -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>ESP-NOW</h2>
|
||||||
|
<form id="espnow-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="wifi-channel-page-input">WiFi channel (ESP-NOW)</label>
|
||||||
|
<input type="number" id="wifi-channel-page-input" name="wifi_channel" min="1" max="11" required>
|
||||||
|
<small>STA channel (1–11) for LED drivers and the serial bridge. Use the same value on every device.</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Save channel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WiFi Access Point Settings -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>WiFi Access Point Settings</h2>
|
||||||
|
|
||||||
|
<div id="ap-status" class="status-info">
|
||||||
|
<h3>AP Status</h3>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="ap-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-ssid">AP SSID (Network Name)</label>
|
||||||
|
<input type="text" id="ap-ssid" name="ssid" placeholder="Enter AP name" required>
|
||||||
|
<small>The name of the WiFi access point this device creates</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-password">AP Password</label>
|
||||||
|
<input type="password" id="ap-password" name="password" placeholder="Enter AP password (min 8 chars)" data-bwignore>
|
||||||
|
<small>Leave empty for open network (min 8 characters if set)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ap-channel">Channel (1-11)</label>
|
||||||
|
<input type="number" id="ap-channel" name="channel" min="1" max="11" placeholder="Auto">
|
||||||
|
<small>WiFi channel (1-11 for 2.4GHz). Leave empty for auto.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Configure AP</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Show message helper
|
||||||
|
function showMessage(text, type = 'success') {
|
||||||
|
const messageEl = document.getElementById('message');
|
||||||
|
messageEl.textContent = text;
|
||||||
|
messageEl.className = `message ${type} show`;
|
||||||
|
setTimeout(() => {
|
||||||
|
messageEl.classList.remove('show');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEspnowChannel() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings');
|
||||||
|
const data = await response.json();
|
||||||
|
const chInput = document.getElementById('wifi-channel-page-input');
|
||||||
|
if (chInput && data && typeof data === 'object') {
|
||||||
|
const ch = data.wifi_channel;
|
||||||
|
chInput.value =
|
||||||
|
ch !== undefined && ch !== null && ch !== '' ? String(ch) : '6';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading ESP-NOW channel:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('espnow-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const chRaw = document.getElementById('wifi-channel-page-input').value;
|
||||||
|
const wifiChannel = parseInt(chRaw, 10);
|
||||||
|
if (Number.isNaN(wifiChannel) || wifiChannel < 1 || wifiChannel > 11) {
|
||||||
|
showMessage('WiFi channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
showMessage('ESP-NOW channel saved.', 'success');
|
||||||
|
} else {
|
||||||
|
showMessage(`Error: ${result.error || 'Failed to save'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load AP status and config
|
||||||
|
async function loadAPStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap');
|
||||||
|
const config = await response.json();
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('ap-status');
|
||||||
|
if (config.active) {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h3>AP Status: <span class="status-connected">Active</span></h3>
|
||||||
|
<p><strong>SSID:</strong> ${config.ssid || 'N/A'}</p>
|
||||||
|
<p><strong>Channel:</strong> ${config.channel || 'Auto'}</p>
|
||||||
|
<p><strong>IP Address:</strong> ${config.ip || 'N/A'}</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = `
|
||||||
|
<h3>AP Status: <span class="status-disconnected">Inactive</span></h3>
|
||||||
|
<p>Access Point is not currently active</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved values
|
||||||
|
if (config.saved_ssid) document.getElementById('ap-ssid').value = config.saved_ssid;
|
||||||
|
if (config.saved_channel) document.getElementById('ap-channel').value = config.saved_channel;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading AP status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AP form submission
|
||||||
|
document.getElementById('ap-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
ssid: document.getElementById('ap-ssid').value,
|
||||||
|
password: document.getElementById('ap-password').value,
|
||||||
|
channel: document.getElementById('ap-channel').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate password length if provided
|
||||||
|
if (formData.password && formData.password.length > 0 && formData.password.length < 8) {
|
||||||
|
showMessage('AP password must be at least 8 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert channel to number if provided
|
||||||
|
if (formData.channel) {
|
||||||
|
formData.channel = parseInt(formData.channel);
|
||||||
|
if (formData.channel < 1 || formData.channel > 11) {
|
||||||
|
showMessage('Channel must be between 1 and 11', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/settings/wifi/ap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showMessage('Access Point configured successfully!', 'success');
|
||||||
|
setTimeout(loadAPStatus, 1000);
|
||||||
|
} else {
|
||||||
|
showMessage(`Error: ${result.error || 'Failed to configure AP'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showMessage(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load all data on page load
|
||||||
|
loadEspnowChannel();
|
||||||
|
loadAPStatus();
|
||||||
|
|
||||||
|
// Refresh status every 10 seconds
|
||||||
|
setInterval(loadAPStatus, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
80
src/util/README.md
Normal file
80
src/util/README.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# ESPNow Message Builder
|
||||||
|
|
||||||
|
This utility module provides functions to build ESPNow messages according to the LED Driver API specification.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Message Building
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_message, build_preset_dict, build_select_dict
|
||||||
|
|
||||||
|
# Build a message with presets and select
|
||||||
|
presets = {
|
||||||
|
"red_blink": build_preset_dict({
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
select = build_select_dict({
|
||||||
|
"device1": "red_blink"
|
||||||
|
})
|
||||||
|
|
||||||
|
message = build_message(presets=presets, select=select)
|
||||||
|
# Result: {"v": "1", "presets": {...}, "select": {...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building Select Messages with Step Synchronization
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_message, build_select_dict
|
||||||
|
|
||||||
|
# Select with step for synchronization
|
||||||
|
select = build_select_dict(
|
||||||
|
{"device1": "rainbow_preset", "device2": "rainbow_preset"},
|
||||||
|
step_mapping={"device1": 10, "device2": 10}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = build_message(select=select)
|
||||||
|
# Result: {"v": "1", "select": {"device1": ["rainbow_preset", 10], "device2": ["rainbow_preset", 10]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Converting Presets
|
||||||
|
|
||||||
|
```python
|
||||||
|
from util.espnow_message import build_preset_dict, build_presets_dict
|
||||||
|
|
||||||
|
# Single preset
|
||||||
|
preset = build_preset_dict({
|
||||||
|
"name": "my_preset",
|
||||||
|
"pattern": "rainbow",
|
||||||
|
"colors": ["#FF0000", "#00FF00"], # Can be hex strings or RGB tuples
|
||||||
|
"delay": 100,
|
||||||
|
"brightness": 127,
|
||||||
|
"auto": False,
|
||||||
|
"n1": 2
|
||||||
|
})
|
||||||
|
|
||||||
|
# Multiple presets
|
||||||
|
presets_data = {
|
||||||
|
"preset1": {"pattern": "on", "colors": ["#FF0000"]},
|
||||||
|
"preset2": {"pattern": "blink", "colors": ["#00FF00"]}
|
||||||
|
}
|
||||||
|
presets = build_presets_dict(presets_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
See `docs/API.md` for the complete ESPNow API specification.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Version Field**: All messages include `"v": "1"` for version tracking
|
||||||
|
- **Preset Format**: Presets use hex colour strings (`#RRGGBB`), not RGB tuples
|
||||||
|
- **Select Format**: Select values are always lists: `["preset_name"]` or `["preset_name", step]`
|
||||||
|
- **Colour Conversion**: Automatically converts RGB tuples to hex strings
|
||||||
|
- **Default Values**: Provides sensible defaults for missing fields
|
||||||
194
src/util/espnow_message.py
Normal file
194
src/util/espnow_message.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""
|
||||||
|
Message builder for LED driver API communication.
|
||||||
|
|
||||||
|
Builds JSON messages according to the LED driver API specification
|
||||||
|
for sending presets and select commands over the transport (e.g. serial).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(presets=None, select=None, save=False, default=None):
|
||||||
|
"""
|
||||||
|
Build an API message (presets and/or select) as a JSON string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
presets: Dictionary mapping preset names to preset objects, or None
|
||||||
|
select: Dictionary mapping device names to select lists, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string ready to send over the transport
|
||||||
|
|
||||||
|
Example:
|
||||||
|
message = build_message(
|
||||||
|
presets={
|
||||||
|
"red_blink": {
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select={
|
||||||
|
"device1": ["red_blink"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
message = {
|
||||||
|
"v": "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
if presets:
|
||||||
|
message["presets"] = presets
|
||||||
|
# When sending presets, optionally include a save flag so the
|
||||||
|
# led-driver can persist them.
|
||||||
|
if save:
|
||||||
|
message["save"] = True
|
||||||
|
|
||||||
|
if select:
|
||||||
|
message["select"] = select
|
||||||
|
|
||||||
|
if default is not None:
|
||||||
|
message["default"] = default
|
||||||
|
|
||||||
|
return json.dumps(message)
|
||||||
|
|
||||||
|
|
||||||
|
def build_select_message(device_name, preset_name, step=None):
|
||||||
|
"""
|
||||||
|
Build a select message for a single device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_name: Name of the device
|
||||||
|
preset_name: Name of the preset to select
|
||||||
|
step: Optional step value for synchronization
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with select field ready to use in build_message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
select = build_select_message("device1", "rainbow_preset", step=10)
|
||||||
|
message = build_message(select=select)
|
||||||
|
"""
|
||||||
|
select_list = [preset_name]
|
||||||
|
if step is not None:
|
||||||
|
select_list.append(step)
|
||||||
|
|
||||||
|
return {device_name: select_list}
|
||||||
|
|
||||||
|
|
||||||
|
def build_preset_dict(preset_data):
|
||||||
|
"""
|
||||||
|
Convert preset data to API-compliant format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_data: Dictionary with preset fields (may include name, pattern, colors, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with preset in API-compliant format (without name field)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
preset = build_preset_dict({
|
||||||
|
"name": "red_blink",
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200,
|
||||||
|
"brightness": 255,
|
||||||
|
"auto": True,
|
||||||
|
"n1": 0,
|
||||||
|
"n2": 0,
|
||||||
|
"n3": 0,
|
||||||
|
"n4": 0,
|
||||||
|
"n5": 0,
|
||||||
|
"n6": 0
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
# Ensure colors are in hex format
|
||||||
|
colors = preset_data.get("colors", preset_data.get("c", ["#FFFFFF"]))
|
||||||
|
if colors:
|
||||||
|
# Convert RGB tuples to hex strings if needed
|
||||||
|
if isinstance(colors[0], list) and len(colors[0]) == 3:
|
||||||
|
# RGB tuple format [r, g, b]
|
||||||
|
colors = [f"#{r:02x}{g:02x}{b:02x}" for r, g, b in colors]
|
||||||
|
elif not isinstance(colors[0], str):
|
||||||
|
# Handle other formats - convert to hex
|
||||||
|
colors = ["#FFFFFF"]
|
||||||
|
# Ensure all colors start with #
|
||||||
|
colors = [c if c.startswith("#") else f"#{c}" for c in colors]
|
||||||
|
else:
|
||||||
|
colors = ["#FFFFFF"]
|
||||||
|
|
||||||
|
# Build payload using the short keys expected by led-driver
|
||||||
|
preset = {
|
||||||
|
"p": preset_data.get("pattern", preset_data.get("p", "off")),
|
||||||
|
"c": colors,
|
||||||
|
"d": preset_data.get("delay", preset_data.get("d", 100)),
|
||||||
|
"b": preset_data.get("brightness", preset_data.get("b", preset_data.get("br", 127))),
|
||||||
|
"a": preset_data.get("auto", preset_data.get("a", True)),
|
||||||
|
"n1": preset_data.get("n1", 0),
|
||||||
|
"n2": preset_data.get("n2", 0),
|
||||||
|
"n3": preset_data.get("n3", 0),
|
||||||
|
"n4": preset_data.get("n4", 0),
|
||||||
|
"n5": preset_data.get("n5", 0),
|
||||||
|
"n6": preset_data.get("n6", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset
|
||||||
|
|
||||||
|
|
||||||
|
def build_presets_dict(presets_data):
|
||||||
|
"""
|
||||||
|
Convert multiple presets to API-compliant format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
presets_data: Dictionary mapping preset names to preset data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping preset names to API-compliant preset objects
|
||||||
|
|
||||||
|
Example:
|
||||||
|
presets = build_presets_dict({
|
||||||
|
"red_blink": {
|
||||||
|
"pattern": "blink",
|
||||||
|
"colors": ["#FF0000"],
|
||||||
|
"delay": 200
|
||||||
|
},
|
||||||
|
"blue_pulse": {
|
||||||
|
"pattern": "pulse",
|
||||||
|
"colors": ["#0000FF"],
|
||||||
|
"delay": 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for preset_name, preset_data in presets_data.items():
|
||||||
|
result[preset_name] = build_preset_dict(preset_data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_select_dict(device_preset_mapping, step_mapping=None):
|
||||||
|
"""
|
||||||
|
Build a select dictionary mapping device names to select lists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_preset_mapping: Dictionary mapping device names to preset names
|
||||||
|
step_mapping: Optional dictionary mapping device names to step values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with select field ready to use in build_message
|
||||||
|
|
||||||
|
Example:
|
||||||
|
select = build_select_dict(
|
||||||
|
{"device1": "rainbow_preset", "device2": "pulse_preset"},
|
||||||
|
step_mapping={"device1": 10}
|
||||||
|
)
|
||||||
|
message = build_message(select=select)
|
||||||
|
"""
|
||||||
|
select = {}
|
||||||
|
for device_name, preset_name in device_preset_mapping.items():
|
||||||
|
select_list = [preset_name]
|
||||||
|
if step_mapping and device_name in step_mapping:
|
||||||
|
select_list.append(step_mapping[device_name])
|
||||||
|
select[device_name] = select_list
|
||||||
|
return select
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import network
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
def connect(ssid, password, ip, gateway):
|
|
||||||
if ssid is None or password is None:
|
|
||||||
print("Missing ssid or password")
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
sta_if = network.WLAN(network.STA_IF)
|
|
||||||
if ip is not None and gateway is not None:
|
|
||||||
sta_if.ifconfig((ip, '255.255.255.0', gateway, '1.1.1.1'))
|
|
||||||
if not sta_if.isconnected():
|
|
||||||
print('connecting to network...')
|
|
||||||
sta_if.active(True)
|
|
||||||
sta_if.connect(ssid, password)
|
|
||||||
sleep(0.1)
|
|
||||||
if sta_if.isconnected():
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
return None
|
|
||||||
return sta_if.ifconfig()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to connect to wifi {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ap(ssid, password):
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
ap_mac = ap_if.config('mac')
|
|
||||||
print(ssid)
|
|
||||||
ap_if.active(True)
|
|
||||||
ap_if.config(essid=ssid, password=password)
|
|
||||||
ap_if.active(False)
|
|
||||||
ap_if.active(True)
|
|
||||||
print(ap_if.ifconfig())
|
|
||||||
|
|
||||||
def get_mac():
|
|
||||||
ap_if = network.WLAN(network.AP_IF)
|
|
||||||
return ap_if.config('mac')
|
|
||||||
79
tests/README.md
Normal file
79
tests/README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Tests
|
||||||
|
|
||||||
|
This directory contains tests for the LED Controller project.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
- `test_endpoints.py` - HTTP endpoint tests that mimic web browser requests (runs against 192.168.4.1)
|
||||||
|
- `test_ws.py` - WebSocket tests
|
||||||
|
- `test_p2p.py` - ESP-NOW P2P tests
|
||||||
|
- `models/` - Model unit tests
|
||||||
|
- `web.py` - Local development web server
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Browser Tests (Real Browser Automation)
|
||||||
|
|
||||||
|
Tests the web interface in an actual browser using Selenium:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_browser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests:
|
||||||
|
- Open a real Chrome browser
|
||||||
|
- Navigate to the device at 192.168.4.1
|
||||||
|
- Interact with UI elements (buttons, forms, modals)
|
||||||
|
- Test complete user workflows
|
||||||
|
- Verify visual elements and interactions
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install selenium
|
||||||
|
# Also need ChromeDriver installed and in PATH
|
||||||
|
# Download from: https://chromedriver.chromium.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoint Tests (Browser-like HTTP)
|
||||||
|
|
||||||
|
Tests HTTP endpoints by making requests to the device at 192.168.4.1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_endpoints.py
|
||||||
|
```
|
||||||
|
|
||||||
|
These tests:
|
||||||
|
- Mimic web browser requests with proper headers
|
||||||
|
- Handle cookies for session management
|
||||||
|
- Test all CRUD operations (GET, POST, PUT, DELETE)
|
||||||
|
- Verify responses and status codes
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/test_ws.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
```bash
|
||||||
|
pip install websockets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/models/run_all.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development Server
|
||||||
|
|
||||||
|
Run the local development server (port 5000):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tests/web.py
|
||||||
|
```
|
||||||
182
tests/async_tcp_server.py
Normal file
182
tests/async_tcp_server.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# Standalone async TCP server (stdlib only). Multiple simultaneous clients.
|
||||||
|
# No watchdog: runs on a full host (e.g. Raspberry Pi); ESP32 clients may use WDT.
|
||||||
|
# For RTT latency, clients may send lines like ``rtt 12345`` (ticks); they are echoed back.
|
||||||
|
#
|
||||||
|
# Run from anywhere (default: all IPv4 interfaces, port 9000):
|
||||||
|
# python3 async_tcp_server.py
|
||||||
|
# python3 async_tcp_server.py --port 9000
|
||||||
|
# Localhost only:
|
||||||
|
# python3 async_tcp_server.py --host 127.0.0.1
|
||||||
|
#
|
||||||
|
# Or from this directory:
|
||||||
|
# chmod +x async_tcp_server.py && ./async_tcp_server.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class _ClientRegistry:
|
||||||
|
"""Track writers and broadcast newline-terminated lines to all clients."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._writers: set[asyncio.StreamWriter] = set()
|
||||||
|
|
||||||
|
def add(self, writer: asyncio.StreamWriter) -> None:
|
||||||
|
self._writers.add(writer)
|
||||||
|
|
||||||
|
def remove(self, writer: asyncio.StreamWriter) -> None:
|
||||||
|
self._writers.discard(writer)
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self._writers)
|
||||||
|
|
||||||
|
async def broadcast_line(self, line: str) -> None:
|
||||||
|
data = (line.rstrip("\r\n") + "\n").encode("utf-8")
|
||||||
|
for writer in list(self._writers):
|
||||||
|
try:
|
||||||
|
writer.write(data)
|
||||||
|
await writer.drain()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[tcp] broadcast failed, dropping client: {e}")
|
||||||
|
self._writers.discard(writer)
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _periodic_broadcast(
|
||||||
|
registry: _ClientRegistry,
|
||||||
|
interval_sec: float,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval_sec)
|
||||||
|
if registry.count() == 0:
|
||||||
|
continue
|
||||||
|
line = message.format(t=time.time())
|
||||||
|
print(f"[tcp] broadcast to {registry.count()} client(s): {line!r}")
|
||||||
|
await registry.broadcast_line(line)
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_client(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
registry: _ClientRegistry,
|
||||||
|
) -> None:
|
||||||
|
peer = writer.get_extra_info("peername")
|
||||||
|
print(f"[tcp] connected: {peer}")
|
||||||
|
registry.add(writer)
|
||||||
|
try:
|
||||||
|
while not reader.at_eof():
|
||||||
|
data = await reader.readline()
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
message = data.decode("utf-8", errors="replace").rstrip("\r\n")
|
||||||
|
# Echo newline-delimited lines (simple test harness behaviour).
|
||||||
|
# Clients may send ``rtt <ticks>`` for round-trip timing; echo unchanged.
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
writer.write((message + "\n").encode("utf-8"))
|
||||||
|
await writer.drain()
|
||||||
|
if message.startswith("rtt "):
|
||||||
|
server_ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
print(
|
||||||
|
f"[tcp] echoed rtt from {peer} "
|
||||||
|
f"(host write+drain ~{server_ms:.2f} ms)"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
registry.remove(writer)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
print(f"[tcp] disconnected: {peer}")
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client_handler(registry: _ClientRegistry):
|
||||||
|
async def _handler(
|
||||||
|
reader: asyncio.StreamReader,
|
||||||
|
writer: asyncio.StreamWriter,
|
||||||
|
) -> None:
|
||||||
|
await _handle_client(reader, writer, registry)
|
||||||
|
|
||||||
|
return _handler
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
broadcast_interval: float | None,
|
||||||
|
broadcast_message: str,
|
||||||
|
) -> None:
|
||||||
|
registry = _ClientRegistry()
|
||||||
|
handler = _make_client_handler(registry)
|
||||||
|
server = await asyncio.start_server(handler, host, port)
|
||||||
|
print(f"[tcp] listening on {host}:{port} (Ctrl+C to stop)")
|
||||||
|
if broadcast_interval is not None and broadcast_interval > 0:
|
||||||
|
print(
|
||||||
|
f"[tcp] periodic broadcast every {broadcast_interval}s "
|
||||||
|
f"(use {{t}} in --message for unix time)"
|
||||||
|
)
|
||||||
|
async with server:
|
||||||
|
tasks = []
|
||||||
|
if broadcast_interval is not None and broadcast_interval > 0:
|
||||||
|
tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_periodic_broadcast(registry, broadcast_interval, broadcast_message),
|
||||||
|
name="broadcast",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(server.serve_forever(), *tasks)
|
||||||
|
else:
|
||||||
|
await server.serve_forever()
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
for t in tasks:
|
||||||
|
try:
|
||||||
|
await t
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Standalone asyncio TCP server (multiple connections).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default="0.0.0.0",
|
||||||
|
help="bind address (default: all IPv4 interfaces)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--port", type=int, default=9000, help="bind port")
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
metavar="SEC",
|
||||||
|
help="seconds between broadcast lines to all clients (default: 5)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--message",
|
||||||
|
default="ping {t:.0f}",
|
||||||
|
help='broadcast line (newline added); use "{t}" for time.time() (default: %(default)s)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-broadcast",
|
||||||
|
action="store_true",
|
||||||
|
help="disable periodic broadcast (echo-only)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
interval = None if args.no_broadcast else args.interval
|
||||||
|
try:
|
||||||
|
asyncio.run(_run(args.host, args.port, interval, args.message))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[tcp] stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user